mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-15 09:21:56 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d90e3dff1 | ||
|
|
b375d7cf09 | ||
|
|
c25c649048 | ||
|
|
f2df968aa5 | ||
|
|
8145597052 | ||
|
|
aec96f9d5d | ||
|
|
c2e1c375aa | ||
|
|
ef28e5f84b | ||
|
|
f2483e7bf3 | ||
|
|
156d41d2f9 | ||
|
|
6e40d87eb5 |
@@ -0,0 +1,174 @@
|
||||
# Data-boundary scan
|
||||
|
||||
`jarvis scan --data-boundaries` reports application-level data boundaries in the
|
||||
current OpenJarvis configuration. It complements the existing host/environment
|
||||
scan, which checks OS posture such as disk encryption, cloud-sync agents, remote
|
||||
access tools, and exposed engine ports.
|
||||
|
||||
The data-boundary scan is a configuration diagnostic. It is not a vulnerability
|
||||
scanner, a legal privacy assessment, a network monitor, or an OAuth-scope audit.
|
||||
|
||||
## Run the scan
|
||||
|
||||
```bash
|
||||
jarvis scan --data-boundaries
|
||||
jarvis scan --data-boundaries --json
|
||||
jarvis scan --data-boundaries --json --show-paths
|
||||
jarvis scan --data-boundaries --strict
|
||||
```
|
||||
|
||||
`--strict` exits with status code `1` when the report contains either a `fail`
|
||||
or a `warn` finding. Use it when CI or pre-demo checks need to enforce a
|
||||
conservative local-only posture.
|
||||
|
||||
Without `--strict`, the command always exits `0` even when fail or warn findings
|
||||
are present. This is useful for exploratory review.
|
||||
|
||||
On a fresh `jarvis init` configuration, common warn findings include
|
||||
`server.host = "0.0.0.0"` and `telemetry.enabled = true`. Running
|
||||
`jarvis scan --data-boundaries --strict` after init therefore exits `1` until
|
||||
those defaults are tightened.
|
||||
|
||||
Absolute paths and connector file basenames are redacted by default so JSON
|
||||
reports can be pasted into issues without revealing local usernames, mount
|
||||
points, or account labels. Use `--show-paths` only for local debugging.
|
||||
|
||||
## What it checks
|
||||
|
||||
The scan inspects configuration values, environment-variable presence, and the
|
||||
existence of known local runtime files. It does not read private content from
|
||||
memory databases, trace databases, connector credentials, prompt files, logs, or
|
||||
OAuth token files.
|
||||
|
||||
The current checks cover:
|
||||
|
||||
- cloud-capable model provider, engine, and default model settings
|
||||
- local memory context injection combined with cloud-capable inference
|
||||
- traces, telemetry, learning, training, and spec-search settings
|
||||
- automatic memory service (`tools.storage.enabled` / `[memory].enabled`)
|
||||
- deep research engine and model settings
|
||||
- security bypass flags when cloud inference is configured
|
||||
- unset `security.profile` (informational)
|
||||
- web search, browser, local file, shell, code, knowledge chunk scanning, and MCP tool surfaces
|
||||
- local knowledge.db composition with cloud-capable Deep Research targets
|
||||
- server binding and unauthenticated A2A exposure
|
||||
- channel enablement, channel credential fields, and channel credential env vars
|
||||
- skills, skill auto-sync, digest sources, and cloud speech/TTS backends such as Cartesia
|
||||
- local stores such as `knowledge.db`, `credentials.toml`, `memory.db`, `traces.db`,
|
||||
`telemetry.db`, `scheduler.db`, embeddings, skill index, `.vault_key`, and memory files
|
||||
- connector credential files under `connectors/*.json`, without reading them
|
||||
- API-key and other runtime credential environment variables (presence only)
|
||||
- a scope note for frontend credential storage when cloud/API-key surfaces exist
|
||||
|
||||
Configured database paths (for example `traces.db_path` or `memory.db_path`)
|
||||
are resolved from config when set, not only the default locations under the
|
||||
OpenJarvis home directory.
|
||||
|
||||
Static Deep Research targeting uses configuration only (no request overrides):
|
||||
`deep_research.engine` or `engine.default`, and `deep_research.model` or
|
||||
`server.model` or `intelligence.default_model`.
|
||||
|
||||
Model identifiers that contain vendor names (for example `deepseek-r1` or
|
||||
`openai/gpt-oss`) are not treated as cloud-bound when their effective engine is
|
||||
explicitly local, such as Ollama.
|
||||
|
||||
## Status levels
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `fail` | A configuration composition is likely incompatible with strict local-only use. |
|
||||
| `warn` | A configured surface may send data outside the local runtime or persist sensitive data. |
|
||||
| `info` | A relevant setting or local store exists, with no immediate fail or warn condition. |
|
||||
|
||||
The command reports potential data paths. It does not prove that a path has been
|
||||
used during a specific run.
|
||||
|
||||
JSON output includes `"schema_version": 1` for stable downstream parsing.
|
||||
|
||||
## Strict local-only checklist
|
||||
|
||||
For a conservative local-only setup, review these settings:
|
||||
|
||||
```toml
|
||||
[analytics]
|
||||
enabled = false
|
||||
|
||||
[traces]
|
||||
enabled = false
|
||||
|
||||
[telemetry]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
context_from_memory = false
|
||||
|
||||
[intelligence]
|
||||
provider = ""
|
||||
preferred_engine = ""
|
||||
default_model = "" # local model name only
|
||||
|
||||
[engine]
|
||||
default = "ollama" # or another local engine
|
||||
|
||||
[tools]
|
||||
enabled = ""
|
||||
|
||||
[tools.storage]
|
||||
enabled = false
|
||||
|
||||
[tools.mcp]
|
||||
enabled = false
|
||||
servers = ""
|
||||
|
||||
[channel]
|
||||
enabled = false
|
||||
|
||||
[learning]
|
||||
enabled = false
|
||||
auto_update = false
|
||||
training_enabled = false
|
||||
|
||||
[learning.spec_search]
|
||||
enabled = false
|
||||
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
|
||||
[security]
|
||||
profile = "personal"
|
||||
|
||||
[a2a]
|
||||
enabled = false
|
||||
```
|
||||
|
||||
Also unset cloud and channel credentials from the process environment when they
|
||||
are not needed.
|
||||
|
||||
## Scope and non-goals
|
||||
|
||||
The scan intentionally avoids reading private data. In particular, it does not:
|
||||
|
||||
- read connector JSON contents or OAuth scopes
|
||||
- inspect browser `localStorage` or Tauri secure storage
|
||||
- inspect frontend credential storage directly
|
||||
- inspect installed skill source code
|
||||
- intercept runtime network traffic
|
||||
- classify provider retention or training policies
|
||||
- prove that a configured path was used at runtime
|
||||
|
||||
Frontend credential storage is tracked separately from this CLI diagnostic. If a
|
||||
cloud/API-key surface is present, the scan emits an informational scope note so
|
||||
users know that browser/Tauri credential storage must be reviewed separately.
|
||||
|
||||
## Configuration resolution
|
||||
|
||||
The scan follows the same explicit configuration override used by the runtime:
|
||||
if `OPENJARVIS_CONFIG` is set, that file is audited. Otherwise the scan uses
|
||||
the default OpenJarvis config path under the resolved OpenJarvis home. If the
|
||||
home directory cannot be resolved, the command reports a `config-root-error`
|
||||
finding instead of crashing.
|
||||
|
||||
## See also
|
||||
|
||||
- [Security](security.md) — three-layer security model (host scan, config scan, BoundaryGuard)
|
||||
- [Configuration](../getting-started/configuration.md) — full config reference
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
OpenJarvis includes a security layer that scans prompts and model outputs for secrets, personally identifiable information (PII), and sensitive file paths. The system is designed to be composable: scanners run as a pipeline, and the `GuardrailsEngine` wrapper drops in front of any inference backend without changing how the rest of your code works.
|
||||
|
||||
## Three layers of security review
|
||||
|
||||
OpenJarvis separates host posture, application data boundaries, and runtime prompt guardrails:
|
||||
|
||||
| Layer | Command / component | What it checks |
|
||||
| --- | --- | --- |
|
||||
| Host scan | `jarvis scan` | Disk encryption, cloud-sync agents, exposed engine ports, remote-access tools |
|
||||
| Data-boundary scan | `jarvis scan --data-boundaries` | Configured inference, memory, traces, channels, tools, and local stores |
|
||||
| Runtime guardrails | `GuardrailsEngine` / BoundaryGuard | Secrets, PII, and file-policy violations in live prompts and outputs |
|
||||
|
||||
Use the host scan before storing sensitive data on the machine. Use the data-boundary scan to verify whether your `config.toml` is local-only, cloud-capable, or mixed. Use BoundaryGuard during inference when you need live redaction or blocking.
|
||||
|
||||
See [Data Boundary Scan](data-boundary-scan.md) for the application config diagnostic and [BoundaryGuard](#guardrailsengine) below for runtime scanning.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
@@ -446,8 +460,15 @@ guarded = GuardrailsEngine(
|
||||
|
||||
---
|
||||
|
||||
## Data boundary scan
|
||||
|
||||
See [Data Boundary Scan](data-boundary-scan.md) for the application config diagnostic (`jarvis scan --data-boundaries`).
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Data Boundary Scan](data-boundary-scan.md) — application config and local-store diagnostic (`jarvis scan --data-boundaries`)
|
||||
- [Architecture: Security](../architecture/security.md) — pipeline design, event flow, and file policy integration
|
||||
- [API Reference: Security](../api-reference/openjarvis/security/index.md) — full class and function signatures
|
||||
- [Tools](tools.md) — how `FileReadTool` uses file policy
|
||||
|
||||
@@ -103,6 +103,24 @@ function CopyMessageButton({ content }: { content: string }) {
|
||||
export function MessageBubble({ message, isLive = false }: Props) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
const cleanContent = useMemo(() => stripThinkTags(message.content), [message.content]);
|
||||
|
||||
// Build a ref→source lookup once per render. Memoized so the rehype plugin
|
||||
// identity stays stable until the source list actually changes.
|
||||
const sourcesMap = useMemo(() => {
|
||||
const m = new Map<number, NonNullable<ChatMessage['researchSources']>[number]>();
|
||||
for (const s of message.researchSources ?? []) {
|
||||
if (typeof s.ref === 'number') m.set(s.ref, s);
|
||||
}
|
||||
return m;
|
||||
}, [message.researchSources]);
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
const base: any[] = [[rehypeHighlight, { detect: true }], rehypeKatex];
|
||||
if (sourcesMap.size > 0) base.push([rehypeCitations, { sources: sourcesMap }]);
|
||||
return base;
|
||||
}, [sourcesMap]);
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end mb-4">
|
||||
@@ -122,24 +140,6 @@ export function MessageBubble({ message, isLive = false }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const cleanContent = useMemo(() => stripThinkTags(message.content), [message.content]);
|
||||
|
||||
// Build a ref→source lookup once per render. Memoized so the rehype plugin
|
||||
// identity stays stable until the source list actually changes.
|
||||
const sourcesMap = useMemo(() => {
|
||||
const m = new Map<number, NonNullable<ChatMessage['researchSources']>[number]>();
|
||||
for (const s of message.researchSources ?? []) {
|
||||
if (typeof s.ref === 'number') m.set(s.ref, s);
|
||||
}
|
||||
return m;
|
||||
}, [message.researchSources]);
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
const base: any[] = [[rehypeHighlight, { detect: true }], rehypeKatex];
|
||||
if (sourcesMap.size > 0) base.push([rehypeCitations, { sources: sourcesMap }]);
|
||||
return base;
|
||||
}, [sourcesMap]);
|
||||
|
||||
return (
|
||||
<div className="group mb-6">
|
||||
{/* Deep Research timeline (steps + status) */}
|
||||
|
||||
@@ -198,6 +198,7 @@ nav:
|
||||
- Benchmarks: user-guide/benchmarks.md
|
||||
- System Access: user-guide/system-access.md
|
||||
- Security: user-guide/security.md
|
||||
- Data Boundary Scan: user-guide/data-boundary-scan.md
|
||||
- LLM-guided spec search: user-guide/llm-guided-spec-search.md
|
||||
- Leaderboard: leaderboard.md
|
||||
- Roadmap: development/roadmap.md
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as _pkg_version
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from openjarvis.sdk import Jarvis, JarvisSystem, MemoryHandle, SystemBuilder
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.sdk import Jarvis, JarvisSystem, MemoryHandle, SystemBuilder
|
||||
|
||||
try:
|
||||
__version__ = _pkg_version("openjarvis")
|
||||
@@ -13,3 +15,21 @@ except PackageNotFoundError: # pragma: no cover — uninstalled source tree
|
||||
__version__ = "0.0.0+unknown"
|
||||
|
||||
__all__ = ["Jarvis", "JarvisSystem", "MemoryHandle", "SystemBuilder", "__version__"]
|
||||
|
||||
_SDK_EXPORTS = {"Jarvis", "JarvisSystem", "MemoryHandle", "SystemBuilder"}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Load SDK exports lazily so lightweight CLI diagnostics can start safely."""
|
||||
if name not in _SDK_EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
from openjarvis import sdk
|
||||
|
||||
value = getattr(sdk, name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | _SDK_EXPORTS)
|
||||
|
||||
@@ -127,6 +127,7 @@ class MonitorOperativeAgent(ToolUsingAgent):
|
||||
memory_backend: Optional[Any] = None,
|
||||
interactive: bool = False,
|
||||
confirm_callback=None,
|
||||
prompt_builder: Optional[Any] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@@ -139,7 +140,7 @@ class MonitorOperativeAgent(ToolUsingAgent):
|
||||
max_tokens=max_tokens,
|
||||
interactive=interactive,
|
||||
confirm_callback=confirm_callback,
|
||||
prompt_builder=kwargs.get("prompt_builder"),
|
||||
prompt_builder=prompt_builder,
|
||||
)
|
||||
# Validate strategies
|
||||
if memory_extraction not in VALID_MEMORY_EXTRACTION:
|
||||
|
||||
@@ -58,6 +58,7 @@ class OperativeAgent(ToolUsingAgent):
|
||||
memory_backend: Optional[Any] = None,
|
||||
interactive: bool = False,
|
||||
confirm_callback=None,
|
||||
prompt_builder: Optional[Any] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@@ -70,7 +71,7 @@ class OperativeAgent(ToolUsingAgent):
|
||||
max_tokens=max_tokens,
|
||||
interactive=interactive,
|
||||
confirm_callback=confirm_callback,
|
||||
prompt_builder=kwargs.get("prompt_builder"),
|
||||
prompt_builder=prompt_builder,
|
||||
)
|
||||
self._system_prompt = system_prompt or ""
|
||||
self._operator_id = operator_id
|
||||
|
||||
+129
-99
@@ -2,45 +2,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
import openjarvis
|
||||
from openjarvis.cli._bootstrap import bootstrap_cmd
|
||||
from openjarvis.cli.add_cmd import add
|
||||
from openjarvis.cli.agent_cmd import agent
|
||||
from openjarvis.cli.ask import ask
|
||||
from openjarvis.cli.bench_cmd import bench
|
||||
from openjarvis.cli.channel_cmd import channel
|
||||
from openjarvis.cli.channels_cmd import channels
|
||||
from openjarvis.cli.chat_cmd import chat
|
||||
from openjarvis.cli.compose_cmd import compose
|
||||
from openjarvis.cli.config_cmd import config
|
||||
from openjarvis.cli.connect_cmd import connect
|
||||
from openjarvis.cli.daemon_cmd import restart, start, status, stop
|
||||
from openjarvis.cli.digest_cmd import digest
|
||||
from openjarvis.cli.doctor_cmd import doctor
|
||||
from openjarvis.cli.eval_cmd import eval_group
|
||||
from openjarvis.cli.feedback_cmd import feedback_group
|
||||
from openjarvis.cli.gateway_cmd import gateway
|
||||
from openjarvis.cli.host_cmd import host
|
||||
from openjarvis.cli.init_cmd import init
|
||||
from openjarvis.cli.memory_cmd import memory
|
||||
from openjarvis.cli.mine_cmd import mine
|
||||
from openjarvis.cli.model import model
|
||||
from openjarvis.cli.operators_cmd import operators
|
||||
from openjarvis.cli.optimize_cmd import optimize_group
|
||||
from openjarvis.cli.pearl_cmd import pearl
|
||||
from openjarvis.cli.quickstart_cmd import quickstart
|
||||
from openjarvis.cli.registry_cmd import registry
|
||||
from openjarvis.cli.scan_cmd import scan
|
||||
from openjarvis.cli.scheduler_cmd import scheduler
|
||||
from openjarvis.cli.self_update_cmd import self_update
|
||||
from openjarvis.cli.serve import serve
|
||||
from openjarvis.cli.skill_cmd import skill
|
||||
from openjarvis.cli.telemetry_cmd import telemetry
|
||||
from openjarvis.cli.tool_cmd import tool
|
||||
from openjarvis.cli.vault_cmd import vault
|
||||
from openjarvis.cli.workflow_cmd import workflow
|
||||
|
||||
|
||||
def _invoked_command(argv: list[str]) -> str:
|
||||
"""Return the first positional CLI token after global flags."""
|
||||
for arg in argv:
|
||||
if arg.startswith("-"):
|
||||
continue
|
||||
return arg
|
||||
return ""
|
||||
|
||||
|
||||
# A data-boundary scan must be able to diagnose an invalid OPENJARVIS_HOME.
|
||||
# Importing the rest of the CLI eagerly would import core.config and resolve that
|
||||
# path before the scan can turn the failure into a finding.
|
||||
_DATA_BOUNDARY_BOOTSTRAP = (
|
||||
_invoked_command(sys.argv[1:]) == "scan" and "--data-boundaries" in sys.argv[1:]
|
||||
)
|
||||
|
||||
|
||||
def _should_skip_update_check(ctx: click.Context, argv: list[str]) -> bool:
|
||||
"""Return true for commands whose diagnostics should remain local-only."""
|
||||
if "--research" in argv:
|
||||
return True
|
||||
return ctx.invoked_subcommand == "scan" and "--data-boundaries" in argv
|
||||
|
||||
|
||||
@click.group(
|
||||
@@ -63,11 +54,13 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None:
|
||||
# Check for updates on interactive commands. The banner is noise in
|
||||
# demo recordings of ``jarvis ask --research``, so skip it whenever
|
||||
# the research flag is in argv (cheap argv sniff — Click hasn't
|
||||
# parsed the subcommand's args yet at this point).
|
||||
# parsed the subcommand's args yet at this point). Also skip
|
||||
# ``jarvis scan --data-boundaries`` because it is intended to be a
|
||||
# local application-data diagnostic with no outbound calls.
|
||||
import sys
|
||||
|
||||
research_mode_active = "--research" in sys.argv
|
||||
if not quiet and ctx.invoked_subcommand and not research_mode_active:
|
||||
skip_update_check = _should_skip_update_check(ctx, sys.argv[1:])
|
||||
if not quiet and ctx.invoked_subcommand and not skip_update_check:
|
||||
import threading
|
||||
|
||||
from openjarvis.cli._version_check import check_for_updates
|
||||
@@ -91,74 +84,111 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None:
|
||||
check_and_route(ctx)
|
||||
|
||||
|
||||
cli.add_command(init, "init")
|
||||
cli.add_command(ask, "ask")
|
||||
cli.add_command(chat, "chat")
|
||||
cli.add_command(serve, "serve")
|
||||
cli.add_command(model, "model")
|
||||
cli.add_command(memory, "memory")
|
||||
cli.add_command(mine, "mine")
|
||||
cli.add_command(pearl, "pearl")
|
||||
cli.add_command(telemetry, "telemetry")
|
||||
cli.add_command(bench, "bench")
|
||||
cli.add_command(channel, "channel")
|
||||
cli.add_command(channels, "channels")
|
||||
cli.add_command(scheduler, "scheduler")
|
||||
cli.add_command(doctor, "doctor")
|
||||
cli.add_command(agent, "agents")
|
||||
cli.add_command(workflow, "workflow")
|
||||
cli.add_command(skill, "skill")
|
||||
cli.add_command(start, "start")
|
||||
cli.add_command(stop, "stop")
|
||||
cli.add_command(restart, "restart")
|
||||
cli.add_command(status, "status")
|
||||
cli.add_command(vault, "vault")
|
||||
cli.add_command(add, "add")
|
||||
cli.add_command(operators, "operators")
|
||||
cli.add_command(eval_group, "eval")
|
||||
cli.add_command(host, "host")
|
||||
cli.add_command(quickstart, "quickstart")
|
||||
cli.add_command(optimize_group, "optimize")
|
||||
cli.add_command(feedback_group, "feedback")
|
||||
cli.add_command(compose, "compose")
|
||||
cli.add_command(gateway, "gateway")
|
||||
cli.add_command(tool, "tool")
|
||||
cli.add_command(registry, "registry")
|
||||
cli.add_command(config, "config")
|
||||
cli.add_command(scan, "scan")
|
||||
cli.add_command(connect, "connect")
|
||||
cli.add_command(digest, "digest")
|
||||
# deep-research setup pulls the ingestion pipeline (embeddings/numpy). Guard it
|
||||
# so a broken or slow numpy on Windows — which can raise at IMPORT time, not
|
||||
# just ImportError (#404) — can never take down the whole CLI, including
|
||||
# `jarvis serve`. Invoking `jarvis deep-research-setup` without the deps still
|
||||
# errors clearly on demand.
|
||||
try:
|
||||
from openjarvis.cli.deep_research_setup_cmd import deep_research_setup
|
||||
if not _DATA_BOUNDARY_BOOTSTRAP:
|
||||
from openjarvis.cli._bootstrap import bootstrap_cmd
|
||||
from openjarvis.cli.add_cmd import add
|
||||
from openjarvis.cli.agent_cmd import agent
|
||||
from openjarvis.cli.ask import ask
|
||||
from openjarvis.cli.bench_cmd import bench
|
||||
from openjarvis.cli.channel_cmd import channel
|
||||
from openjarvis.cli.channels_cmd import channels
|
||||
from openjarvis.cli.chat_cmd import chat
|
||||
from openjarvis.cli.compose_cmd import compose
|
||||
from openjarvis.cli.config_cmd import config
|
||||
from openjarvis.cli.connect_cmd import connect
|
||||
from openjarvis.cli.daemon_cmd import restart, start, status, stop
|
||||
from openjarvis.cli.digest_cmd import digest
|
||||
from openjarvis.cli.doctor_cmd import doctor
|
||||
from openjarvis.cli.eval_cmd import eval_group
|
||||
from openjarvis.cli.feedback_cmd import feedback_group
|
||||
from openjarvis.cli.gateway_cmd import gateway
|
||||
from openjarvis.cli.host_cmd import host
|
||||
from openjarvis.cli.init_cmd import init
|
||||
from openjarvis.cli.memory_cmd import memory
|
||||
from openjarvis.cli.mine_cmd import mine
|
||||
from openjarvis.cli.model import model
|
||||
from openjarvis.cli.operators_cmd import operators
|
||||
from openjarvis.cli.optimize_cmd import optimize_group
|
||||
from openjarvis.cli.pearl_cmd import pearl
|
||||
from openjarvis.cli.quickstart_cmd import quickstart
|
||||
from openjarvis.cli.registry_cmd import registry
|
||||
from openjarvis.cli.scheduler_cmd import scheduler
|
||||
from openjarvis.cli.self_update_cmd import self_update
|
||||
from openjarvis.cli.serve import serve
|
||||
from openjarvis.cli.skill_cmd import skill
|
||||
from openjarvis.cli.telemetry_cmd import telemetry
|
||||
from openjarvis.cli.tool_cmd import tool
|
||||
from openjarvis.cli.vault_cmd import vault
|
||||
from openjarvis.cli.workflow_cmd import workflow
|
||||
|
||||
cli.add_command(deep_research_setup, "deep-research-setup")
|
||||
cli.add_command(deep_research_setup, "research")
|
||||
except Exception as _dr_exc:
|
||||
import logging as _logging
|
||||
cli.add_command(init, "init")
|
||||
cli.add_command(ask, "ask")
|
||||
cli.add_command(chat, "chat")
|
||||
cli.add_command(serve, "serve")
|
||||
cli.add_command(model, "model")
|
||||
cli.add_command(memory, "memory")
|
||||
cli.add_command(mine, "mine")
|
||||
cli.add_command(pearl, "pearl")
|
||||
cli.add_command(telemetry, "telemetry")
|
||||
cli.add_command(bench, "bench")
|
||||
cli.add_command(channel, "channel")
|
||||
cli.add_command(channels, "channels")
|
||||
cli.add_command(scheduler, "scheduler")
|
||||
cli.add_command(doctor, "doctor")
|
||||
cli.add_command(agent, "agents")
|
||||
cli.add_command(workflow, "workflow")
|
||||
cli.add_command(skill, "skill")
|
||||
cli.add_command(start, "start")
|
||||
cli.add_command(stop, "stop")
|
||||
cli.add_command(restart, "restart")
|
||||
cli.add_command(status, "status")
|
||||
cli.add_command(vault, "vault")
|
||||
cli.add_command(add, "add")
|
||||
cli.add_command(operators, "operators")
|
||||
cli.add_command(eval_group, "eval")
|
||||
cli.add_command(host, "host")
|
||||
cli.add_command(quickstart, "quickstart")
|
||||
cli.add_command(optimize_group, "optimize")
|
||||
cli.add_command(feedback_group, "feedback")
|
||||
cli.add_command(compose, "compose")
|
||||
cli.add_command(gateway, "gateway")
|
||||
cli.add_command(tool, "tool")
|
||||
cli.add_command(registry, "registry")
|
||||
cli.add_command(config, "config")
|
||||
cli.add_command(connect, "connect")
|
||||
cli.add_command(digest, "digest")
|
||||
|
||||
_logging.getLogger(__name__).debug("deep-research command unavailable: %s", _dr_exc)
|
||||
cli.add_command(self_update, "self-update")
|
||||
cli.add_command(bootstrap_cmd, "_bootstrap")
|
||||
# Deep Research setup pulls the ingestion pipeline (embeddings/numpy). Guard
|
||||
# it so an import-time dependency failure cannot take down the whole CLI.
|
||||
try:
|
||||
from openjarvis.cli.deep_research_setup_cmd import deep_research_setup
|
||||
|
||||
# Gateway CLI commands (lazy import to avoid pulling starlette)
|
||||
try:
|
||||
from openjarvis.cli.auth_cmd import auth
|
||||
cli.add_command(deep_research_setup, "deep-research-setup")
|
||||
cli.add_command(deep_research_setup, "research")
|
||||
except Exception as _dr_exc:
|
||||
import logging as _logging
|
||||
|
||||
cli.add_command(auth, "auth")
|
||||
except ImportError:
|
||||
pass
|
||||
_logging.getLogger(__name__).debug(
|
||||
"deep-research command unavailable: %s", _dr_exc
|
||||
)
|
||||
cli.add_command(self_update, "self-update")
|
||||
cli.add_command(bootstrap_cmd, "_bootstrap")
|
||||
|
||||
try:
|
||||
from openjarvis.cli.tunnel_cmd import tunnel
|
||||
# Gateway CLI commands (lazy import to avoid pulling starlette)
|
||||
try:
|
||||
from openjarvis.cli.auth_cmd import auth
|
||||
|
||||
cli.add_command(tunnel, "tunnel")
|
||||
except ImportError:
|
||||
pass
|
||||
cli.add_command(auth, "auth")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from openjarvis.cli.tunnel_cmd import tunnel
|
||||
|
||||
cli.add_command(tunnel, "tunnel")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -398,9 +398,8 @@ def _run_agent(
|
||||
|
||||
# Wire the SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md persona
|
||||
# files actually reach the model. Only passed to agents whose __init__
|
||||
# accepts a `prompt_builder` kwarg (BaseAgent does; agents that override
|
||||
# __init__ without forwarding it, e.g. OrchestratorAgent, opt out
|
||||
# automatically and keep their existing system-prompt machinery).
|
||||
# explicitly accepts a `prompt_builder` kwarg. Agents with specialized
|
||||
# prompt machinery opt in by naming and forwarding the parameter.
|
||||
import inspect as _inspect
|
||||
|
||||
if "prompt_builder" in _inspect.signature(agent_cls.__init__).parameters:
|
||||
|
||||
@@ -311,12 +311,14 @@ def chat(
|
||||
# Generate response even when optional memory context is unavailable.
|
||||
try:
|
||||
if agent is not None:
|
||||
agent_context = None
|
||||
if agent_context_message is not None:
|
||||
from openjarvis.agents._stubs import AgentContext
|
||||
from openjarvis.agents._stubs import AgentContext
|
||||
|
||||
agent_context = AgentContext()
|
||||
agent_context = AgentContext()
|
||||
if agent_context_message is not None:
|
||||
agent_context.conversation.add(agent_context_message)
|
||||
for msg in history[:-1]:
|
||||
if msg.role != Role.SYSTEM:
|
||||
agent_context.conversation.add(msg)
|
||||
response = agent.run(user_input, context=agent_context)
|
||||
content = (
|
||||
response.content if hasattr(response, "content") else str(response)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
@@ -11,7 +12,11 @@ from typing import Callable, List
|
||||
|
||||
import click
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.paths import get_config_dir, get_config_path
|
||||
from openjarvis.security.data_boundary_audit import (
|
||||
DataBoundaryReport,
|
||||
build_data_boundary_report,
|
||||
)
|
||||
|
||||
# Engine ports that should only be listening on localhost.
|
||||
_ENGINE_PORTS = {11434, 8080, 8000, 30000, 1234, 52415, 18181}
|
||||
@@ -441,10 +446,45 @@ _RICH_ICONS = {
|
||||
"ok": "[green]\u2713[/green]",
|
||||
"warn": "[yellow]![/yellow]",
|
||||
"fail": "[red]\u2717[/red]",
|
||||
"info": "[blue]i[/blue]",
|
||||
"skip": "[dim]-[/dim]",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_data_boundary_config_path() -> Path:
|
||||
env_config = os.environ.get("OPENJARVIS_CONFIG")
|
||||
if env_config:
|
||||
return Path(env_config).expanduser().resolve()
|
||||
return get_config_path()
|
||||
|
||||
|
||||
def _load_data_boundary_config():
|
||||
"""Load config without treating missing config as active."""
|
||||
root = None
|
||||
root_error = ""
|
||||
try:
|
||||
root = get_config_dir()
|
||||
config_path = _resolve_data_boundary_config_path()
|
||||
except Exception as exc:
|
||||
config_path = None
|
||||
root_error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
if root_error:
|
||||
return None, root, False, "", root_error
|
||||
|
||||
try:
|
||||
from openjarvis.core.config import JarvisConfig, load_config
|
||||
except Exception as exc:
|
||||
return None, root, False, f"{type(exc).__name__}: {exc}", ""
|
||||
|
||||
if config_path is None or not config_path.exists():
|
||||
return JarvisConfig(), root, False, "", root_error
|
||||
try:
|
||||
return load_config(config_path), root, True, "", root_error
|
||||
except Exception as exc:
|
||||
return JarvisConfig(), root, False, f"{type(exc).__name__}: {exc}", root_error
|
||||
|
||||
|
||||
def _render_results(results: List[ScanResult]) -> None:
|
||||
"""Render scan results as a Rich table."""
|
||||
from rich.console import Console
|
||||
@@ -494,17 +534,123 @@ def _render_results(results: List[ScanResult]) -> None:
|
||||
console.print()
|
||||
|
||||
|
||||
def _render_data_boundary_report(
|
||||
report: DataBoundaryReport,
|
||||
*,
|
||||
show_paths: bool,
|
||||
) -> None:
|
||||
"""Render application data-boundary findings as a Rich table."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
console.print()
|
||||
console.print("[bold]OpenJarvis Data-Boundary Scan[/bold]")
|
||||
console.print(f"Verdict: [bold]{report.verdict}[/bold]")
|
||||
console.print()
|
||||
|
||||
table = Table(show_header=True, header_style="bold", show_lines=True)
|
||||
table.add_column("", width=3, justify="center")
|
||||
table.add_column("Finding")
|
||||
table.add_column("Recommendation")
|
||||
|
||||
for finding in report.findings:
|
||||
icon = _RICH_ICONS.get(finding.status, "?")
|
||||
style = {"fail": "red", "warn": "yellow", "info": "blue"}.get(
|
||||
finding.status,
|
||||
"white",
|
||||
)
|
||||
details = [f"[{style}]{finding.title}[/{style}]"]
|
||||
details.append(f"[dim]{finding.potential_data_path}[/dim]")
|
||||
if finding.location:
|
||||
location = finding.absolute_location if show_paths else finding.location
|
||||
details.append(f"[dim]Location: {location}[/dim]")
|
||||
table.add_row(icon, "\n".join(details), finding.recommendation)
|
||||
|
||||
console.print(table)
|
||||
summary = report.summary()
|
||||
console.print()
|
||||
console.print(
|
||||
f" [red]{summary['fail']} fail[/red], "
|
||||
f"[yellow]{summary['warn']} warning(s)[/yellow], "
|
||||
f"[blue]{summary['info']} info[/blue]"
|
||||
)
|
||||
if not show_paths:
|
||||
console.print(
|
||||
" [dim]Absolute paths and connector basenames are redacted by default. "
|
||||
"Use --show-paths for local debugging.[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def _emit_data_boundary_json(
|
||||
report: DataBoundaryReport,
|
||||
*,
|
||||
show_paths: bool,
|
||||
) -> None:
|
||||
click.echo(json.dumps(report.to_dict(show_paths=show_paths), indent=2))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--quick", is_flag=True, default=False, help="Run only critical checks.")
|
||||
@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.")
|
||||
def scan(quick: bool, as_json: bool) -> None:
|
||||
@click.option(
|
||||
"--data-boundaries",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Run application data-boundary checks instead of host checks.",
|
||||
)
|
||||
@click.option(
|
||||
"--strict",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Exit non-zero if data-boundary fail or warn findings are present.",
|
||||
)
|
||||
@click.option(
|
||||
"--show-paths",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Show absolute paths in data-boundary output.",
|
||||
)
|
||||
def scan(
|
||||
quick: bool,
|
||||
as_json: bool,
|
||||
data_boundaries: bool,
|
||||
strict: bool,
|
||||
show_paths: bool,
|
||||
) -> None:
|
||||
"""Audit your environment for privacy and security risks."""
|
||||
if data_boundaries:
|
||||
if quick:
|
||||
raise click.UsageError("--quick cannot be combined with --data-boundaries.")
|
||||
config, root, config_loaded, config_error, root_error = (
|
||||
_load_data_boundary_config()
|
||||
)
|
||||
report = build_data_boundary_report(
|
||||
config,
|
||||
root,
|
||||
config_loaded=config_loaded,
|
||||
config_error=config_error,
|
||||
root_error=root_error,
|
||||
)
|
||||
if as_json:
|
||||
_emit_data_boundary_json(report, show_paths=show_paths)
|
||||
else:
|
||||
_render_data_boundary_report(report, show_paths=show_paths)
|
||||
summary = report.summary()
|
||||
if strict and (summary["fail"] or summary["warn"]):
|
||||
raise click.exceptions.Exit(1)
|
||||
return
|
||||
|
||||
if strict or show_paths:
|
||||
raise click.UsageError(
|
||||
"--strict and --show-paths are only supported with --data-boundaries."
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -514,7 +660,7 @@ def scan(quick: bool, as_json: bool) -> None:
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
click.echo(json_mod.dumps(output, indent=2))
|
||||
click.echo(json.dumps(output, indent=2))
|
||||
return
|
||||
|
||||
if not results:
|
||||
|
||||
@@ -367,6 +367,27 @@ def serve(
|
||||
if getattr(agent_cls, "accepts_tools", False):
|
||||
agent_kwargs["max_turns"] = config.agent.max_turns
|
||||
|
||||
# Wire the SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md
|
||||
# reach the model on the SERVE path too. ``ask.py`` has done
|
||||
# this since the persona system landed; ``serve.py`` never did,
|
||||
# so an agent served over HTTP silently answered as a generic
|
||||
# assistant while the same agent via the CLI kept its persona.
|
||||
# Guarded so agents with specialized prompt machinery must opt
|
||||
# in by explicitly naming and forwarding the kwarg.
|
||||
import inspect as _inspect
|
||||
|
||||
if (
|
||||
"prompt_builder"
|
||||
in _inspect.signature(agent_cls.__init__).parameters
|
||||
):
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
agent_kwargs["prompt_builder"] = SystemPromptBuilder(
|
||||
agent_template=config.agent.default_system_prompt or "",
|
||||
memory_files_config=config.memory_files,
|
||||
system_prompt_config=config.system_prompt,
|
||||
)
|
||||
|
||||
agent = agent_cls(engine, model_name, **agent_kwargs)
|
||||
# Pin MCP transports to the agent's lifetime so HTTP
|
||||
# connections don't close mid-request (#461).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -516,6 +516,20 @@ class Jarvis:
|
||||
existing = agent_kwargs.get("tools", [])
|
||||
agent_kwargs["tools"] = digest_tools + list(existing)
|
||||
|
||||
# Wire the SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md reach
|
||||
# the model — mirrors ``cli/ask.py`` and ``cli/serve.py``. Guarded so
|
||||
# agents whose ``__init__`` doesn't accept the kwarg opt out.
|
||||
import inspect as _inspect
|
||||
|
||||
if "prompt_builder" in _inspect.signature(agent_cls.__init__).parameters:
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
agent_kwargs["prompt_builder"] = SystemPromptBuilder(
|
||||
agent_template=self._config.agent.default_system_prompt or "",
|
||||
memory_files_config=self._config.memory_files,
|
||||
system_prompt_config=self._config.system_prompt,
|
||||
)
|
||||
|
||||
agent_obj = agent_cls(self._engine, model_name, **agent_kwargs)
|
||||
ctx = AgentContext()
|
||||
|
||||
|
||||
@@ -4,27 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from typing import Any, Optional
|
||||
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.security._stubs import BaseScanner
|
||||
from openjarvis.security.audit import AuditLogger
|
||||
from openjarvis.security.file_policy import (
|
||||
DEFAULT_SENSITIVE_PATTERNS,
|
||||
filter_sensitive_paths,
|
||||
is_sensitive_file,
|
||||
)
|
||||
from openjarvis.security.guardrails import GuardrailsEngine, SecurityBlockError
|
||||
from openjarvis.security.scanner import PIIScanner, SecretScanner
|
||||
from openjarvis.security.ssrf import check_ssrf, is_private_ip
|
||||
from openjarvis.security.types import (
|
||||
RedactionMode,
|
||||
ScanFinding,
|
||||
ScanResult,
|
||||
SecurityEvent,
|
||||
SecurityEventType,
|
||||
ThreatLevel,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,6 +33,12 @@ def setup_security(
|
||||
if not config.security.enabled:
|
||||
return SecurityContext(engine=engine)
|
||||
|
||||
from openjarvis.security._stubs import BaseScanner
|
||||
from openjarvis.security.audit import AuditLogger
|
||||
from openjarvis.security.guardrails import GuardrailsEngine
|
||||
from openjarvis.security.scanner import PIIScanner, SecretScanner
|
||||
from openjarvis.security.types import RedactionMode
|
||||
|
||||
# Scanners + engine wrapping
|
||||
try:
|
||||
scanners: list[BaseScanner] = []
|
||||
@@ -121,3 +110,46 @@ __all__ = [
|
||||
"is_sensitive_file",
|
||||
"setup_security",
|
||||
]
|
||||
|
||||
_LAZY_EXPORTS = {
|
||||
"AuditLogger": ("openjarvis.security.audit", "AuditLogger"),
|
||||
"BaseScanner": ("openjarvis.security._stubs", "BaseScanner"),
|
||||
"DEFAULT_SENSITIVE_PATTERNS": (
|
||||
"openjarvis.security.file_policy",
|
||||
"DEFAULT_SENSITIVE_PATTERNS",
|
||||
),
|
||||
"GuardrailsEngine": ("openjarvis.security.guardrails", "GuardrailsEngine"),
|
||||
"PIIScanner": ("openjarvis.security.scanner", "PIIScanner"),
|
||||
"RedactionMode": ("openjarvis.security.types", "RedactionMode"),
|
||||
"ScanFinding": ("openjarvis.security.types", "ScanFinding"),
|
||||
"ScanResult": ("openjarvis.security.types", "ScanResult"),
|
||||
"SecretScanner": ("openjarvis.security.scanner", "SecretScanner"),
|
||||
"SecurityBlockError": (
|
||||
"openjarvis.security.guardrails",
|
||||
"SecurityBlockError",
|
||||
),
|
||||
"SecurityEvent": ("openjarvis.security.types", "SecurityEvent"),
|
||||
"SecurityEventType": ("openjarvis.security.types", "SecurityEventType"),
|
||||
"ThreatLevel": ("openjarvis.security.types", "ThreatLevel"),
|
||||
"check_ssrf": ("openjarvis.security.ssrf", "check_ssrf"),
|
||||
"filter_sensitive_paths": (
|
||||
"openjarvis.security.file_policy",
|
||||
"filter_sensitive_paths",
|
||||
),
|
||||
"is_private_ip": ("openjarvis.security.ssrf", "is_private_ip"),
|
||||
"is_sensitive_file": ("openjarvis.security.file_policy", "is_sensitive_file"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
target = _LAZY_EXPORTS.get(name)
|
||||
if target is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_name, attribute = target
|
||||
value = getattr(import_module(module_name), attribute)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(_LAZY_EXPORTS))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -396,11 +396,10 @@ class TestPersonaFilesReachModel:
|
||||
assert "MEMORY_SENTINEL" in joined
|
||||
assert "USER_SENTINEL" in joined
|
||||
|
||||
def test_orchestrator_keeps_its_own_system_prompt(
|
||||
def test_orchestrator_accepts_persona_prompt_builder(
|
||||
self, runner, monkeypatch, tmp_path
|
||||
):
|
||||
"""OrchestratorAgent's __init__ doesn't accept ``prompt_builder``;
|
||||
the wiring must skip it silently rather than crash."""
|
||||
"""Orchestrator explicitly accepts and applies persona wiring."""
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
soul = tmp_path / "SOUL.md"
|
||||
@@ -425,5 +424,6 @@ class TestPersonaFilesReachModel:
|
||||
):
|
||||
result = runner.invoke(cli, ["ask", "--agent", "orchestrator", "Hello"])
|
||||
|
||||
# Pass condition: doesn't crash with TypeError on prompt_builder kwarg.
|
||||
assert result.exit_code == 0, result.output
|
||||
messages = engine.generate.call_args.args[0]
|
||||
assert "ORCH_PERSONA_SENTINEL" in messages[0].content
|
||||
|
||||
+101
-1
@@ -17,7 +17,7 @@ from openjarvis.cli.chat_cmd import _read_input, chat
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.core.events import Event, EventBus, EventType
|
||||
from openjarvis.core.registry import AgentRegistry, ToolRegistry
|
||||
from openjarvis.core.types import ToolCall, ToolResult
|
||||
from openjarvis.core.types import Role, ToolCall, ToolResult
|
||||
from openjarvis.memory.store import LocalFactStore
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
@@ -195,6 +195,106 @@ class TestChatAgents:
|
||||
assert "simple ok" in result.output
|
||||
assert "failed" not in result.output.lower()
|
||||
|
||||
def test_agent_receives_prior_turn_history(self) -> None:
|
||||
"""Multi-turn chat must pass prior turns to agent.run() via AgentContext."""
|
||||
|
||||
captured_contexts: list[AgentContext | None] = []
|
||||
|
||||
class _CapturingAgent(BaseAgent):
|
||||
agent_id = "capturing_chat_agent"
|
||||
|
||||
def run(self, input, context: AgentContext | None = None, **kwargs):
|
||||
captured_contexts.append(context)
|
||||
return AgentResult(content=f"reply-{len(captured_contexts)}", turns=1)
|
||||
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
config = JarvisConfig()
|
||||
config.intelligence.default_model = "test-model"
|
||||
|
||||
AgentRegistry.register_value("capturing_chat_agent", _CapturingAgent)
|
||||
|
||||
with (
|
||||
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
|
||||
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
chat,
|
||||
["--agent", "capturing_chat_agent", "--model", "test-model"],
|
||||
input="first turn\nsecond turn\n/quit\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert len(captured_contexts) == 2
|
||||
|
||||
first_turn_context, second_turn_context = captured_contexts
|
||||
assert first_turn_context is not None
|
||||
assert first_turn_context.conversation.messages == []
|
||||
|
||||
assert second_turn_context is not None
|
||||
prior_texts = [m.content for m in second_turn_context.conversation.messages]
|
||||
assert "first turn" in prior_texts
|
||||
assert "reply-1" in prior_texts
|
||||
|
||||
def test_agent_memory_context_precedes_prior_turn_history(self, tmp_path) -> None:
|
||||
"""Memory system context must remain ahead of prior conversation turns."""
|
||||
|
||||
captured_contexts: list[AgentContext | None] = []
|
||||
|
||||
class _CapturingAgent(BaseAgent):
|
||||
agent_id = "capturing_memory_chat_agent"
|
||||
|
||||
def run(self, input, context: AgentContext | None = None, **kwargs):
|
||||
captured_contexts.append(context)
|
||||
return AgentResult(content=f"reply-{len(captured_contexts)}", turns=1)
|
||||
|
||||
facts_path = tmp_path / "facts.jsonl"
|
||||
LocalFactStore(facts_path).add("The user likes jazz", source="auto")
|
||||
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
config = JarvisConfig()
|
||||
config.intelligence.default_model = "test-model"
|
||||
config.memory.enabled = True
|
||||
config.memory.facts_path = str(facts_path)
|
||||
config.agent.context_from_memory = True
|
||||
|
||||
AgentRegistry.register_value(
|
||||
"capturing_memory_chat_agent",
|
||||
_CapturingAgent,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
|
||||
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
patch("openjarvis.memory.build_memory_service", return_value=None),
|
||||
patch("openjarvis.cli.ask._get_memory_backend", return_value=None),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
chat,
|
||||
["--agent", "capturing_memory_chat_agent", "--model", "test-model"],
|
||||
input="first turn\nsecond turn\n/quit\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert len(captured_contexts) == 2
|
||||
|
||||
second_turn_context = captured_contexts[1]
|
||||
assert second_turn_context is not None
|
||||
messages = second_turn_context.conversation.messages
|
||||
assert [message.role for message in messages] == [
|
||||
Role.SYSTEM,
|
||||
Role.USER,
|
||||
Role.ASSISTANT,
|
||||
]
|
||||
assert "user likes jazz" in messages[0].content
|
||||
assert [message.content for message in messages[1:]] == [
|
||||
"first turn",
|
||||
"reply-1",
|
||||
]
|
||||
|
||||
def test_memory_service_started_fed_and_stopped(self) -> None:
|
||||
"""The REPL starts memory, publishes each turn, and stops it."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from openjarvis.cli.scan_cmd import PrivacyScanner, ScanResult, scan
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
|
||||
def _low_noise_config():
|
||||
"""Baseline config with no warn/fail findings under an empty scan root.
|
||||
|
||||
JarvisConfig defaults include absolute store paths under the real
|
||||
OPENJARVIS_HOME; clear those so tests only see artifacts under tmp_path.
|
||||
"""
|
||||
config = JarvisConfig()
|
||||
config.analytics.enabled = False
|
||||
config.traces.enabled = False
|
||||
config.telemetry.enabled = False
|
||||
config.agent.context_from_memory = False
|
||||
config.agent.tools = ""
|
||||
config.skills.enabled = False
|
||||
config.digest.enabled = False
|
||||
config.channel.enabled = False
|
||||
config.learning.enabled = False
|
||||
config.learning.training_enabled = False
|
||||
config.learning.auto_update = False
|
||||
config.learning.spec_search.enabled = False
|
||||
config.tools.enabled = ""
|
||||
config.tools.mcp.enabled = False
|
||||
config.tools.storage.enabled = False
|
||||
config.optimize.optimizer_provider = ""
|
||||
config.optimize.judge_model = ""
|
||||
config.server.host = "127.0.0.1"
|
||||
config.security.profile = "personal"
|
||||
# Avoid scanning the developer's real ~/.openjarvis store files.
|
||||
config.traces.db_path = ""
|
||||
config.telemetry.db_path = ""
|
||||
config.security.audit_log_path = ""
|
||||
config.security.vault_key_path = ""
|
||||
config.tools.storage.db_path = ""
|
||||
config.tools.storage.facts_path = ""
|
||||
config.sessions.db_path = ""
|
||||
config.agent_manager.db_path = ""
|
||||
config.optimize.db_path = ""
|
||||
config.scheduler.db_path = ""
|
||||
config.skills.index_dir = ""
|
||||
config.memory_files.soul_path = ""
|
||||
config.memory_files.memory_path = ""
|
||||
config.memory_files.user_path = ""
|
||||
return config
|
||||
|
||||
|
||||
def _patch_config(monkeypatch, tmp_path, config, config_loaded=True, error=""):
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.cli.scan_cmd._load_data_boundary_config",
|
||||
lambda: (config, tmp_path, config_loaded, error, ""),
|
||||
)
|
||||
monkeypatch.setattr("openjarvis.cli.scan_cmd.get_config_dir", lambda: tmp_path)
|
||||
|
||||
|
||||
def test_scan_data_boundaries_json_redacts_paths(monkeypatch, tmp_path):
|
||||
config = _low_noise_config()
|
||||
(tmp_path / "traces.db").write_text("", encoding="utf-8")
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["schema_version"] == 1
|
||||
assert payload["root"] != str(tmp_path.resolve())
|
||||
assert str(tmp_path.resolve()) not in result.output
|
||||
assert "findings" in payload
|
||||
|
||||
|
||||
def test_scan_data_boundaries_show_paths_json(monkeypatch, tmp_path):
|
||||
config = _low_noise_config()
|
||||
trace_db = tmp_path / "traces.db"
|
||||
trace_db.write_text("", encoding="utf-8")
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
scan,
|
||||
["--data-boundaries", "--json", "--show-paths"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["root"] == str(tmp_path.resolve())
|
||||
assert "traces.db" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_handles_config_load_error(monkeypatch, tmp_path):
|
||||
config = _low_noise_config()
|
||||
_patch_config(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
config,
|
||||
config_loaded=False,
|
||||
error="TOMLDecodeError: invalid config",
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["summary"]["fail"] == 1
|
||||
assert payload["findings"][0]["id"] == "config-load-error"
|
||||
|
||||
|
||||
def test_scan_data_boundaries_strict_exits_nonzero_on_fail(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config = _low_noise_config()
|
||||
config.intelligence.provider = "openai"
|
||||
config.agent.context_from_memory = True
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--strict"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "local memory may be sent to cloud inference" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_fail_exits_zero_without_strict(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config = _low_noise_config()
|
||||
config.intelligence.provider = "openai"
|
||||
config.agent.context_from_memory = True
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "local memory may be sent to cloud inference" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_strict_exits_on_warning(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config = _low_noise_config()
|
||||
config.tools.enabled = "web_search"
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--strict"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Web search tool is configured" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_strict_passes_with_info_only(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config = _low_noise_config()
|
||||
config.agent.context_from_memory = True
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--strict"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "OpenJarvis Data-Boundary Scan" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_init_defaults_strict_exits_on_warn(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config = _low_noise_config()
|
||||
config.server.host = "0.0.0.0"
|
||||
config.telemetry.enabled = True
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--strict"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "bind all" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_rejects_quick(monkeypatch, tmp_path):
|
||||
config = _low_noise_config()
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--quick", "--data-boundaries"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "cannot be combined" in result.output
|
||||
|
||||
|
||||
def test_scan_rejects_strict_without_data_boundaries():
|
||||
result = CliRunner().invoke(scan, ["--strict"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "only supported with --data-boundaries" in result.output
|
||||
|
||||
|
||||
def test_existing_scan_json_still_works(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
PrivacyScanner,
|
||||
"run_all",
|
||||
lambda self: [
|
||||
ScanResult(
|
||||
name="Network Exposure",
|
||||
status="ok",
|
||||
message="No exposed ports.",
|
||||
platform="all",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload[0]["name"] == "Network Exposure"
|
||||
assert payload[0]["status"] == "ok"
|
||||
|
||||
|
||||
def test_existing_scan_quick_json_still_works(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
PrivacyScanner,
|
||||
"run_quick",
|
||||
lambda self: [
|
||||
ScanResult(
|
||||
name="Cloud Sync Agents",
|
||||
status="ok",
|
||||
message="No cloud-sync agents detected.",
|
||||
platform="all",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--quick", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload[0]["name"] == "Cloud Sync Agents"
|
||||
|
||||
|
||||
def test_top_level_cli_registers_data_boundary_scan(monkeypatch, tmp_path):
|
||||
from openjarvis.cli import cli
|
||||
|
||||
config = _low_noise_config()
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(cli, ["scan", "--data-boundaries", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["schema_version"] == 1
|
||||
assert "summary" in payload
|
||||
|
||||
|
||||
def test_top_level_scan_data_boundaries_does_not_check_for_updates(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
import sys
|
||||
|
||||
from openjarvis.cli import cli
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
called = {"value": False}
|
||||
|
||||
def fake_check_for_updates(_subcommand):
|
||||
called["value"] = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.cli._version_check.check_for_updates",
|
||||
fake_check_for_updates,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.cli.scan_cmd._load_data_boundary_config",
|
||||
lambda: (JarvisConfig(), tmp_path, False, "", ""),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["jarvis", "scan", "--data-boundaries", "--json"],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["scan", "--data-boundaries", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert called["value"] is False
|
||||
|
||||
|
||||
def test_update_check_skip_helper_is_precise():
|
||||
from click import Command, Context
|
||||
|
||||
from openjarvis.cli import _should_skip_update_check
|
||||
|
||||
ctx = Context(Command("jarvis"))
|
||||
ctx.invoked_subcommand = "scan"
|
||||
assert _should_skip_update_check(ctx, ["scan", "--data-boundaries"])
|
||||
|
||||
ctx.invoked_subcommand = "ask"
|
||||
assert not _should_skip_update_check(ctx, ["ask", "scan", "--data-boundaries"])
|
||||
|
||||
|
||||
def test_existing_scan_quick_text_still_works(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
PrivacyScanner,
|
||||
"run_quick",
|
||||
lambda self: [
|
||||
ScanResult(
|
||||
name="Cloud Sync Agents",
|
||||
status="ok",
|
||||
message="No cloud-sync agents detected.",
|
||||
platform="all",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--quick"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "OpenJarvis Security Scan" in result.output
|
||||
|
||||
|
||||
def test_data_boundary_loader_honors_openjarvis_config(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
from openjarvis.cli import scan_cmd
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
config_path = tmp_path / "custom.toml"
|
||||
config_path.write_text(
|
||||
"[telemetry]\nenabled = false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENJARVIS_CONFIG", str(config_path))
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
load_config.cache_clear()
|
||||
|
||||
_config, _root, loaded, error, root_error = scan_cmd._load_data_boundary_config()
|
||||
|
||||
assert loaded is True
|
||||
assert error == ""
|
||||
assert root_error == ""
|
||||
|
||||
|
||||
def test_data_boundary_loader_reports_root_error(monkeypatch):
|
||||
from openjarvis.cli import scan_cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.cli.scan_cmd.get_config_dir",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("bad home")),
|
||||
)
|
||||
|
||||
_config, root, loaded, error, root_error = scan_cmd._load_data_boundary_config()
|
||||
|
||||
assert root is None
|
||||
assert loaded is False
|
||||
assert error == ""
|
||||
assert "bad home" in root_error
|
||||
|
||||
|
||||
def test_data_boundary_cli_reports_real_root_error_without_import_crash():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
invalid_home = repo_root / ".invalid-openjarvis-home"
|
||||
env = os.environ.copy()
|
||||
env["OPENJARVIS_HOME"] = str(invalid_home)
|
||||
env["PYTHONPATH"] = str(repo_root / "src")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from openjarvis.cli import main; main()",
|
||||
"scan",
|
||||
"--data-boundaries",
|
||||
"--json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["findings"][0]["id"] == "config-root-error"
|
||||
assert str(invalid_home) not in result.stdout
|
||||
assert str(repo_root) not in result.stdout
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Regression: ``jarvis serve`` must wire the ``SystemPromptBuilder`` into the
|
||||
agent it constructs, so SOUL.md / MEMORY.md / USER.md reach the model over HTTP.
|
||||
|
||||
``cli/ask.py`` (and ``cli/chat_cmd.py`` and the managed-agent executor) have
|
||||
wired the builder since the persona system landed. The serve path never did, so
|
||||
an agent served over HTTP silently answered as a generic assistant — explicitly
|
||||
denying the persona — while the same agent via the CLI kept it. Found deploying
|
||||
a personal assistant: SOUL.md was correct on disk the whole time; no error, no
|
||||
warning.
|
||||
|
||||
This test boots ``serve`` just far enough to capture the agent handed to
|
||||
``create_app`` and asserts the builder (and thus the persona content) is
|
||||
present. It fails on the unpatched serve path: ``agent._prompt_builder`` is
|
||||
``None``, so the persona files never reach the model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from openjarvis.cli import cli
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
pytest.importorskip("uvicorn")
|
||||
|
||||
# ``openjarvis.cli.serve`` as a package attribute resolves to the click
|
||||
# *command* (re-exported); grab the real module to monkeypatch its globals.
|
||||
serve_mod = importlib.import_module("openjarvis.cli.serve")
|
||||
|
||||
|
||||
def _fake_engine() -> MagicMock:
|
||||
engine = MagicMock()
|
||||
engine.list_models.return_value = ["test-model"]
|
||||
engine.health.return_value = True
|
||||
engine.name = "mock"
|
||||
engine.engine_id = "mock"
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"agent_name",
|
||||
["simple", "orchestrator", "monitor_operative", "operative"],
|
||||
)
|
||||
def test_serve_wires_persona_builder_into_served_agent(
|
||||
tmp_path, monkeypatch, agent_name
|
||||
):
|
||||
"""The agent built on the serve path must carry a SystemPromptBuilder whose
|
||||
assembled prompt includes SOUL.md content (regression for the HTTP persona
|
||||
loss)."""
|
||||
from openjarvis.agents.monitor_operative import MonitorOperativeAgent
|
||||
from openjarvis.agents.operative import OperativeAgent
|
||||
from openjarvis.agents.orchestrator import OrchestratorAgent
|
||||
from openjarvis.agents.simple import SimpleAgent
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
# Persona file with a unique sentinel we can grep for in the built prompt.
|
||||
soul = tmp_path / "SOUL.md"
|
||||
soul.write_text("SERVE_PERSONA_SENTINEL", encoding="utf-8")
|
||||
|
||||
# conftest clears registries per-test; re-register the agent we exercise.
|
||||
agent_classes = {
|
||||
"simple": SimpleAgent,
|
||||
"orchestrator": OrchestratorAgent,
|
||||
"monitor_operative": MonitorOperativeAgent,
|
||||
"operative": OperativeAgent,
|
||||
}
|
||||
if not AgentRegistry.contains(agent_name):
|
||||
AgentRegistry.register_value(agent_name, agent_classes[agent_name])
|
||||
|
||||
config = JarvisConfig()
|
||||
config.server.host = "127.0.0.1"
|
||||
config.server.port = 8123
|
||||
config.intelligence.default_model = "test-model"
|
||||
config.memory_files.soul_path = str(soul)
|
||||
# Keep the heavy optional subsystems off so we reach create_app cleanly.
|
||||
config.telemetry.enabled = False
|
||||
config.agent_manager.enabled = False
|
||||
config.sessions.enabled = False
|
||||
config.channel.enabled = False
|
||||
config.skills.enabled = False
|
||||
config.agent.context_from_memory = False
|
||||
|
||||
engine = _fake_engine()
|
||||
monkeypatch.setattr(serve_mod, "load_config", lambda *a, **k: config)
|
||||
monkeypatch.setattr(serve_mod, "get_engine", lambda *a, **k: ("mock", engine))
|
||||
monkeypatch.setattr(serve_mod, "discover_engines", lambda *a, **k: {})
|
||||
monkeypatch.setattr(serve_mod, "discover_models", lambda *a, **k: {})
|
||||
|
||||
# setup_security returns its own context; pass the engine straight through.
|
||||
sec = MagicMock()
|
||||
sec.engine = engine
|
||||
sec.capability_policy = None
|
||||
sec.audit_logger = None
|
||||
monkeypatch.setattr("openjarvis.security.setup_security", lambda *a, **k: sec)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture_create_app(*args, **kwargs):
|
||||
captured["agent"] = kwargs.get("agent")
|
||||
return MagicMock(name="app")
|
||||
|
||||
with (
|
||||
patch("openjarvis.server.app.create_app", side_effect=_capture_create_app),
|
||||
patch("uvicorn.run", lambda *a, **k: None),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
cli, ["serve", "--agent", agent_name], catch_exceptions=False
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
agent = captured.get("agent")
|
||||
assert agent is not None, (
|
||||
"serve did not construct an agent or never reached create_app; "
|
||||
f"output:\n{result.output}"
|
||||
)
|
||||
# The regression: without the fix ``agent._prompt_builder`` is None and the
|
||||
# persona files never reach the model over HTTP.
|
||||
assert agent._prompt_builder is not None, (
|
||||
f"serve constructed {agent_name} without a prompt_builder — SOUL.md / "
|
||||
"MEMORY.md / USER.md would be silently dropped on the HTTP path."
|
||||
)
|
||||
assert "SERVE_PERSONA_SENTINEL" in agent._prompt_builder.build(), (
|
||||
"prompt_builder is wired on serve, but its built prompt omits SOUL.md"
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -95,6 +95,30 @@ class TestJarvisAsk:
|
||||
assert result == "Agent response"
|
||||
j.close()
|
||||
|
||||
def test_ask_with_agent_wires_persona(self, tmp_path):
|
||||
from openjarvis.agents.simple import SimpleAgent
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
soul = tmp_path / "SOUL.md"
|
||||
soul.write_text("SDK_PERSONA_SENTINEL", encoding="utf-8")
|
||||
|
||||
cfg = JarvisConfig()
|
||||
cfg.memory_files.soul_path = str(soul)
|
||||
cfg.memory_files.memory_path = ""
|
||||
cfg.memory_files.user_path = ""
|
||||
cfg.agent.context_from_memory = False
|
||||
|
||||
if not AgentRegistry.contains("simple"):
|
||||
AgentRegistry.register_value("simple", SimpleAgent)
|
||||
|
||||
engine = _make_engine()
|
||||
with patch("openjarvis.sdk.get_engine", return_value=("mock", engine)):
|
||||
j = Jarvis(config=cfg, model="test-model")
|
||||
j.ask("Hello", agent="simple")
|
||||
messages = engine.generate.call_args.args[0]
|
||||
assert "SDK_PERSONA_SENTINEL" in messages[0].content
|
||||
j.close()
|
||||
|
||||
def test_ask_no_engine_raises(self):
|
||||
with patch("openjarvis.sdk.get_engine", return_value=None):
|
||||
j = Jarvis(config=JarvisConfig())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -64,6 +64,8 @@ EXPECTED_TOOLS = {
|
||||
"image_generate",
|
||||
# audio_tool.py
|
||||
"audio_transcribe",
|
||||
# text_to_speech.py
|
||||
"text_to_speech",
|
||||
# knowledge_tools.py
|
||||
"kg_add_entity",
|
||||
"kg_add_relation",
|
||||
|
||||
Reference in New Issue
Block a user