Compare commits

...
Author SHA1 Message Date
Elliot Slusky 548d9e04fe fix(digest): prevent persona fact leakage (#742) 2026-08-14 18:19:54 -07:00
8d90e3dff1 fix: reorder HeuristicRouter rules so low-complexity check precedes math check (#671)
* fix: reorder HeuristicRouter rules so low-complexity check precedes math check

Trivial arithmetic like "what is 2+2?" was escalating to the largest
available model just because it matched the math keyword, since the
math rule ran before the low-complexity rule. Math queries above the
low-complexity threshold still escalate correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: make low-complexity math routing effective

---------

Co-authored-by: Ari <ari.silva@paipe.co>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-08-14 18:18:33 -07:00
b375d7cf09 fix(server): wire WS event bridge to the bus channels actually publish to (#673)
* fix(server): wire WS event bridge to the bus channels actually publish to

include_all_routes() built the /v1/agents/events WebSocket router from
get_event_bus() — a global singleton nothing in `jarvis serve` publishes
to. The real bus lives at app.state.bus (set in server/app.py and handed
to every channel/agent). Events published on it silently never reached
any connected browser client, since the WS router was subscribed to a
different, disconnected EventBus instance entirely.

* fix(server): keep managed agent events on app bus

---------

Co-authored-by: Ari <ari.silva@paipe.co>
Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-08-14 18:18:11 -07:00
c25c649048 fix: strip openrouter/ prefix before forwarding to OpenRouter API (#672)
* fix: strip openrouter/ prefix before forwarding to OpenRouter API

cloud_router.get_provider() correctly detects LiteLLM-style
"openrouter/anthropic/claude-haiku-4.5" strings as the openrouter
provider, but was forwarding them verbatim, so the redundant prefix
reached OpenRouter's API and the request failed.

* fix: preserve native OpenRouter model IDs

---------

Co-authored-by: Ari <ari.silva@paipe.co>
Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-08-14 18:01:53 -07:00
Haotian Zheng f2df968aa5 fix(memory): replace chunks when re-indexing sources (#675) 2026-08-14 17:41:40 -07:00
17 changed files with 496 additions and 105 deletions
@@ -1,8 +1,6 @@
You are Jarvis — the local AI assistant. You are loyal, efficient, dry-witted, and genuinely care about the person you serve. You have a warm British sensibility: polite but never obsequious, witty but never frivolous.
PERSONALITY:
- You anticipate needs before being asked
- You deliver bad news with constructive dry wit: "Your rebuttals appear to have slipped past their deadline, sir. I'd suggest making them your first order of business — before anyone notices."
- Your humor is understated — a raised eyebrow in voice form
- You are calm under pressure and never flustered
- You treat the briefing as a conversation with someone you respect, not a status report
@@ -12,19 +10,7 @@ ADDRESS:
- Use it 2-3 times per briefing: once in greeting, once mid-briefing, once in closing
- Never every sentence — that would be a parody, not Jarvis
EMAIL TRIAGE:
- Important emails are from REAL PEOPLE (not automated senders, newsletters, or marketing)
- Prioritize emails that need a REPLY or DECISION, or contain a DEADLINE
- Skip promotional, automated, and notification emails entirely
- For important emails, mention the sender name and what they need
MESSAGE TRIAGE (iMessage, Slack, etc.):
- Highlight messages from key people and threads needing a reply
- Briefly acknowledge casual threads so the user knows you checked: "Your group chat has been lively but nothing requiring a response"
- Skip reactions, emoji-only messages, and automated notifications
CONSTRAINTS:
- ONLY report facts present in the provided data. Never invent.
- NEVER describe actions you are taking (adjusting lights, ordering food, queuing playlists, etc.)
- No markdown formatting, no emojis, no bullet points, no headers — this is spoken aloud
- If a data source is disconnected or errored, skip it silently — do not mention connection issues
@@ -33,6 +33,33 @@ impl PySQLiteMemory {
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
}
fn replace_source(
&self,
source: &str,
documents: Vec<(String, Option<String>)>,
) -> PyResult<Vec<String>> {
let parsed_documents = documents
.into_iter()
.map(|(content, metadata)| {
let metadata = metadata
.map(|value| serde_json::from_str(&value))
.transpose()
.map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string())
})?;
Ok((content, metadata))
})
.collect::<PyResult<Vec<_>>>()?;
let document_refs = parsed_documents
.iter()
.map(|(content, metadata)| (content.as_str(), metadata.as_ref()))
.collect::<Vec<_>>();
self.inner
.replace_source(source, &document_refs)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
}
#[pyo3(signature = (query, top_k=5))]
fn retrieve(&self, query: &str, top_k: usize) -> PyResult<String> {
let results = self
@@ -94,6 +94,57 @@ impl SQLiteMemory {
pub fn in_memory() -> Result<Self, OpenJarvisError> {
Self::new(Path::new(":memory:"))
}
/// Atomically replace every document for *source* with *documents*.
pub fn replace_source(
&self,
source: &str,
documents: &[(&str, Option<&Value>)],
) -> Result<Vec<String>, OpenJarvisError> {
let mut conn = self.conn.lock();
let tx = conn.transaction().map_err(|e| {
OpenJarvisError::Io(std::io::Error::other(e.to_string()))
})?;
tx.execute(
"DELETE FROM documents_fts
WHERE rowid IN (SELECT rowid FROM documents WHERE source = ?1)",
rusqlite::params![source],
)
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
tx.execute(
"DELETE FROM documents WHERE source = ?1",
rusqlite::params![source],
)
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
let mut doc_ids = Vec::with_capacity(documents.len());
for (content, metadata) in documents {
let doc_id = Uuid::new_v4().to_string();
let meta_str = metadata
.map(|m| serde_json::to_string(m).unwrap_or_default())
.unwrap_or_else(|| "{}".to_string());
tx.execute(
"INSERT INTO documents (id, content, source, metadata)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![doc_id, content, source, meta_str],
)
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
let rowid = tx.last_insert_rowid();
tx.execute(
"INSERT INTO documents_fts (rowid, content, source) VALUES (?1, ?2, ?3)",
rusqlite::params![rowid, content, source],
)
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
doc_ids.push(doc_id);
}
tx.commit()
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
Ok(doc_ids)
}
}
impl MemoryBackend for SQLiteMemory {
@@ -306,6 +357,40 @@ mod tests {
assert_eq!(mem.count().unwrap(), 0);
}
#[test]
fn test_sqlite_replace_source_is_idempotent() {
let mem = SQLiteMemory::in_memory().unwrap();
mem.replace_source("notes.txt", &[("old project notes", None)])
.unwrap();
assert_eq!(mem.count().unwrap(), 1);
mem.replace_source("notes.txt", &[("updated project notes", None)])
.unwrap();
assert_eq!(mem.count().unwrap(), 1);
assert!(mem.retrieve("old", 5).unwrap().is_empty());
let updated = mem.retrieve("updated", 5).unwrap();
assert_eq!(updated.len(), 1);
assert_eq!(updated[0].source, "notes.txt");
}
#[test]
fn test_sqlite_replace_source_preserves_other_sources() {
let mem = SQLiteMemory::in_memory().unwrap();
mem.store("keep this manual", "manual.txt", None).unwrap();
mem.replace_source("notes.txt", &[("old project notes", None)])
.unwrap();
mem.replace_source("notes.txt", &[("updated project notes", None)])
.unwrap();
assert_eq!(mem.count().unwrap(), 2);
let manual = mem.retrieve("manual", 5).unwrap();
assert_eq!(manual.len(), 1);
assert_eq!(manual[0].source, "manual.txt");
}
#[test]
fn test_sqlite_case_insensitive_search() {
let mem = SQLiteMemory::in_memory().unwrap();
+30 -38
View File
@@ -17,6 +17,14 @@ from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall
_SECTION_PROMPTS = {
"messages": "MESSAGES — Prioritize provided messages or tasks needing action.",
"calendar": "CALENDAR — Cover only provided upcoming events.",
"health": "HEALTH — Describe only supported trends; omit raw measurements.",
"world": "WORLD — Summarize only provided world items.",
"music": "MUSIC — Summarize only provided listening information.",
}
def _load_persona(persona_name: str) -> str:
"""Load a persona prompt file by name."""
@@ -56,6 +64,15 @@ class MorningDigestAgent(ToolUsingAgent):
persona_text = _load_persona(self._persona)
now = datetime.now()
honorific = getattr(self, "_honorific", "sir")
sections = dict.fromkeys(
str(section).strip().casefold()
for section in self._sections
if str(section).strip()
)
section_block = "\n".join(
f"- {_SECTION_PROMPTS.get(section, section.upper())}"
for section in sections
)
return (
f"{persona_text}\n\n"
@@ -65,35 +82,16 @@ class MorningDigestAgent(ToolUsingAgent):
"You receive structured data from the user's connected services. "
"The data has ALREADY been collected — it appears in the user "
"message. You do NOT fetch anything yourself.\n\n"
"Produce a 2-4 minute spoken briefing in DECREASING order of "
"importance:\n\n"
"1. GREETING + PRIORITIES — Open with the honorific and "
"immediately state what needs attention: overdue tasks, today's "
"deadlines, events requiring preparation. Connect related items "
"('Your rebuttals are overdue and you have a dinner at 6, so "
"I'd tackle those first').\n\n"
"2. SCHEDULE — Today's upcoming events with time context: 'You "
"have 3 hours before your next meeting.' Skip past events.\n\n"
"3. MESSAGES — Triage across ALL channels (email, texts, Slack):\n"
" - First: messages from real people needing a REPLY or DECISION\n"
" - Second: messages containing deadlines or action items\n"
" - Last: brief acknowledgment of casual threads ('Your group "
"chat has been lively but nothing requiring a response')\n"
" - SKIP automated emails, newsletters, and marketing entirely\n"
" - Quote relevant message text when it helps\n\n"
"4. HEALTH — Interpret trends, not raw numbers. 'Your sleep has "
"improved three nights running and your readiness is strong'"
"not 'HRV 53, HR 56.' If multiple days of data, compare.\n\n"
"5. WORLD — Weather forecast, top news (AI/tech, business, "
"general). Skip if no data.\n\n"
"6. CLOSING — One forward-looking sentence with the honorific.\n\n"
"Produce a concise spoken briefing in decreasing order of importance. "
"Cover only the configured sections below and only when the collected "
"data supports them. Silently omit absent data and sources.\n\n"
f"CONFIGURED SECTIONS:\n{section_block or '- None'}\n\n"
"Open briefly with the honorific and end after the last supported item. "
"Do not add conversational offers or personal asides.\n\n"
"ABSOLUTE RULES (violations are unacceptable):\n"
"- ONLY facts from the data. Zero hallucination.\n"
"- NEVER mention disconnected or unavailable sources.\n"
"- NEVER state raw health numbers. Say 'your sleep was solid' "
"NOT 'heart rate 56 bpm' or 'HRV 53' or '6000 steps' or "
"'readiness 82'. Interpret, never enumerate.\n"
"- NEVER describe actions you are taking.\n"
"- NEVER invent personal context or claim, offer, or suggest actions.\n"
"- Acknowledge every source that returned data, even briefly.\n"
"- No markdown, emojis, bullets, or headers.\n"
"- STRICT LIMIT: 200 words. Be concise."
@@ -147,18 +145,12 @@ class MorningDigestAgent(ToolUsingAgent):
Message(
role=Role.USER,
content=(
f"Here is the collected data from my sources:\n\n"
f"{collected_data}\n\n"
f"Synthesize my morning briefing. Remember:\n"
f"- Priority-first, connect related items\n"
f"- For health: say 'solid', 'improving', 'dipped' "
f"— NEVER say any number (no 82, no 56, no 6000)\n"
f"- Do NOT invent reasons for health changes\n"
f"- Do NOT mention disconnected sources\n"
f"- Do NOT repeat the greeting in your closing\n"
f"- Use the honorific ONLY 2-3 times total\n"
f"- Skip notifications from the user themselves\n"
f"- STRICT LIMIT: 200-250 words maximum"
"The following collected data is the only factual evidence for "
f"the briefing:\n\n<collected_data>\n{collected_data}\n"
"</collected_data>\n\nUse configured sections only. Omit missing "
"data and sources. Do not add personal context or activities. "
"Use the honorific no more than three times and keep the "
"briefing under 200 words."
),
),
]
+33 -9
View File
@@ -89,15 +89,39 @@ def index(
mem = _get_backend(backend)
try:
for chunk in track(chunks, description="Storing chunks...", console=console):
mem.store(
chunk.content,
source=chunk.source,
metadata={
"offset": chunk.offset,
"index": chunk.index,
},
)
replace_source = getattr(mem, "replace_source", None)
if callable(replace_source):
documents_by_source = {}
for chunk in chunks:
documents_by_source.setdefault(chunk.source, []).append(
(
chunk.content,
{
"offset": chunk.offset,
"index": chunk.index,
},
)
)
for source, documents in track(
documents_by_source.items(),
description="Replacing sources...",
console=console,
):
replace_source(source, documents)
else:
for chunk in track(
chunks,
description="Storing chunks...",
console=console,
):
mem.store(
chunk.content,
source=chunk.source,
metadata={
"offset": chunk.offset,
"index": chunk.index,
},
)
finally:
if hasattr(mem, "close"):
mem.close()
+13 -7
View File
@@ -93,11 +93,16 @@ class HeuristicRouter(RouterPolicy):
Rules (applied in order):
1. Code detected → prefer model with "code"/"coder" in name
2. Math detected → prefer larger model
3. Low complexity (score < 0.20) → prefer smaller/faster model
2. Low complexity (score <= 0.20) → prefer smaller/faster model
3. Math detected → prefer larger model
4. High complexity (score >= 0.55 OR reasoning keywords) → prefer larger model
5. High urgency (>0.8) → override to smaller model
6. Default fallback → default_model → fallback_model → first available
Low complexity is checked before the math check so that simple arithmetic
("calculate 2+2") routes to the smallest model instead of always escalating
on the "math" keyword; math problems above the low-complexity threshold
still escalate to the larger model.
"""
def __init__(
@@ -134,14 +139,15 @@ class HeuristicRouter(RouterPolicy):
# Fall through to larger model for code
return _largest_model(available) or available[0]
# Rule 2: Math detected → prefer larger model
# Rule 2: Low complexity → prefer smaller model (checked before the math
# rule so simple arithmetic doesn't escalate to the largest model)
if context.complexity_score <= 0.20:
return _smallest_model(available) or available[0]
# Rule 3: Math detected → prefer larger model
if context.has_math:
return _largest_model(available) or available[0]
# Rule 3: Low complexity → prefer smaller model
if context.complexity_score < 0.20:
return _smallest_model(available) or available[0]
# Rule 4: High complexity or reasoning → prefer larger model
if context.complexity_score >= 0.55 or context.has_reasoning:
return _largest_model(available) or available[0]
+31 -21
View File
@@ -34,6 +34,15 @@ _MEMORY_BACKEND_LOCK_SETUP = threading.Lock()
_MCP_LOCK_SETUP = threading.Lock()
def _get_runtime_event_bus(runtime: Any = None) -> Any:
"""Return the server-owned event bus, falling back outside app runtimes."""
from openjarvis.core.events import get_event_bus
bus = getattr(runtime, "bus", None)
return bus if bus is not None else get_event_bus()
def _start_managed_worker(app_state: Any, target: Any, *, name: str) -> Any:
"""Start and track a managed-agent worker for orderly app shutdown."""
@@ -301,14 +310,13 @@ def _make_lightweight_system(
# Wrap with InstrumentedEngine so agent ticks are recorded
# in telemetry (FLOPs, energy, cost savings).
try:
from openjarvis.core.events import get_event_bus
from openjarvis.telemetry.instrumented_engine import (
InstrumentedEngine,
)
plain_engine = InstrumentedEngine(
plain_engine,
get_event_bus(),
_get_runtime_event_bus(runtime),
)
except Exception:
pass # telemetry is optional
@@ -1644,26 +1652,27 @@ def create_agent_manager_router(
# 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)
app_state = request.app.state
server_engine = getattr(app_state, "engine", None)
server_model = getattr(app_state, "model", "")
server_config = getattr(app_state, "config", None)
server_bus = _get_runtime_event_bus(app_state)
def _run_tick():
try:
from openjarvis.agents.executor import AgentExecutor
from openjarvis.core.events import get_event_bus
_ts = getattr(request.app.state, "trace_store", None)
_ts = getattr(app_state, "trace_store", None)
executor = AgentExecutor(
manager=manager,
event_bus=get_event_bus(),
event_bus=server_bus,
trace_store=_ts,
)
system = _make_lightweight_system(
server_engine,
server_model,
server_config,
request.app.state,
app_state,
)
executor.set_system(system)
# The route handler above already called start_tick() to
@@ -1690,7 +1699,7 @@ def create_agent_manager_router(
try:
_start_managed_worker(
request.app.state,
app_state,
_run_tick,
name=f"managed-agent-run-{agent_id}",
)
@@ -1997,11 +2006,12 @@ def create_agent_manager_router(
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)
_app_state = request.app.state
_srv_engine = getattr(_app_state, "engine", None)
_srv_model = getattr(_app_state, "model", "")
_srv_config = getattr(_app_state, "config", None)
_srv_bus = _get_runtime_event_bus(_app_state)
def _immediate_tick():
_start = _time.time()
@@ -2011,17 +2021,17 @@ def create_agent_manager_router(
_srv_model,
)
try:
_ts2 = getattr(request.app.state, "trace_store", None)
_ts2 = getattr(_app_state, "trace_store", None)
executor = AgentExecutor(
manager=manager,
event_bus=get_event_bus(),
event_bus=_srv_bus,
trace_store=_ts2,
)
system = _make_lightweight_system(
_srv_engine,
_srv_model,
_srv_config,
request.app.state,
_app_state,
)
executor.set_system(system)
logger.info(
@@ -2055,7 +2065,7 @@ def create_agent_manager_router(
try:
_start_managed_worker(
request.app.state,
_app_state,
_immediate_tick,
name=f"managed-agent-immediate-{agent_id}",
)
@@ -2106,12 +2116,12 @@ def create_agent_manager_router(
return {"learning_log": manager.list_learning_log(agent_id)}
@agents_router.post("/{agent_id}/learning/run")
def trigger_learning(agent_id: str):
def trigger_learning(agent_id: str, request: Request):
if not manager.get_agent(agent_id):
raise HTTPException(status_code=404, detail="Agent not found")
from openjarvis.core.events import EventType, get_event_bus
from openjarvis.core.events import EventType
bus = get_event_bus()
bus = _get_runtime_event_bus(request.app.state)
bus.publish(EventType.AGENT_LEARNING_STARTED, {"agent_id": agent_id})
return {"status": "triggered"}
+6 -2
View File
@@ -1087,12 +1087,16 @@ def include_all_routes(app) -> None:
except ImportError:
pass
# WebSocket bridge for real-time agent events
# WebSocket bridge for real-time agent events. Must subscribe on the
# same EventBus instance channels/agents actually publish to
# (app.state.bus, set in server/app.py) — the get_event_bus() global
# singleton is a *different* bus that nothing in `jarvis serve` ever
# publishes to, so events silently never reached this endpoint.
try:
from openjarvis.core.events import get_event_bus
from openjarvis.server.ws_bridge import create_ws_router
ws_router = create_ws_router(get_event_bus())
ws_router = create_ws_router(getattr(app.state, "bus", None) or get_event_bus())
app.include_router(ws_router)
except Exception:
logger.debug("WebSocket bridge not available", exc_info=True)
+12 -1
View File
@@ -84,6 +84,17 @@ def is_cloud_model(model: str) -> bool:
return get_provider(model) is not None
def _openrouter_model_id(model: str) -> str:
"""Return the provider-facing ID for an OpenRouter model."""
prefix = "openrouter/"
candidate = model.removeprefix(prefix)
# OpenRouter owns IDs such as "openrouter/auto" itself. Only remove the
# LiteLLM routing prefix when the remainder is still a provider/model ID.
if model.startswith(prefix) and "/" in candidate:
return candidate
return model
# ---------------------------------------------------------------------------
# Message conversion
# ---------------------------------------------------------------------------
@@ -371,7 +382,7 @@ async def stream_cloud(
"OPENROUTER_API_KEY not set — add it in the Cloud Models tab"
)
async for token in _stream_openai(
model,
_openrouter_model_id(model),
messages,
temperature,
max_tokens,
+23
View File
@@ -95,6 +95,29 @@ class SQLiteMemory(MemoryBackend):
)
return doc_id
def replace_source(
self,
source: str,
documents: List[tuple[str, Optional[Dict[str, Any]]]],
) -> List[str]:
"""Atomically replace all documents associated with *source*."""
payload = [
(content, json.dumps(metadata) if metadata else None)
for content, metadata in documents
]
doc_ids = self._rust_impl.replace_source(source, payload)
bus = get_event_bus()
for doc_id in doc_ids:
bus.publish(
EventType.MEMORY_STORE,
{
"backend": self.backend_id,
"doc_id": doc_id,
"source": source,
},
)
return doc_ids
def retrieve(
self,
query: str,
+15 -3
View File
@@ -21,7 +21,7 @@ def test_morning_digest_run(tmp_path):
mock_engine = MagicMock()
mock_engine.generate.return_value = {
"content": "Good morning sir. You have 3 emails and 2 meetings today.",
"content": "Good morning sir. AtlasDB 1.0 was released.",
"finish_reason": "stop",
"usage": {},
}
@@ -29,7 +29,7 @@ def test_morning_digest_run(tmp_path):
# Mock collect result
mock_collect_result = ToolResult(
tool_name="digest_collect",
content='=== MESSAGES ===\n[gmail] From: alice@co.com — "Budget" (1h ago)\n',
content="=== WORLD ===\n[hackernews] AtlasDB 1.0 Released — 241 points\n",
success=True,
metadata={"total_items": 2},
)
@@ -46,7 +46,9 @@ def test_morning_digest_run(tmp_path):
mock_engine,
"test-model",
tools=[],
persona="neutral",
persona="jarvis",
sections=["world"],
section_sources={"world": ["hackernews", "news_rss"]},
digest_store_path=str(tmp_path / "digest.db"),
)
@@ -61,6 +63,16 @@ def test_morning_digest_run(tmp_path):
assert "Good morning" in result.content
assert result.turns == 1
assert len(result.tool_results) == 2
assert set(result.metadata["sources_used"]) == {"hackernews", "news_rss"}
prompt = "\n".join(
message.text for message in mock_engine.generate.call_args.args[0]
).casefold()
assert "world —" in prompt
for forbidden in (
"messages —|calendar —|health —|rebuttal|dinner at|group chat|"
"slack|next meeting|readiness|hrv|weather"
).split("|"):
assert forbidden not in prompt
def test_load_persona():
+29
View File
@@ -40,6 +40,35 @@ def test_memory_index_file(tmp_path: Path, monkeypatch):
assert "Indexed" in result.output or "chunk" in result.output
def test_memory_index_replaces_existing_source(tmp_path: Path, monkeypatch):
"""Re-indexing a file replaces its previous chunks."""
_register_sqlite()
db_path = str(tmp_path / "mem.db")
doc = tmp_path / "doc.txt"
doc.write_text(" ".join(["legacy"] * 100), encoding="utf-8")
mod = importlib.import_module("openjarvis.cli.memory_cmd")
monkeypatch.setattr(
mod,
"_get_backend",
lambda b=None: SQLiteMemory(db_path=db_path),
)
first = CliRunner().invoke(cli, ["memory", "index", str(doc)])
assert first.exit_code == 0
doc.write_text(" ".join(["updated"] * 100), encoding="utf-8")
second = CliRunner().invoke(cli, ["memory", "index", str(doc)])
assert second.exit_code == 0
backend = SQLiteMemory(db_path=db_path)
assert backend.count() == 1
assert backend.retrieve("legacy") == []
updated = backend.retrieve("updated")
assert len(updated) == 1
assert updated[0].source == str(doc)
def test_memory_index_nonexistent(tmp_path: Path):
"""Indexing a nonexistent path should fail."""
_register_sqlite()
+17 -5
View File
@@ -88,13 +88,25 @@ class TestHeuristicRouter:
router = HeuristicRouter(
available_models=["small", "large", "coder"],
)
ctx = RoutingContext(
query="solve x",
query_length=7,
has_math=True,
)
ctx = build_routing_context("solve the integral of x^2 dx")
assert ctx.has_math is True
assert ctx.complexity_score > 0.20
assert router.select_model(ctx) == "large"
def test_low_complexity_math_prefers_small(self) -> None:
"""Regression test: a trivial math query ("calculate 2+2") must not
escalate to the largest model just because it contains a math
keyword the low-complexity rule takes priority over the math rule.
"""
_register_models()
router = HeuristicRouter(
available_models=["small", "large", "coder"],
)
ctx = build_routing_context("calculate 2+2")
assert ctx.has_math is True
assert ctx.complexity_score == 0.20
assert router.select_model(ctx) == "small"
def test_high_complexity_prefers_large(self) -> None:
_register_models()
router = HeuristicRouter(
+3 -5
View File
@@ -77,11 +77,9 @@ class TestRouterWithNewModels:
router = HeuristicRouter(
available_models=NEW_LOCAL_MODELS,
)
ctx = RoutingContext(
query="solve the integral of x^2 dx",
query_length=29,
has_math=True,
)
ctx = build_routing_context("solve the integral of x^2 dx")
assert ctx.has_math is True
assert ctx.complexity_score > 0.20
selected = router.select_model(ctx)
assert selected == "gpt-oss:120b"
+33
View File
@@ -605,6 +605,39 @@ class TestLightweightSystemEngineResolution:
)
assert captured["key"] == "llamacpp"
def test_instrumented_engine_uses_runtime_event_bus(self, monkeypatch):
pytest.importorskip("fastapi")
from openjarvis.core.events import EventBus
from openjarvis.server import agent_manager_routes as amr
from openjarvis.telemetry import instrumented_engine
resolved_engine = MagicMock()
wrapped_engine = MagicMock()
runtime_bus = EventBus()
runtime = SimpleNamespace(
bus=runtime_bus,
memory_backend=object(),
channel_backend=None,
channel_bridge=None,
knowledge_db_path=None,
)
monkeypatch.setattr(
"openjarvis.engine._discovery.get_engine",
MagicMock(return_value=("resolved", resolved_engine)),
)
instrumented = MagicMock(return_value=wrapped_engine)
monkeypatch.setattr(instrumented_engine, "InstrumentedEngine", instrumented)
system = amr._make_lightweight_system(
engine=MagicMock(),
model="m",
config=self._cfg("vllm", "ollama"),
runtime=runtime,
)
instrumented.assert_called_once_with(resolved_engine, runtime_bus)
assert system.engine is wrapped_engine
def test_caches_tool_memory_backend_when_prompt_context_is_disabled(
self,
monkeypatch,
+49
View File
@@ -0,0 +1,49 @@
"""Regression tests for OpenRouter model ID normalization."""
from __future__ import annotations
import pytest
from openjarvis.core.types import Message
from openjarvis.server import cloud_router
def test_get_provider_detects_bare_openrouter_id():
assert cloud_router.get_provider("anthropic/claude-haiku-4.5") == "openrouter"
def test_get_provider_detects_litellm_prefixed_openrouter_id():
model = "openrouter/anthropic/claude-haiku-4.5"
assert cloud_router.get_provider(model) == "openrouter"
@pytest.mark.parametrize(
"requested_model,expected_forwarded_model",
[
("anthropic/claude-haiku-4.5", "anthropic/claude-haiku-4.5"),
("openrouter/anthropic/claude-haiku-4.5", "anthropic/claude-haiku-4.5"),
("openrouter/auto", "openrouter/auto"),
],
)
@pytest.mark.asyncio
async def test_stream_cloud_normalizes_openrouter_model_before_forwarding(
monkeypatch, requested_model, expected_forwarded_model
):
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
captured: dict[str, str] = {}
async def fake_stream_openai(model, messages, temperature, max_tokens, **kwargs):
captured["model"] = model
yield "ok"
monkeypatch.setattr(cloud_router, "_stream_openai", fake_stream_openai)
tokens = [
token
async for token in cloud_router.stream_cloud(
requested_model, [Message(role="user", content="hi")]
)
]
assert tokens == ["ok"]
assert captured["model"] == expected_forwarded_model
+90
View File
@@ -3,8 +3,10 @@
from __future__ import annotations
import asyncio
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@@ -161,3 +163,91 @@ class TestWSBridge:
]
asyncio.run(exercise())
class TestIncludeAllRoutesBusWiring:
"""Regression: the WS endpoint must subscribe on the same EventBus that
channels/agents actually publish to (app.state.bus), not the unrelated
get_event_bus() global singleton publishing on the latter used to
silently never reach any connected browser client."""
def test_uses_app_state_bus_not_global_singleton(self):
from openjarvis.core.events import reset_event_bus
from openjarvis.server.api_routes import include_all_routes
reset_event_bus() # isolate from other tests' global singleton state
app = FastAPI()
real_bus = EventBus()
app.state.bus = real_bus
include_all_routes(app)
client = TestClient(app)
with client.websocket_connect("/v1/agents/events") as ws:
real_bus.publish(EventType.AGENT_TICK_START, {"agent_id": "test-123"})
time.sleep(0.05)
data = ws.receive_json()
assert data["data"]["agent_id"] == "test-123"
@pytest.mark.parametrize(
("path", "payload"),
[
("/v1/managed-agents/test-123/run", None),
(
"/v1/managed-agents/test-123/messages",
{"content": "run now", "mode": "immediate", "stream": False},
),
],
)
def test_managed_agent_run_paths_publish_to_app_bus(self, path, payload):
from openjarvis.agents.executor import AgentExecutor
from openjarvis.core.events import reset_event_bus
from openjarvis.server.api_routes import include_all_routes
reset_event_bus()
app_bus = EventBus()
manager = MagicMock()
manager.get_agent.return_value = {
"id": "test-123",
"name": "test",
"status": "idle",
"config": {},
}
manager.send_message.return_value = {
"id": "message-123",
"agent_id": "test-123",
"content": "run now",
"mode": "immediate",
}
app = FastAPI()
app.state.bus = app_bus
app.state.agent_manager = manager
include_all_routes(app)
executed = threading.Event()
observed_buses = []
def publish_tick(executor, agent_id, **_kwargs):
observed_buses.append(executor._bus)
executor._bus.publish(EventType.AGENT_TICK_START, {"agent_id": agent_id})
executed.set()
client = TestClient(app)
with (
patch.object(AgentExecutor, "execute_tick", publish_tick),
patch(
"openjarvis.server.agent_manager_routes._make_lightweight_system",
return_value=MagicMock(),
) as make_system,
client.websocket_connect("/v1/agents/events?agent_id=test-123") as ws,
):
request_kwargs = {"json": payload} if payload is not None else {}
response = client.post(path, **request_kwargs)
assert response.status_code == 200
assert executed.wait(timeout=1)
assert observed_buses == [app_bus]
data = ws.receive_json()
assert data["type"] == "agent_tick_start"
assert data["data"]["agent_id"] == "test-123"
make_system.assert_called_once()