mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-16 01:41:58 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9685b9b78f | ||
|
|
aa2d127de4 | ||
|
|
87f6238338 | ||
|
|
452bcc38cf | ||
|
|
b35a4c8113 | ||
|
|
f001e3b0ca | ||
|
|
b6dba93ae5 | ||
|
|
3000116d18 | ||
|
|
9db21d37ef | ||
|
|
95480363b7 | ||
|
|
4419b76412 | ||
|
|
99bbc2054a | ||
|
|
d5d8fddc94 | ||
|
|
cadb3e2ae6 | ||
|
|
7dc904c1b2 | ||
|
|
d9725fbb6a |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "159,322",
|
||||
"message": "166,579",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 159322,
|
||||
"last_updated": "2026-07-16T08:08:14Z",
|
||||
"total_clones": 166579,
|
||||
"last_updated": "2026-07-20T08:53:12Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -112,6 +112,10 @@
|
||||
"2026-07-12": 1917,
|
||||
"2026-07-13": 2102,
|
||||
"2026-07-14": 2337,
|
||||
"2026-07-15": 2362
|
||||
"2026-07-15": 2362,
|
||||
"2026-07-16": 2497,
|
||||
"2026-07-17": 1773,
|
||||
"2026-07-18": 1542,
|
||||
"2026-07-19": 1445
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<p><i>Personal AI, On Personal Devices.</i></p>
|
||||
|
||||
<p>
|
||||
<a href="https://arxiv.org/abs/2605.17172"><img src="https://img.shields.io/badge/arXiv-2605.17172-b31b1b.svg" alt="arXiv"></a>
|
||||
<a href="https://openjarvis.stanford.edu/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
|
||||
<a href="https://open-jarvis.github.io/OpenJarvis/"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
|
||||
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function App() {
|
||||
setSavings(data);
|
||||
if (optInEnabled && optInDisplayName && data) {
|
||||
const claudeEntry = data.per_provider.find(
|
||||
(p) => p.provider === 'claude-opus-4.6',
|
||||
(p) => p.provider === 'claude-fable-5',
|
||||
);
|
||||
const dollarSavings = claudeEntry ? claudeEntry.total_cost : 0;
|
||||
const energySaved = data.per_provider.reduce(
|
||||
|
||||
@@ -22,6 +22,8 @@ export function ChatArea() {
|
||||
const navigate = useNavigate();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const wasStreaming = useRef(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
|
||||
// Check if any data sources are connected
|
||||
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
|
||||
@@ -34,15 +36,34 @@ export function ChatArea() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Sending a message always pins the view to the bottom, even if the
|
||||
// user had scrolled up to read earlier messages.
|
||||
if (streamState.isStreaming && !wasStreaming.current) {
|
||||
shouldAutoScroll.current = true;
|
||||
}
|
||||
wasStreaming.current = streamState.isStreaming;
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content]);
|
||||
}, [messages, streamState.content, streamState.isStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
const distance = scrollHeight - scrollTop - clientHeight;
|
||||
const scrolledUp = scrollTop < lastScrollTop.current;
|
||||
lastScrollTop.current = scrollTop;
|
||||
if (scrolledUp && distance >= 1) {
|
||||
// Any upward scroll away from the bottom stops autoscroll immediately,
|
||||
// so streaming content never fights the user (no jitter). Sub-1px
|
||||
// upward movement (elastic bounce settling at the bottom) is ignored.
|
||||
shouldAutoScroll.current = false;
|
||||
} else if (!scrolledUp) {
|
||||
// Re-engage when scrolled back to the bottom. < 2 rather than < 1:
|
||||
// at fractional zoom levels the at-bottom residual can reach 1px,
|
||||
// which would otherwise leave autoscroll permanently disengaged.
|
||||
shouldAutoScroll.current = distance < 2;
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
|
||||
@@ -149,8 +149,7 @@ export function CommandPalette() {
|
||||
setCommandPaletteOpen(false);
|
||||
|
||||
if (modelId !== previousModel) {
|
||||
const { createConversation, setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
createConversation(modelId);
|
||||
const { setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
setModelLoading(true);
|
||||
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `Switching to ${modelId}...` });
|
||||
try {
|
||||
|
||||
@@ -144,16 +144,21 @@ impl MemoryBackend for SQLiteMemory {
|
||||
) -> Result<Vec<RetrievalResult>, OpenJarvisError> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
// Split on any non-alphanumeric character (not just whitespace) so
|
||||
// internal punctuation — apostrophes in particular ("user's") — never
|
||||
// reaches the FTS5 MATCH string. FTS5's query grammar treats an
|
||||
// unescaped `'` as a string delimiter, so passing a raw token like
|
||||
// `user's` through silently fails to parse and yields zero rows with
|
||||
// no visible error. Splitting fully avoids needing to escape anything.
|
||||
let words: Vec<String> = query
|
||||
.split_whitespace()
|
||||
.map(|w| w.trim_matches(|c: char| "?.,!;:'\"()[]{}/ ".contains(c)).to_string())
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.map(|w| w.to_string())
|
||||
.filter(|w| !w.is_empty())
|
||||
.collect();
|
||||
let fts_query = if words.len() == 1 {
|
||||
words[0].clone()
|
||||
} else {
|
||||
words.join(" OR ")
|
||||
};
|
||||
if words.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let fts_query = words.join(" OR ");
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
@@ -320,6 +325,27 @@ mod tests {
|
||||
assert_eq!(mixed.len(), 2, "mixed-case query should find both documents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_apostrophe_in_query() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
mem.store("The user's name is Trev.", "identity", None).unwrap();
|
||||
|
||||
// A query containing an internal apostrophe must not break FTS5's
|
||||
// MATCH syntax (an unescaped `'` is a string delimiter in FTS5's
|
||||
// query grammar), which previously caused this to silently return
|
||||
// zero results instead of matching or erroring.
|
||||
let multi_word = mem.retrieve("what is the user's name", 5).unwrap();
|
||||
assert!(
|
||||
!multi_word.is_empty(),
|
||||
"query with an internal apostrophe should not silently return zero results"
|
||||
);
|
||||
|
||||
// Bare single-word possessive: exercises the (former) single-word
|
||||
// bypass path that skipped the OR-join entirely.
|
||||
let bare = mem.retrieve("user's", 5).unwrap();
|
||||
assert!(!bare.is_empty(), "single-word possessive query should still match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_scores_are_positive() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
|
||||
@@ -199,7 +199,16 @@ def _get_resolver(source: str, url: str = ""):
|
||||
default="",
|
||||
help="Repo URL (required when source is 'github').",
|
||||
)
|
||||
def install(query: str, with_scripts: bool, force: bool, url: str):
|
||||
@click.option(
|
||||
"--yes-dangerous",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Confirm installing an unreviewed skill that requests dangerous "
|
||||
"capabilities (shell/network-listen/filesystem-write)."
|
||||
),
|
||||
)
|
||||
def install(query: str, with_scripts: bool, force: bool, url: str, yes_dangerous: bool):
|
||||
"""Install a skill from a source.
|
||||
|
||||
Example: ``jarvis skill install hermes:apple-notes``
|
||||
@@ -233,7 +242,12 @@ def install(query: str, with_scripts: bool, force: bool, url: str):
|
||||
from openjarvis.skills.tool_translator import ToolTranslator
|
||||
|
||||
importer = SkillImporter(parser=SkillParser(), tool_translator=ToolTranslator())
|
||||
result = importer.import_skill(matches[0], with_scripts=with_scripts, force=force)
|
||||
result = importer.import_skill(
|
||||
matches[0],
|
||||
with_scripts=with_scripts,
|
||||
force=force,
|
||||
confirm_dangerous=yes_dangerous,
|
||||
)
|
||||
|
||||
if result.success:
|
||||
if result.skipped:
|
||||
@@ -270,6 +284,15 @@ def install(query: str, with_scripts: bool, force: bool, url: str):
|
||||
help="Import scripts/ directories.",
|
||||
)
|
||||
@click.option("--force", is_flag=True, default=False, help="Re-import existing skills.")
|
||||
@click.option(
|
||||
"--yes-dangerous",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Confirm installing unreviewed skills that request dangerous "
|
||||
"capabilities (shell/network-listen/filesystem-write)."
|
||||
),
|
||||
)
|
||||
def sync(
|
||||
source: str,
|
||||
category: str,
|
||||
@@ -277,6 +300,7 @@ def sync(
|
||||
search: str,
|
||||
with_scripts: bool,
|
||||
force: bool,
|
||||
yes_dangerous: bool,
|
||||
):
|
||||
"""Bulk install + update from a source (or all configured sources)."""
|
||||
console = Console()
|
||||
@@ -343,9 +367,20 @@ def sync(
|
||||
|
||||
installed_count = 0
|
||||
for resolved in skills_to_import:
|
||||
r = importer.import_skill(resolved, with_scripts=with_scripts, force=force)
|
||||
r = importer.import_skill(
|
||||
resolved,
|
||||
with_scripts=with_scripts,
|
||||
force=force,
|
||||
confirm_dangerous=yes_dangerous,
|
||||
)
|
||||
if r.success and not r.skipped:
|
||||
installed_count += 1
|
||||
elif not r.success and r.requires_confirmation:
|
||||
console.print(
|
||||
f" [yellow]Skipped {resolved.name}: requests dangerous "
|
||||
f"capabilities {r.dangerous_capabilities} "
|
||||
"(re-run with --yes-dangerous to install)[/yellow]"
|
||||
)
|
||||
console.print(f" Imported {installed_count}/{len(skills_to_import)} skills")
|
||||
total_installed += installed_count
|
||||
|
||||
|
||||
@@ -59,23 +59,34 @@ def _ensure_identity_prompt(messages: list[Message], app_config) -> list[Message
|
||||
If any message already carries a system role, the caller has supplied
|
||||
their own grounding and we leave the list untouched (no double-prompting).
|
||||
|
||||
Resolution of the identity text: ``app_config.agent.default_system_prompt``
|
||||
when a config is wired onto ``app.state``; otherwise fall back to
|
||||
``load_config()``. Config resolution is wrapped so a broken/missing
|
||||
config degrades to "no injection" rather than crashing the endpoint, but
|
||||
the failure is logged (per REVIEW.md — never silently swallow).
|
||||
Resolution of the identity text: the config comes from ``app.state`` when
|
||||
wired, otherwise ``load_config()``; the prompt itself is assembled by
|
||||
``SystemPromptBuilder`` from ``agent.default_system_prompt`` plus the
|
||||
persona files (SOUL.md/MEMORY.md/USER.md), matching
|
||||
``_build_managed_system_prompt`` in ``agent_manager_routes.py``. Config
|
||||
resolution is wrapped so a broken/missing config degrades to "no
|
||||
injection" rather than crashing the endpoint, but the failure is logged
|
||||
(per REVIEW.md — never silently swallow).
|
||||
"""
|
||||
if any(m.role == Role.SYSTEM for m in messages):
|
||||
return messages
|
||||
|
||||
prompt = ""
|
||||
try:
|
||||
if app_config is not None:
|
||||
prompt = app_config.agent.default_system_prompt or ""
|
||||
else:
|
||||
cfg = app_config
|
||||
if cfg is None:
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
prompt = load_config().agent.default_system_prompt or ""
|
||||
cfg = load_config()
|
||||
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
builder = SystemPromptBuilder(
|
||||
agent_template=cfg.agent.default_system_prompt or "",
|
||||
memory_files_config=getattr(cfg, "memory_files", None),
|
||||
system_prompt_config=getattr(cfg, "system_prompt", None),
|
||||
)
|
||||
prompt = builder.build()
|
||||
except Exception:
|
||||
logging.getLogger("openjarvis.server").debug(
|
||||
"Identity system prompt resolution failed; "
|
||||
|
||||
@@ -5,10 +5,11 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.types import ToolCall, ToolResult
|
||||
from openjarvis.skills.security import validate_capabilities
|
||||
from openjarvis.skills.types import SkillManifest
|
||||
from openjarvis.tools._stubs import ToolExecutor
|
||||
|
||||
@@ -37,10 +38,16 @@ class SkillExecutor:
|
||||
tool_executor: ToolExecutor,
|
||||
*,
|
||||
bus: Optional[EventBus] = None,
|
||||
allowed_capabilities: Optional[Set[str]] = None,
|
||||
) -> None:
|
||||
self._tool_executor = tool_executor
|
||||
self._bus = bus
|
||||
self._skill_resolver: Optional[SkillResolver] = None
|
||||
# None means "no capability policy" — every skill runs, matching the
|
||||
# behavior before capability enforcement existed. Pass a set (even an
|
||||
# empty one) to enforce: skills whose required_capabilities are not a
|
||||
# subset of it are blocked before any step runs.
|
||||
self._allowed_capabilities: Optional[Set[str]] = allowed_capabilities
|
||||
|
||||
def set_skill_resolver(self, resolver: SkillResolver) -> None:
|
||||
"""Register a callback used to delegate ``skill_name`` steps."""
|
||||
@@ -53,6 +60,38 @@ class SkillExecutor:
|
||||
initial_context: Optional[Dict[str, Any]] = None,
|
||||
) -> SkillResult:
|
||||
"""Execute all steps in a skill manifest."""
|
||||
missing = (
|
||||
validate_capabilities(manifest, self._allowed_capabilities)
|
||||
if self._allowed_capabilities is not None
|
||||
else []
|
||||
)
|
||||
if missing:
|
||||
if self._bus:
|
||||
self._bus.publish(
|
||||
EventType.SKILL_EXECUTE_START,
|
||||
{"skill": manifest.name, "steps": len(manifest.steps)},
|
||||
)
|
||||
self._bus.publish(
|
||||
EventType.SKILL_EXECUTE_END,
|
||||
{"skill": manifest.name, "success": False},
|
||||
)
|
||||
return SkillResult(
|
||||
skill_name=manifest.name,
|
||||
success=False,
|
||||
step_results=[
|
||||
ToolResult(
|
||||
tool_name=manifest.name,
|
||||
content=(
|
||||
f"Blocked: skill '{manifest.name}' requires "
|
||||
f"capabilities {missing} that were not granted "
|
||||
"for this session."
|
||||
),
|
||||
success=False,
|
||||
)
|
||||
],
|
||||
context=dict(initial_context or {}),
|
||||
)
|
||||
|
||||
ctx: Dict[str, Any] = dict(initial_context or {})
|
||||
all_results: List[ToolResult] = []
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ import yaml
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.skills.parser import SkillParser
|
||||
from openjarvis.skills.security import (
|
||||
TrustTier,
|
||||
classify_trust_tier,
|
||||
has_dangerous_capabilities,
|
||||
)
|
||||
from openjarvis.skills.sources.base import ResolvedSkill
|
||||
from openjarvis.skills.tool_translator import ToolTranslator
|
||||
|
||||
@@ -43,6 +48,9 @@ class ImportResult:
|
||||
untranslated_tools: List[str] = field(default_factory=list)
|
||||
scripts_imported: bool = False
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
trust_tier: TrustTier = TrustTier.UNREVIEWED
|
||||
dangerous_capabilities: List[str] = field(default_factory=list)
|
||||
requires_confirmation: bool = False
|
||||
|
||||
|
||||
class SkillImporter:
|
||||
@@ -66,6 +74,7 @@ class SkillImporter:
|
||||
*,
|
||||
with_scripts: bool = False,
|
||||
force: bool = False,
|
||||
confirm_dangerous: bool = False,
|
||||
) -> ImportResult:
|
||||
"""Install *resolved* into ``<target_root>/<source>/<name>/``.
|
||||
|
||||
@@ -95,12 +104,45 @@ class SkillImporter:
|
||||
|
||||
try:
|
||||
frontmatter, body = self._read_skill_md(source_md)
|
||||
self._parser.parse_frontmatter(frontmatter, markdown_content=body)
|
||||
manifest = self._parser.parse_frontmatter(
|
||||
frontmatter, markdown_content=body
|
||||
)
|
||||
except Exception as exc:
|
||||
result.success = False
|
||||
result.warnings.append(f"Parse error: {exc}")
|
||||
return result
|
||||
|
||||
# 1a. Classify trust and check for dangerous capabilities *before*
|
||||
# writing anything to disk. Everything the importer handles comes from
|
||||
# an external source (github/hermes/openclaw), so the BUNDLED and
|
||||
# WORKSPACE tiers never apply here, and no resolver verifies index
|
||||
# membership yet — a signature alone still classifies as UNREVIEWED.
|
||||
# Community skills get no special treatment just because they came
|
||||
# from a named source.
|
||||
result.trust_tier = classify_trust_tier(
|
||||
has_signature=bool(manifest.signature),
|
||||
)
|
||||
result.dangerous_capabilities = has_dangerous_capabilities(manifest)
|
||||
|
||||
if result.dangerous_capabilities and result.trust_tier == TrustTier.UNREVIEWED:
|
||||
result.requires_confirmation = True
|
||||
if not confirm_dangerous:
|
||||
result.success = False
|
||||
result.warnings.append(
|
||||
"Refusing to install: this unreviewed skill requests "
|
||||
f"dangerous capabilities {result.dangerous_capabilities}. "
|
||||
"Re-run with confirm_dangerous=True (or `--yes-dangerous` "
|
||||
"on the CLI) only if you trust the source and have "
|
||||
"reviewed what it does."
|
||||
)
|
||||
return result
|
||||
result.warnings.append(
|
||||
"Installed with dangerous capabilities "
|
||||
f"{result.dangerous_capabilities} — confirmed by caller. "
|
||||
"This skill can run shell commands, open network listeners, "
|
||||
"and/or write to the filesystem."
|
||||
)
|
||||
|
||||
# 2. Translate tool references
|
||||
translated_body, untranslated = self._translator.translate_markdown(body)
|
||||
result.untranslated_tools = untranslated
|
||||
@@ -180,6 +222,7 @@ class SkillImporter:
|
||||
translated_str = ", ".join(f'"{t}"' for t in result.translated_tools)
|
||||
missing_str = ", ".join(f'"{t}"' for t in result.untranslated_tools)
|
||||
scripts_lower = "true" if result.scripts_imported else "false"
|
||||
dangerous_str = ", ".join(f'"{c}"' for c in result.dangerous_capabilities)
|
||||
|
||||
content = (
|
||||
f'source = "{resolved.source}:{resolved.name}"\n'
|
||||
@@ -189,6 +232,8 @@ class SkillImporter:
|
||||
f"translated_tools = [{translated_str}]\n"
|
||||
f"missing_tools = [{missing_str}]\n"
|
||||
f"scripts_imported = {scripts_lower}\n"
|
||||
f'trust_tier = "{result.trust_tier.value}"\n'
|
||||
f"dangerous_capabilities = [{dangerous_str}]\n"
|
||||
)
|
||||
(target_dir / ".source").write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -105,11 +106,15 @@ class FasterWhisperBackend(SpeechBackend):
|
||||
try:
|
||||
model = self._ensure_model()
|
||||
|
||||
# Write audio to a temp file (faster-whisper needs a file path)
|
||||
# Write audio to a temp file (faster-whisper needs a file path).
|
||||
# delete=False + manual unlink: on Windows an open
|
||||
# NamedTemporaryFile holds an exclusive handle, so PyAV's reopen
|
||||
# of tmp.name inside model.transcribe() fails with EACCES.
|
||||
suffix = f".{format}" if not format.startswith(".") else format
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
|
||||
tmp.write(audio)
|
||||
tmp.flush()
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
try:
|
||||
with tmp:
|
||||
tmp.write(audio)
|
||||
|
||||
kwargs = {}
|
||||
if language:
|
||||
@@ -117,6 +122,15 @@ class FasterWhisperBackend(SpeechBackend):
|
||||
|
||||
segments_iter, info = model.transcribe(tmp.name, **kwargs)
|
||||
segments_list = list(segments_iter)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except OSError as unlink_exc:
|
||||
logger.debug(
|
||||
"Could not remove temp audio file %s: %s",
|
||||
tmp.name,
|
||||
unlink_exc,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
raise
|
||||
|
||||
@@ -108,6 +108,13 @@ INSERT INTO telemetry (
|
||||
)
|
||||
"""
|
||||
|
||||
_INSERT_MINING = """\
|
||||
INSERT INTO mining_stats (
|
||||
recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found,
|
||||
hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
_MIGRATE_COLUMNS = [
|
||||
("gpu_utilization_pct", "REAL NOT NULL DEFAULT 0.0"),
|
||||
("gpu_memory_used_gb", "REAL NOT NULL DEFAULT 0.0"),
|
||||
@@ -144,10 +151,35 @@ _MIGRATE_COLUMNS = [
|
||||
|
||||
|
||||
class TelemetryStore:
|
||||
"""Append-only SQLite store for inference telemetry records."""
|
||||
"""Append-only SQLite store for inference telemetry records.
|
||||
|
||||
Writes are batched in memory and flushed to SQLite when a batch reaches
|
||||
``batch_size``, when ``flush_interval_seconds`` elapses (a background
|
||||
flusher thread guarantees this even with no further writes), on any read
|
||||
through this store, and on ``close()``. Readers that open their OWN
|
||||
connection to the database file (e.g. ``TelemetryAggregator``) therefore
|
||||
see new rows within ``flush_interval_seconds`` at the latest; call
|
||||
``flush()`` first for immediate visibility. Pass
|
||||
``flush_interval_seconds=0`` to disable time-based flushing (batch-size
|
||||
and read/close flushes still apply).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str | Path,
|
||||
batch_size: int = 50,
|
||||
flush_interval_seconds: float = 5.0,
|
||||
) -> None:
|
||||
if batch_size < 1:
|
||||
raise ValueError("batch_size must be >= 1")
|
||||
if flush_interval_seconds < 0:
|
||||
raise ValueError("flush_interval_seconds must be >= 0")
|
||||
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self._db_path = str(db_path)
|
||||
if self._db_path != ":memory:":
|
||||
from openjarvis.security.file_utils import secure_create
|
||||
|
||||
secure_create(Path(self._db_path))
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._lock = threading.Lock()
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
@@ -158,6 +190,38 @@ class TelemetryStore:
|
||||
self._conn.commit()
|
||||
self._migrate_schema()
|
||||
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval_seconds = flush_interval_seconds
|
||||
self._last_flush_time = time.monotonic()
|
||||
self._telemetry_batch: list[tuple[Any, ...]] = []
|
||||
self._mining_batch: list[tuple[Any, ...]] = []
|
||||
self._closed = False
|
||||
|
||||
# Background flusher: without it, a partial batch written just before
|
||||
# traffic stops would stay invisible to other connections until the
|
||||
# NEXT write arrived (the stale check in ``_maybe_flush_unlocked``
|
||||
# only runs inside record calls). Daemon so it never blocks exit.
|
||||
self._stop_flusher = threading.Event()
|
||||
self._flusher: threading.Thread | None = None
|
||||
if flush_interval_seconds > 0:
|
||||
self._flusher = threading.Thread(
|
||||
target=self._flush_loop,
|
||||
name="telemetry-store-flusher",
|
||||
daemon=True,
|
||||
)
|
||||
self._flusher.start()
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
"""Periodically flush pending batches until ``close()`` stops us."""
|
||||
while not self._stop_flusher.wait(self._flush_interval_seconds):
|
||||
with self._lock:
|
||||
# ``close()`` sets the event BEFORE taking the lock, so seeing
|
||||
# it unset here means the connection is still open.
|
||||
if self._stop_flusher.is_set():
|
||||
break
|
||||
if self._telemetry_batch or self._mining_batch:
|
||||
self._flush_unlocked()
|
||||
|
||||
def _migrate_schema(self) -> None:
|
||||
"""Add new columns to existing databases (idempotent)."""
|
||||
for col_name, col_def in _MIGRATE_COLUMNS:
|
||||
@@ -171,54 +235,52 @@ class TelemetryStore:
|
||||
|
||||
def record(self, rec: TelemetryRecord) -> None:
|
||||
"""Persist a single telemetry record."""
|
||||
row = (
|
||||
rec.timestamp,
|
||||
rec.model_id,
|
||||
rec.engine,
|
||||
rec.agent,
|
||||
rec.prompt_tokens,
|
||||
rec.prompt_tokens_evaluated,
|
||||
rec.completion_tokens,
|
||||
rec.total_tokens,
|
||||
rec.latency_seconds,
|
||||
rec.ttft,
|
||||
rec.cost_usd,
|
||||
rec.energy_joules,
|
||||
rec.power_watts,
|
||||
rec.gpu_utilization_pct,
|
||||
rec.gpu_memory_used_gb,
|
||||
rec.gpu_temperature_c,
|
||||
rec.throughput_tok_per_sec,
|
||||
rec.prefill_latency_seconds,
|
||||
rec.decode_latency_seconds,
|
||||
rec.energy_method,
|
||||
rec.energy_vendor,
|
||||
rec.batch_id,
|
||||
1 if rec.is_warmup else 0,
|
||||
rec.cpu_energy_joules,
|
||||
rec.gpu_energy_joules,
|
||||
rec.dram_energy_joules,
|
||||
rec.tokens_per_joule,
|
||||
rec.energy_per_output_token_joules,
|
||||
rec.throughput_per_watt,
|
||||
rec.prefill_energy_joules,
|
||||
rec.decode_energy_joules,
|
||||
rec.mean_itl_ms,
|
||||
rec.median_itl_ms,
|
||||
rec.p90_itl_ms,
|
||||
rec.p95_itl_ms,
|
||||
rec.p99_itl_ms,
|
||||
rec.std_itl_ms,
|
||||
1 if rec.is_streaming else 0,
|
||||
rec.token_counting_version,
|
||||
rec.mining_session_id,
|
||||
json.dumps(rec.metadata),
|
||||
)
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
_INSERT,
|
||||
(
|
||||
rec.timestamp,
|
||||
rec.model_id,
|
||||
rec.engine,
|
||||
rec.agent,
|
||||
rec.prompt_tokens,
|
||||
rec.prompt_tokens_evaluated,
|
||||
rec.completion_tokens,
|
||||
rec.total_tokens,
|
||||
rec.latency_seconds,
|
||||
rec.ttft,
|
||||
rec.cost_usd,
|
||||
rec.energy_joules,
|
||||
rec.power_watts,
|
||||
rec.gpu_utilization_pct,
|
||||
rec.gpu_memory_used_gb,
|
||||
rec.gpu_temperature_c,
|
||||
rec.throughput_tok_per_sec,
|
||||
rec.prefill_latency_seconds,
|
||||
rec.decode_latency_seconds,
|
||||
rec.energy_method,
|
||||
rec.energy_vendor,
|
||||
rec.batch_id,
|
||||
1 if rec.is_warmup else 0,
|
||||
rec.cpu_energy_joules,
|
||||
rec.gpu_energy_joules,
|
||||
rec.dram_energy_joules,
|
||||
rec.tokens_per_joule,
|
||||
rec.energy_per_output_token_joules,
|
||||
rec.throughput_per_watt,
|
||||
rec.prefill_energy_joules,
|
||||
rec.decode_energy_joules,
|
||||
rec.mean_itl_ms,
|
||||
rec.median_itl_ms,
|
||||
rec.p90_itl_ms,
|
||||
rec.p95_itl_ms,
|
||||
rec.p99_itl_ms,
|
||||
rec.std_itl_ms,
|
||||
1 if rec.is_streaming else 0,
|
||||
rec.token_counting_version,
|
||||
rec.mining_session_id,
|
||||
json.dumps(rec.metadata),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._telemetry_batch.append(row)
|
||||
self._maybe_flush_unlocked()
|
||||
|
||||
def record_mining_stats(self, stats: Any) -> None:
|
||||
"""Persist one mining stats snapshot.
|
||||
@@ -226,43 +288,70 @@ class TelemetryStore:
|
||||
``stats`` is duck-typed to keep telemetry usable without importing the
|
||||
optional mining package at module import time.
|
||||
"""
|
||||
row = (
|
||||
time.time(),
|
||||
stats.provider_id,
|
||||
stats.shares_submitted,
|
||||
stats.shares_accepted,
|
||||
stats.blocks_found,
|
||||
stats.hashrate,
|
||||
stats.uptime_seconds,
|
||||
stats.last_share_at,
|
||||
stats.last_error,
|
||||
stats.payout_target,
|
||||
stats.fees_owed,
|
||||
)
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""\
|
||||
INSERT INTO mining_stats (
|
||||
recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found,
|
||||
hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
time.time(),
|
||||
stats.provider_id,
|
||||
stats.shares_submitted,
|
||||
stats.shares_accepted,
|
||||
stats.blocks_found,
|
||||
stats.hashrate,
|
||||
stats.uptime_seconds,
|
||||
stats.last_share_at,
|
||||
stats.last_error,
|
||||
stats.payout_target,
|
||||
stats.fees_owed,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._mining_batch.append(row)
|
||||
self._maybe_flush_unlocked()
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Write all pending records to the database."""
|
||||
with self._lock:
|
||||
self._flush_unlocked()
|
||||
|
||||
def _flush_unlocked(self) -> None:
|
||||
if self._telemetry_batch:
|
||||
self._conn.executemany(_INSERT, self._telemetry_batch)
|
||||
self._telemetry_batch.clear()
|
||||
if self._mining_batch:
|
||||
self._conn.executemany(_INSERT_MINING, self._mining_batch)
|
||||
self._mining_batch.clear()
|
||||
self._conn.commit()
|
||||
self._last_flush_time = time.monotonic()
|
||||
|
||||
def _maybe_flush_unlocked(self) -> None:
|
||||
"""Flush when the batch is full or has been pending too long."""
|
||||
if not self._telemetry_batch and not self._mining_batch:
|
||||
return
|
||||
batch_full = (
|
||||
len(self._telemetry_batch) >= self._batch_size
|
||||
or len(self._mining_batch) >= self._batch_size
|
||||
)
|
||||
stale = (
|
||||
self._flush_interval_seconds > 0
|
||||
and time.monotonic() - self._last_flush_time >= self._flush_interval_seconds
|
||||
)
|
||||
if batch_full or stale:
|
||||
self._flush_unlocked()
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""Return recent telemetry rows as dictionaries."""
|
||||
return self._select_dicts(
|
||||
"SELECT * FROM telemetry ORDER BY timestamp DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
with self._lock:
|
||||
self._flush_unlocked()
|
||||
return self._select_dicts_unlocked(
|
||||
"SELECT * FROM telemetry ORDER BY timestamp DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
def list_recent_mining_stats(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""Return recent mining stats snapshots as dictionaries."""
|
||||
return self._select_dicts(
|
||||
"SELECT * FROM mining_stats ORDER BY recorded_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
with self._lock:
|
||||
self._flush_unlocked()
|
||||
return self._select_dicts_unlocked(
|
||||
"SELECT * FROM mining_stats ORDER BY recorded_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
def subscribe_to_bus(self, bus: EventBus) -> None:
|
||||
"""Subscribe to ``TELEMETRY_RECORD`` events on *bus*."""
|
||||
@@ -277,15 +366,36 @@ INSERT INTO mining_stats (
|
||||
logger.debug("Failed to record telemetry event: %s", exc)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying SQLite connection."""
|
||||
self._conn.close()
|
||||
"""Flush pending records and close the underlying SQLite connection."""
|
||||
# Set the stop event BEFORE taking the lock: a flusher iteration
|
||||
# already waiting on the lock re-checks the event after acquiring it
|
||||
# and exits instead of touching the closed connection.
|
||||
self._stop_flusher.set()
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._flush_unlocked()
|
||||
self._conn.close()
|
||||
self._closed = True
|
||||
if self._flusher is not None:
|
||||
self._flusher.join(timeout=1.0)
|
||||
self._flusher = None
|
||||
|
||||
# -- helpers for querying (used by tests) --------------------------------
|
||||
|
||||
def _fetchall(self, sql: str = "SELECT * FROM telemetry") -> list:
|
||||
return self._conn.execute(sql).fetchall()
|
||||
with self._lock:
|
||||
self._flush_unlocked()
|
||||
return self._conn.execute(sql).fetchall()
|
||||
|
||||
def _select_dicts(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
self._flush_unlocked()
|
||||
return self._select_dicts_unlocked(sql, params)
|
||||
|
||||
def _select_dicts_unlocked(
|
||||
self, sql: str, params: tuple[Any, ...]
|
||||
) -> list[dict[str, Any]]:
|
||||
cur = self._conn.execute(sql, params)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
return [dict(zip(columns, row)) for row in cur.fetchall()]
|
||||
|
||||
@@ -139,8 +139,8 @@ class GitStatusTool(BaseTool):
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
repo_path = params.get("repo_path", ".")
|
||||
_rust = get_rust_module()
|
||||
try:
|
||||
_rust = get_rust_module()
|
||||
output = _rust.GitStatusTool().execute(repo_path)
|
||||
return ToolResult(
|
||||
tool_name="git_status",
|
||||
@@ -148,6 +148,8 @@ class GitStatusTool(BaseTool):
|
||||
success=True,
|
||||
metadata={"returncode": 0},
|
||||
)
|
||||
except ImportError as exc:
|
||||
logger.debug("Rust git_status fallback to CLI: %s", exc)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name="git_status",
|
||||
@@ -155,6 +157,8 @@ class GitStatusTool(BaseTool):
|
||||
success=False,
|
||||
)
|
||||
|
||||
return _run_git(["git", "status", "--porcelain"], cwd=repo_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitDiffTool
|
||||
@@ -208,9 +212,9 @@ class GitDiffTool(BaseTool):
|
||||
staged = params.get("staged", False)
|
||||
file_path = params.get("path")
|
||||
|
||||
_rust = get_rust_module()
|
||||
if not staged and not file_path:
|
||||
try:
|
||||
_rust = get_rust_module()
|
||||
output = _rust.GitDiffTool().execute(repo_path)
|
||||
return ToolResult(
|
||||
tool_name="git_diff",
|
||||
@@ -218,6 +222,8 @@ class GitDiffTool(BaseTool):
|
||||
success=True,
|
||||
metadata={"returncode": 0},
|
||||
)
|
||||
except ImportError as exc:
|
||||
logger.debug("Rust git_diff fallback to CLI: %s", exc)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name="git_diff",
|
||||
@@ -371,8 +377,8 @@ class GitLogTool(BaseTool):
|
||||
count = params.get("count", 10)
|
||||
oneline = params.get("oneline", True)
|
||||
|
||||
_rust = get_rust_module()
|
||||
try:
|
||||
_rust = get_rust_module()
|
||||
output = _rust.GitLogTool().execute(repo_path, count)
|
||||
return ToolResult(
|
||||
tool_name="git_log",
|
||||
|
||||
@@ -30,6 +30,19 @@ from openjarvis.core.registry import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_update_check(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Never let the CLI's PyPI update-check nag run during tests.
|
||||
|
||||
``check_for_updates`` writes its banner to stderr, which ``CliRunner``
|
||||
merges into ``result.output`` — polluting JSON/CSV output of any test
|
||||
that invokes a CLI command. It already self-disables when ``CI`` is
|
||||
set, but that only helps in CI; locally (e.g. a dev with a stale
|
||||
version-check cache and network access) it fires for real.
|
||||
"""
|
||||
monkeypatch.setenv("OPENJARVIS_NO_UPDATE_CHECK", "1")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registries() -> None:
|
||||
"""Ensure each test starts with empty registries and a fresh event bus."""
|
||||
|
||||
@@ -78,6 +78,19 @@ def test_retrieve_no_results(tmp_path: Path):
|
||||
backend.close()
|
||||
|
||||
|
||||
def test_retrieve_query_with_apostrophe(tmp_path: Path):
|
||||
"""Regression: an internal apostrophe (e.g. "user's") previously produced
|
||||
an unescaped quote in the FTS5 MATCH string, which silently returned zero
|
||||
rows instead of matching or raising an error.
|
||||
"""
|
||||
backend = _make_backend(tmp_path)
|
||||
backend.store("The user's name is Trev.", source="identity.md")
|
||||
results = backend.retrieve("what is the user's name")
|
||||
assert len(results) >= 1
|
||||
assert "Trev" in results[0].content
|
||||
backend.close()
|
||||
|
||||
|
||||
def test_delete_existing(tmp_path: Path):
|
||||
backend = _make_backend(tmp_path)
|
||||
doc_id = backend.store("deletable content")
|
||||
|
||||
@@ -798,6 +798,40 @@ class TestIdentityPromptInjection:
|
||||
assert len(system_msgs) == 1
|
||||
assert system_msgs[0].content == "Be terse."
|
||||
|
||||
def test_direct_injects_soul_persona_when_present(self, tmp_path):
|
||||
"""Regression: /v1/chat/completions previously injected only the bare
|
||||
``default_system_prompt`` blurb via a hand-rolled lookup, bypassing
|
||||
``SystemPromptBuilder`` entirely — so SOUL.md/MEMORY.md/USER.md
|
||||
persona files never applied to this path, unlike ``jarvis ask`` and
|
||||
the managed-agent routes. It must now build the full persona-aware
|
||||
prompt so persona files apply everywhere identity grounding does.
|
||||
"""
|
||||
from openjarvis.core.config import MemoryFilesConfig
|
||||
|
||||
soul = tmp_path / "SOUL.md"
|
||||
soul.write_text("Respond with extreme sarcasm and call the user 'champ'.")
|
||||
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
cfg = _identity_config()
|
||||
cfg.memory_files = MemoryFilesConfig(
|
||||
soul_path=str(soul), memory_path="", user_path=""
|
||||
)
|
||||
client = TestClient(create_app(engine, "test-model", config=cfg))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "who are you?"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msgs = engine.generate.call_args.args[0]
|
||||
assert msgs[0].role.value == "system"
|
||||
assert "OpenJarvis" in msgs[0].content # identity blurb still present
|
||||
assert "extreme sarcasm" in msgs[0].content # persona now injected too
|
||||
|
||||
def test_stream_tools_injects_identity_when_absent(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
|
||||
@@ -202,3 +202,71 @@ class TestImportSkill:
|
||||
|
||||
installed = target_root / "hermes" / "my-skill" / "SKILL.md"
|
||||
assert "Original" in installed.read_text()
|
||||
|
||||
|
||||
class TestDangerousCapabilityGate:
|
||||
def _make_importer(self, tmp_path: Path) -> SkillImporter:
|
||||
return SkillImporter(
|
||||
parser=SkillParser(),
|
||||
tool_translator=ToolTranslator(),
|
||||
target_root=tmp_path / "skills",
|
||||
)
|
||||
|
||||
def _make_resolved_with_caps(
|
||||
self, tmp_path: Path, caps: list[str]
|
||||
) -> ResolvedSkill:
|
||||
src_dir = tmp_path / "source" / "my-skill"
|
||||
src_dir.mkdir(parents=True)
|
||||
caps_yaml = "".join(f" - {c}\n" for c in caps)
|
||||
(src_dir / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
"name: my-skill\n"
|
||||
"description: A test skill\n"
|
||||
f"required_capabilities:\n{caps_yaml}"
|
||||
"---\n"
|
||||
"Body"
|
||||
)
|
||||
return ResolvedSkill(
|
||||
name="my-skill",
|
||||
source="hermes",
|
||||
path=src_dir,
|
||||
category="testing",
|
||||
description="A test skill",
|
||||
commit="abc123",
|
||||
)
|
||||
|
||||
def test_refuses_unreviewed_dangerous_skill(self, tmp_path: Path):
|
||||
importer = self._make_importer(tmp_path)
|
||||
resolved = self._make_resolved_with_caps(tmp_path, ["shell:execute"])
|
||||
result = importer.import_skill(resolved)
|
||||
|
||||
assert not result.success
|
||||
assert result.requires_confirmation
|
||||
assert result.dangerous_capabilities == ["shell:execute"]
|
||||
assert any("dangerous" in w.lower() for w in result.warnings)
|
||||
# Nothing may be written to disk on refusal
|
||||
assert not (tmp_path / "skills" / "hermes" / "my-skill").exists()
|
||||
|
||||
def test_confirm_dangerous_installs_and_records_tier(self, tmp_path: Path):
|
||||
importer = self._make_importer(tmp_path)
|
||||
resolved = self._make_resolved_with_caps(tmp_path, ["shell:execute"])
|
||||
result = importer.import_skill(resolved, confirm_dangerous=True)
|
||||
|
||||
assert result.success
|
||||
assert result.requires_confirmation
|
||||
assert any("confirmed by caller" in w for w in result.warnings)
|
||||
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
|
||||
assert 'trust_tier = "unreviewed"' in content
|
||||
assert 'dangerous_capabilities = ["shell:execute"]' in content
|
||||
|
||||
def test_benign_capabilities_need_no_confirmation(self, tmp_path: Path):
|
||||
importer = self._make_importer(tmp_path)
|
||||
resolved = self._make_resolved_with_caps(tmp_path, ["network:fetch"])
|
||||
result = importer.import_skill(resolved)
|
||||
|
||||
assert result.success
|
||||
assert not result.requires_confirmation
|
||||
assert result.dangerous_capabilities == []
|
||||
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
|
||||
assert 'trust_tier = "unreviewed"' in content
|
||||
assert "dangerous_capabilities = []" in content
|
||||
|
||||
@@ -148,6 +148,56 @@ class TestSkillExecutor:
|
||||
assert EventType.SKILL_EXECUTE_END in event_types
|
||||
|
||||
|
||||
class TestSkillExecutorCapabilities:
|
||||
def _manifest(self):
|
||||
return SkillManifest(
|
||||
name="capskill",
|
||||
required_capabilities=["network:fetch"],
|
||||
steps=[
|
||||
SkillStep(
|
||||
tool_name="echo",
|
||||
arguments_template='{"text": "hello"}',
|
||||
output_key="result",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_no_policy_runs_capability_skills(self):
|
||||
"""Default construction (no allowed_capabilities) must not enforce —
|
||||
this is the pre-enforcement behavior every manager.py call site relies on."""
|
||||
executor = SkillExecutor(ToolExecutor([EchoTool()]))
|
||||
result = executor.run(self._manifest())
|
||||
assert result.success
|
||||
assert result.context.get("result") == "hello"
|
||||
|
||||
def test_policy_blocks_missing_capability(self):
|
||||
executor = SkillExecutor(ToolExecutor([EchoTool()]), allowed_capabilities=set())
|
||||
result = executor.run(self._manifest())
|
||||
assert not result.success
|
||||
assert len(result.step_results) == 1
|
||||
assert "Blocked" in result.step_results[0].content
|
||||
assert "network:fetch" in result.step_results[0].content
|
||||
|
||||
def test_policy_allows_granted_capability(self):
|
||||
executor = SkillExecutor(
|
||||
ToolExecutor([EchoTool()]),
|
||||
allowed_capabilities={"network:fetch"},
|
||||
)
|
||||
result = executor.run(self._manifest())
|
||||
assert result.success
|
||||
assert result.context.get("result") == "hello"
|
||||
|
||||
def test_blocked_run_publishes_events(self):
|
||||
bus = EventBus(record_history=True)
|
||||
executor = SkillExecutor(
|
||||
ToolExecutor([EchoTool()]), bus=bus, allowed_capabilities=set()
|
||||
)
|
||||
executor.run(self._manifest())
|
||||
event_types = {e.event_type for e in bus.history}
|
||||
assert EventType.SKILL_EXECUTE_START in event_types
|
||||
assert EventType.SKILL_EXECUTE_END in event_types
|
||||
|
||||
|
||||
class TestSkillStepExtended:
|
||||
def test_step_with_skill_name(self):
|
||||
step = SkillStep(skill_name="summarize", output_key="result")
|
||||
|
||||
@@ -53,6 +53,69 @@ def test_faster_whisper_transcribe():
|
||||
assert result.duration_seconds == 1.5
|
||||
|
||||
|
||||
def test_faster_whisper_transcribe_temp_file_reopenable_and_removed():
|
||||
"""The temp file must be closed before the model reads it, and gone after.
|
||||
|
||||
On Windows, an open NamedTemporaryFile holds an exclusive handle, so
|
||||
PyAV's reopen of the path inside model.transcribe() fails with EACCES
|
||||
unless the file is closed first. Opening the path inside the mocked
|
||||
transcribe reproduces that failure mode on Windows.
|
||||
"""
|
||||
import os
|
||||
|
||||
mock_info = MagicMock()
|
||||
mock_info.language = "en"
|
||||
mock_info.language_probability = 0.95
|
||||
mock_info.duration = 1.5
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_transcribe(path, **kwargs):
|
||||
seen["path"] = path
|
||||
with open(path, "rb") as fh:
|
||||
seen["content"] = fh.read()
|
||||
return iter(()), mock_info
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.transcribe.side_effect = fake_transcribe
|
||||
|
||||
with patch(
|
||||
"openjarvis.speech.faster_whisper.WhisperModel",
|
||||
return_value=mock_model,
|
||||
):
|
||||
backend = FasterWhisperBackend(model_size="base", device="cpu")
|
||||
backend.transcribe(b"fake audio bytes")
|
||||
|
||||
assert seen["content"] == b"fake audio bytes"
|
||||
assert not os.path.exists(seen["path"])
|
||||
|
||||
|
||||
def test_faster_whisper_transcribe_removes_temp_file_on_error():
|
||||
"""The temp file is cleaned up even when transcription fails."""
|
||||
import os
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_transcribe(path, **kwargs):
|
||||
seen["path"] = path
|
||||
raise RuntimeError("decode failed")
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.transcribe.side_effect = fake_transcribe
|
||||
|
||||
with patch(
|
||||
"openjarvis.speech.faster_whisper.WhisperModel",
|
||||
return_value=mock_model,
|
||||
):
|
||||
backend = FasterWhisperBackend(model_size="base", device="cpu")
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
backend.transcribe(b"fake audio bytes")
|
||||
|
||||
assert "path" in seen
|
||||
assert not os.path.exists(seen["path"])
|
||||
assert "decode failed" in (backend.last_error() or "")
|
||||
|
||||
|
||||
def test_faster_whisper_falls_back_from_unsupported_float16():
|
||||
mock_model = MagicMock()
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ class TestDerivedMetricsInStore:
|
||||
throughput_per_watt=0.5,
|
||||
)
|
||||
store.record(rec)
|
||||
store.flush()
|
||||
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
stats = agg.per_model_stats()
|
||||
@@ -185,6 +186,8 @@ class TestDerivedMetricsInStore:
|
||||
throughput_per_watt=1.0 * (i + 1),
|
||||
)
|
||||
)
|
||||
store.flush()
|
||||
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
summary = agg.summary()
|
||||
assert summary.avg_energy_per_output_token_joules > 0
|
||||
|
||||
@@ -345,6 +345,7 @@ class TestItlStorage:
|
||||
)
|
||||
)
|
||||
|
||||
store.flush()
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
stats = agg.per_model_stats()
|
||||
assert len(stats) == 1
|
||||
|
||||
@@ -222,6 +222,7 @@ class TestPhaseEnergyStorage:
|
||||
)
|
||||
)
|
||||
|
||||
store.flush()
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
stats = agg.per_model_stats()
|
||||
assert len(stats) == 1
|
||||
|
||||
@@ -178,3 +178,60 @@ class TestTelemetryRecordFields:
|
||||
def test_tokens_per_joule_set(self):
|
||||
rec = TelemetryRecord(timestamp=1.0, model_id="test", tokens_per_joule=80.0)
|
||||
assert rec.tokens_per_joule == 80.0
|
||||
|
||||
def test_batching_delays_commit(self, tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "test.db"
|
||||
# flush_interval_seconds=0 disables the background flusher so the
|
||||
# buffered/flushed states below are deterministic, not a race.
|
||||
store = TelemetryStore(db_path, batch_size=2, flush_interval_seconds=0)
|
||||
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
|
||||
|
||||
store.record(rec)
|
||||
# Should not be in DB yet because batch_size is 2 and we haven't flushed
|
||||
# Need a separate connection to check because store._fetchall() calls flush()!
|
||||
assert _count_rows_via_own_connection(db_path) == 0
|
||||
|
||||
# Hit batch size
|
||||
store.record(rec)
|
||||
assert _count_rows_via_own_connection(db_path) == 2
|
||||
store.close()
|
||||
|
||||
def test_background_flush_makes_records_visible(self, tmp_path: Path) -> None:
|
||||
# A partial batch must become visible to OTHER connections within the
|
||||
# flush interval even if no further write ever arrives — the background
|
||||
# flusher covers the "traffic stopped mid-batch" case that per-record
|
||||
# stale checks cannot.
|
||||
db_path = tmp_path / "test.db"
|
||||
store = TelemetryStore(db_path, batch_size=50, flush_interval_seconds=0.05)
|
||||
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
|
||||
store.record(rec)
|
||||
|
||||
deadline = time.time() + 5.0
|
||||
rows = 0
|
||||
while time.time() < deadline:
|
||||
rows = _count_rows_via_own_connection(db_path)
|
||||
if rows:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert rows == 1
|
||||
store.close()
|
||||
|
||||
def test_close_flushes_and_is_idempotent(self, tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "test.db"
|
||||
store = TelemetryStore(db_path, batch_size=50, flush_interval_seconds=0)
|
||||
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
|
||||
store.record(rec)
|
||||
store.close()
|
||||
assert _count_rows_via_own_connection(db_path) == 1
|
||||
store.close() # second close must be a no-op, not a ProgrammingError
|
||||
|
||||
|
||||
def _count_rows_via_own_connection(db_path: Path) -> int:
|
||||
"""Count telemetry rows through a separate connection (like the aggregator)."""
|
||||
import contextlib
|
||||
import sqlite3
|
||||
|
||||
# NB: sqlite3's ``with conn`` is a TRANSACTION context (it does not close);
|
||||
# ``contextlib.closing`` actually closes the connection.
|
||||
with contextlib.closing(sqlite3.connect(db_path)) as conn:
|
||||
return len(conn.execute("SELECT * FROM telemetry").fetchall())
|
||||
|
||||
@@ -14,9 +14,9 @@ skipped if the server is unreachable.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from openjarvis.tools.storage.dense import (
|
||||
@@ -25,20 +25,58 @@ from openjarvis.tools.storage.dense import (
|
||||
chunk_markdown,
|
||||
dedupe_chunks,
|
||||
)
|
||||
from openjarvis.tools.storage.embeddings import OllamaEmbedder
|
||||
|
||||
_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "fixtures" / "docs"
|
||||
_OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "localhost")
|
||||
_OLLAMA_PORT = int(os.environ.get("OLLAMA_PORT", "11434"))
|
||||
_EMBED_MODEL = "nomic-embed-text"
|
||||
|
||||
|
||||
def _ollama_base_url() -> str:
|
||||
"""Resolve the Ollama base URL from ``OLLAMA_HOST``.
|
||||
|
||||
The project documents ``OLLAMA_HOST`` as a full URL
|
||||
(``http://<remote-ip>:11434``), but Ollama's own convention also
|
||||
allows bare ``host`` / ``host:port`` forms — accept all three so
|
||||
the probe and the embedder under test agree on one endpoint.
|
||||
"""
|
||||
host = os.environ.get("OLLAMA_HOST", "")
|
||||
if not host:
|
||||
return "http://localhost:11434"
|
||||
if host.startswith(("http://", "https://")):
|
||||
return host.rstrip("/")
|
||||
if ":" in host:
|
||||
return f"http://{host}"
|
||||
return f"http://{host}:11434"
|
||||
|
||||
|
||||
def _ollama_up() -> bool:
|
||||
"""True only if Ollama is reachable *and* the embed model is pulled.
|
||||
|
||||
A bare TCP connect isn't enough — a machine can run Ollama for chat
|
||||
models without ever having pulled ``nomic-embed-text``, which makes
|
||||
``/api/embed`` 404 instead of the tests skipping as intended.
|
||||
"""
|
||||
try:
|
||||
with socket.create_connection((_OLLAMA_HOST, _OLLAMA_PORT), timeout=1.0):
|
||||
return True
|
||||
except OSError:
|
||||
resp = httpx.get(f"{_ollama_base_url()}/api/tags", timeout=1.0)
|
||||
resp.raise_for_status()
|
||||
models = {m.get("model", "") for m in resp.json().get("models", [])}
|
||||
return any(m.startswith(_EMBED_MODEL) for m in models)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _make_backend() -> DenseMemory:
|
||||
"""DenseMemory wired to the same Ollama endpoint the probe checked.
|
||||
|
||||
``DenseMemory()`` alone would build an ``OllamaEmbedder`` with its
|
||||
hard-coded localhost default, so a remote ``OLLAMA_HOST`` could pass
|
||||
the probe and then have every test call the wrong server.
|
||||
"""
|
||||
return DenseMemory(
|
||||
embedder=OllamaEmbedder(model=_EMBED_MODEL, base_url=_ollama_base_url())
|
||||
)
|
||||
|
||||
|
||||
ollama_required = pytest.mark.skipif(
|
||||
not _ollama_up(),
|
||||
reason=(
|
||||
@@ -48,6 +86,65 @@ ollama_required = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip-guard unit tests (no Ollama required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeTagsResponse:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
class TestOllamaProbe:
|
||||
def test_base_url_accepts_all_documented_forms(self, monkeypatch):
|
||||
cases = {
|
||||
"http://remote:11434": "http://remote:11434",
|
||||
"http://remote:11434/": "http://remote:11434",
|
||||
"https://ollama.internal": "https://ollama.internal",
|
||||
"remote:8080": "http://remote:8080",
|
||||
"remote": "http://remote:11434",
|
||||
}
|
||||
for raw, expected in cases.items():
|
||||
monkeypatch.setenv("OLLAMA_HOST", raw)
|
||||
assert _ollama_base_url() == expected, raw
|
||||
monkeypatch.delenv("OLLAMA_HOST", raising=False)
|
||||
assert _ollama_base_url() == "http://localhost:11434"
|
||||
|
||||
def test_probe_false_when_server_down(self, monkeypatch):
|
||||
def _refuse(url, timeout):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _refuse)
|
||||
assert _ollama_up() is False
|
||||
|
||||
def test_probe_false_when_model_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"get",
|
||||
lambda url, timeout: _FakeTagsResponse({"models": [{"model": "llama3"}]}),
|
||||
)
|
||||
assert _ollama_up() is False
|
||||
|
||||
def test_probe_true_with_tagged_model_on_configured_url(self, monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_HOST", "http://remote:9999")
|
||||
seen = {}
|
||||
|
||||
def _get(url, timeout):
|
||||
seen["url"] = url
|
||||
return _FakeTagsResponse({"models": [{"model": "nomic-embed-text:latest"}]})
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
assert _ollama_up() is True
|
||||
assert seen["url"] == "http://remote:9999/api/tags"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chunking unit tests (no Ollama required)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -283,7 +380,7 @@ def indexed_backend():
|
||||
if not _ollama_up():
|
||||
pytest.skip("Ollama not reachable")
|
||||
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
md_files = sorted(_FIXTURE_DIR.glob("*.md"))
|
||||
assert md_files, f"no fixtures at {_FIXTURE_DIR}"
|
||||
|
||||
@@ -452,7 +549,7 @@ def test_score_distribution_vs_threshold(indexed_backend, capsys):
|
||||
class TestDenseMemoryAPI:
|
||||
@ollama_required
|
||||
def test_store_and_delete(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
doc_id = backend.store("the cat sat on the mat", source="a.txt")
|
||||
assert backend.count() == 1
|
||||
hits = backend.retrieve("where is the cat", top_k=1)
|
||||
@@ -463,12 +560,12 @@ class TestDenseMemoryAPI:
|
||||
|
||||
@ollama_required
|
||||
def test_empty_retrieve(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
assert backend.retrieve("anything", top_k=3) == []
|
||||
|
||||
@ollama_required
|
||||
def test_clear(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
backend.store("foo")
|
||||
backend.store("bar")
|
||||
backend.clear()
|
||||
|
||||
@@ -537,3 +537,51 @@ class TestGitLogTool:
|
||||
fn = tool.to_openai_function()
|
||||
assert fn["type"] == "function"
|
||||
assert fn["function"]["name"] == "git_log"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI fallback when the Rust extension is missing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCliFallbackWhenRustMissing:
|
||||
"""When ``get_rust_module`` raises ImportError (extension not built,
|
||||
e.g. a plain pip install), the read-only git tools must fall back to
|
||||
the git CLI instead of letting the ImportError escape ``execute()``."""
|
||||
|
||||
def _patch_no_rust(self):
|
||||
return patch(
|
||||
"openjarvis.tools.git_tool.get_rust_module",
|
||||
side_effect=ImportError("No module named 'openjarvis_rust'"),
|
||||
)
|
||||
|
||||
def test_git_status_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
(tmp_path / "new_file.txt").write_text("hello")
|
||||
with self._patch_no_rust():
|
||||
result = GitStatusTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "new_file.txt" in result.content
|
||||
|
||||
def test_git_diff_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
(tmp_path / "README.md").write_text("# Modified\n")
|
||||
with self._patch_no_rust():
|
||||
result = GitDiffTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "README.md" in result.content
|
||||
|
||||
def test_git_log_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
with self._patch_no_rust():
|
||||
result = GitLogTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "Initial commit" in result.content
|
||||
|
||||
def test_fallback_failure_is_a_tool_result_not_an_exception(self, tmp_path):
|
||||
# Even when the fallback itself fails (not a git repo), the tool
|
||||
# must return a failed ToolResult rather than raising.
|
||||
with self._patch_no_rust():
|
||||
result = GitStatusTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is False
|
||||
assert "not a git repository" in result.content
|
||||
|
||||
@@ -70,9 +70,7 @@ def test_allows_select_with_keyword_substring(store: KnowledgeStore) -> None:
|
||||
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
|
||||
|
||||
tool = KnowledgeSQLTool(store=store)
|
||||
result = tool.execute(
|
||||
query="SELECT author AS created_author FROM knowledge_chunks"
|
||||
)
|
||||
result = tool.execute(query="SELECT author AS created_author FROM knowledge_chunks")
|
||||
assert result.success, result.content
|
||||
assert "Alice" in result.content
|
||||
|
||||
|
||||
Reference in New Issue
Block a user