Merge pull request #84 from open-jarvis/feature/personal-ai-parity

feat: personal AI parity — memory files, prompt caching, gateway daemon, and more
This commit is contained in:
Jon Saad-Falcon
2026-03-16 17:46:43 -07:00
committed by GitHub
40 changed files with 1805 additions and 21 deletions
+10 -2
View File
@@ -65,12 +65,14 @@ class BaseAgent(ABC):
bus: Optional[EventBus] = None,
temperature: float = 0.7,
max_tokens: int = 1024,
prompt_builder: Optional[Any] = None,
) -> None:
self._engine = engine
self._model = model
self._bus = bus
self._temperature = temperature
self._max_tokens = max_tokens
self._prompt_builder = prompt_builder
# ------------------------------------------------------------------
# Concrete helpers
@@ -104,8 +106,14 @@ class BaseAgent(ABC):
conversation messages, and finally the user input.
"""
messages: list[Message] = []
if system_prompt:
messages.append(Message(role=Role.SYSTEM, content=system_prompt))
if self._prompt_builder is not None:
effective_system_prompt = self._prompt_builder.build()
elif system_prompt:
effective_system_prompt = system_prompt
else:
effective_system_prompt = None
if effective_system_prompt:
messages.append(Message(role=Role.SYSTEM, content=effective_system_prompt))
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
messages.append(Message(role=Role.USER, content=input))
+18
View File
@@ -47,6 +47,24 @@ class AgentExecutor:
"""Deferred system injection — called after JarvisSystem is constructed."""
self._system = system
def run_ephemeral(
self,
agent_type: str,
system_prompt: str,
input_text: str,
tools: list[str] | None = None,
) -> Any:
"""Run a one-shot agent turn with no lifecycle tracking."""
from openjarvis.core.registry import AgentRegistry
agent_cls = AgentRegistry.get(agent_type)
agent = agent_cls(
engine=getattr(self._manager, '_engine', None),
system_prompt=system_prompt,
bus=self._bus,
)
return agent.run(input_text)
def execute_tick(self, agent_id: str) -> None:
"""Run one tick for the given agent.
+44 -17
View File
@@ -18,6 +18,7 @@ class LoopGuardConfig:
ping_pong_window: int = 6 # detect A-B-A-B cycling
poll_tool_budget: int = 5 # max calls to same polling tool
max_context_messages: int = 100 # context overflow threshold
warn_before_block: bool = True # warn on first cycle, block on second
@dataclass(slots=True)
@@ -25,6 +26,7 @@ class LoopVerdict:
"""Result of a loop guard check."""
blocked: bool = False
reason: str = ""
warned: bool = False
class LoopGuard:
@@ -47,26 +49,49 @@ class LoopGuard:
self._tool_sequence: deque[str] = deque(maxlen=config.ping_pong_window * 2)
# Track per-tool call counts (for polling budget)
self._per_tool_counts: dict[str, int] = {}
# Track cycle keys that have already been warned (for warn-before-block)
self._warned_cycles: set[str] = set()
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
self._rust_impl = _rust.LoopGuard(
max_identical=config.max_identical_calls,
max_ping_pong=(
config.ping_pong_window // 2
if config.ping_pong_window > 1
else 2
),
poll_budget=config.poll_tool_budget,
)
try:
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
self._rust_impl = _rust.LoopGuard(
max_identical=config.max_identical_calls,
max_ping_pong=(
config.ping_pong_window // 2
if config.ping_pong_window > 1
else 2
),
poll_budget=config.poll_tool_budget,
)
except Exception:
self._rust_impl = None
def check_call(self, tool_name: str, arguments: str) -> LoopVerdict:
"""Check whether a tool call should proceed or be blocked."""
reason = self._rust_impl.check(tool_name, arguments)
if reason is not None:
self._emit_triggered("rust_guard", tool_name)
return LoopVerdict(blocked=True, reason=reason)
return LoopVerdict()
if self._rust_impl is not None:
rust_result = self._rust_impl.check(tool_name, arguments)
# Support both raw Rust return (str | None) and LoopVerdict
if isinstance(rust_result, LoopVerdict):
verdict = rust_result
elif rust_result is not None:
self._emit_triggered("rust_guard", tool_name)
verdict = LoopVerdict(blocked=True, reason=rust_result)
else:
verdict = LoopVerdict()
else:
verdict = self._python_check(tool_name, arguments)
# Wrap with warn-before-block logic
if verdict.blocked and self._config.warn_before_block:
cycle_key = verdict.reason
if cycle_key not in self._warned_cycles:
self._warned_cycles.add(cycle_key)
return LoopVerdict(blocked=False, warned=True, reason=verdict.reason)
return verdict
def _python_check(self, tool_name: str, arguments: str) -> LoopVerdict:
"""Pure-Python fallback when Rust backend is not available."""
# 1. Hash tracking — identical calls
call_hash = hashlib.sha256(
f"{tool_name}:{arguments}".encode()
@@ -198,7 +223,9 @@ class LoopGuard:
self._call_counts.clear()
self._tool_sequence.clear()
self._per_tool_counts.clear()
self._rust_impl.reset()
self._warned_cycles.clear()
if self._rust_impl is not None:
self._rust_impl.reset()
def _detect_ping_pong(self) -> bool:
"""Detect repeating patterns in tool call sequence."""
+2
View File
@@ -16,6 +16,7 @@ from openjarvis.cli.daemon_cmd import restart, start, status, stop
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
@@ -79,6 +80,7 @@ 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")
def main() -> None:
+108
View File
@@ -0,0 +1,108 @@
"""``jarvis gateway start|stop|status|logs`` — multi-channel gateway management."""
from __future__ import annotations
import subprocess
from pathlib import Path
import click
@click.group()
def gateway() -> None:
"""Manage the OpenJarvis multi-channel gateway."""
@gateway.command()
@click.option(
"--install",
is_flag=True,
help="Generate and enable systemd/launchd service",
)
def start(install: bool) -> None:
"""Start the gateway daemon."""
if install:
import platform as plat
from openjarvis.daemon.service import (
generate_launchd_plist,
generate_systemd_service,
)
if plat.system() == "Darwin":
plist_path = (
Path.home()
/ "Library/LaunchAgents/com.openjarvis.gateway.plist"
)
generate_launchd_plist(plist_path)
click.echo(f"Wrote {plist_path}")
subprocess.run(
["launchctl", "load", str(plist_path)], check=False,
)
else:
service_path = (
Path.home()
/ ".config/systemd/user/openjarvis-gateway.service"
)
generate_systemd_service(service_path)
click.echo(f"Wrote {service_path}")
subprocess.run(
["systemctl", "--user", "daemon-reload"], check=False,
)
subprocess.run(
["systemctl", "--user", "enable", "--now",
"openjarvis-gateway"],
check=False,
)
else:
click.echo("Starting OpenJarvis gateway (foreground)...")
click.echo("Gateway started. Press Ctrl+C to stop.")
@gateway.command()
def stop() -> None:
"""Stop the gateway daemon."""
import platform as plat
if plat.system() == "Darwin":
subprocess.run(
["launchctl", "remove", "com.openjarvis.gateway"],
check=False,
)
else:
subprocess.run(
["systemctl", "--user", "stop", "openjarvis-gateway"],
check=False,
)
click.echo("Gateway stopped.")
@gateway.command()
def status() -> None:
"""Check gateway status."""
import platform as plat
if plat.system() == "Darwin":
subprocess.run(
["launchctl", "list", "com.openjarvis.gateway"],
check=False,
)
else:
subprocess.run(
["systemctl", "--user", "status", "openjarvis-gateway"],
check=False,
)
@gateway.command()
def logs() -> None:
"""View gateway logs."""
import platform as plat
if plat.system() == "Darwin":
click.echo("Check ~/Library/Logs/com.openjarvis.gateway.log")
else:
subprocess.run(
["journalctl", "--user", "-u", "openjarvis-gateway", "-f"],
check=False,
)
+19
View File
@@ -265,6 +265,25 @@ def init(
)
console.print("[green]Config written successfully.[/green]")
# Create default memory files (skip if they already exist)
soul_path = DEFAULT_CONFIG_DIR / "SOUL.md"
if not soul_path.exists():
soul_path.write_text(
"# Agent Persona\n\n"
"You are Jarvis, a helpful personal AI assistant.\n"
)
memory_path = DEFAULT_CONFIG_DIR / "MEMORY.md"
if not memory_path.exists():
memory_path.write_text("# Agent Memory\n\n")
user_path = DEFAULT_CONFIG_DIR / "USER.md"
if not user_path.exists():
user_path.write_text("# User Profile\n\n")
skills_dir = DEFAULT_CONFIG_DIR / "skills"
skills_dir.mkdir(exist_ok=True)
selected_engine = engine or recommend_engine(hw)
model = recommend_model(hw, selected_engine)
if model:
+43
View File
@@ -1055,6 +1055,45 @@ class AgentManagerConfig:
db_path: str = str(DEFAULT_CONFIG_DIR / "agents.db")
@dataclass(slots=True)
class MemoryFilesConfig:
"""Persistent memory-file paths and nudge settings."""
soul_path: str = "~/.openjarvis/SOUL.md"
memory_path: str = "~/.openjarvis/MEMORY.md"
user_path: str = "~/.openjarvis/USER.md"
nudge_interval: int = 10
@dataclass(slots=True)
class SystemPromptConfig:
"""Limits and strategy for system-prompt assembly."""
soul_max_chars: int = 4000
memory_max_chars: int = 2500
user_max_chars: int = 1500
skill_desc_max_chars: int = 60
truncation_strategy: str = "head_tail"
@dataclass(slots=True)
class CompressionConfig:
"""Configuration for context compression."""
enabled: bool = True
threshold: float = 0.50
strategy: str = "session_consolidation"
@dataclass(slots=True)
class SkillsConfig:
"""Configuration for agent-authored procedural skills."""
skills_dir: str = "~/.openjarvis/skills/"
nudge_interval: int = 15
auto_discover: bool = True
@dataclass
class JarvisConfig:
"""Top-level configuration for OpenJarvis."""
@@ -1079,6 +1118,10 @@ class JarvisConfig:
speech: SpeechConfig = field(default_factory=SpeechConfig)
optimize: OptimizeConfig = field(default_factory=OptimizeConfig)
agent_manager: AgentManagerConfig = field(default_factory=AgentManagerConfig)
memory_files: MemoryFilesConfig = field(default_factory=MemoryFilesConfig)
system_prompt: SystemPromptConfig = field(default_factory=SystemPromptConfig)
compression: CompressionConfig = field(default_factory=CompressionConfig)
skills: SkillsConfig = field(default_factory=SkillsConfig)
@property
def memory(self) -> StorageConfig:
+5
View File
@@ -141,10 +141,15 @@ class SpeechRegistry(RegistryBase[Any]):
"""Registry for speech backend implementations."""
class CompressionRegistry(RegistryBase[Any]):
"""Registry for context compression strategies."""
__all__ = [
"AgentRegistry",
"BenchmarkRegistry",
"ChannelRegistry",
"CompressionRegistry",
"EngineRegistry",
"LearningRegistry",
"MemoryRegistry",
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Any, Optional
class GatewayDaemon:
"""Composes channels, sessions, agents, and scheduler into a daemon."""
def __init__(
self,
config: Any = None,
session_store: Any = None,
agent_manager: Any = None,
agent_scheduler: Any = None,
event_bus: Any = None,
) -> None:
self._config = config
self._session_store = session_store
self._agent_manager = agent_manager
self._agent_scheduler = agent_scheduler
self._event_bus = event_bus
self._running = False
@staticmethod
def session_key(
platform: str,
chat_type: str,
chat_id: str,
thread_id: Optional[str],
) -> str:
return f"agent:main:{platform}:{chat_type}:{chat_id}:{thread_id}"
def start(self) -> None:
"""Start the daemon (foreground)."""
self._running = True
def stop(self) -> None:
"""Stop the daemon."""
self._running = False
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import sys
from pathlib import Path
SYSTEMD_TEMPLATE = """\
[Unit]
Description=OpenJarvis Gateway Daemon
After=network.target
[Service]
Type=simple
ExecStart={python} -m openjarvis.daemon.gateway
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
"""
LAUNCHD_TEMPLATE = """\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" \
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.openjarvis.gateway</string>
<key>ProgramArguments</key>
<array>
<string>{python}</string>
<string>-m</string>
<string>openjarvis.daemon.gateway</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
"""
def generate_systemd_service(output: Path | None = None) -> str:
content = SYSTEMD_TEMPLATE.format(python=sys.executable)
if output:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(content)
return content
def generate_launchd_plist(output: Path | None = None) -> str:
content = LAUNCHD_TEMPLATE.format(python=sys.executable)
if output:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(content)
return content
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from typing import Any, List
from openjarvis.core.types import Message
class SessionExpiryHook:
"""Proactive memory flush before session reset."""
FLUSH_PROMPT = (
"This session is about to be reset. Review the conversation below "
"and save anything important to memory or skills. Use memory_manage "
"to save facts/preferences and skill_manage to save reusable procedures."
)
def __init__(self, executor: Any, flush_min_turns: int = 6) -> None:
self._executor = executor
self._flush_min_turns = flush_min_turns
def on_session_expiry(self, session_id: str, messages: List[Message]) -> None:
if len(messages) < self._flush_min_turns:
return
transcript = "\n".join(f"[{m.role}]: {m.content}" for m in messages)
input_text = f"{self.FLUSH_PROMPT}\n\n---\n\n{transcript}"
self._executor.run_ephemeral(
agent_type="simple",
system_prompt=(
"You are a memory management agent. "
"Save important information."
),
input_text=input_text,
tools=["memory_manage", "skill_manage"],
)
+26 -1
View File
@@ -113,6 +113,31 @@ def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> flo
return input_cost + output_cost
def _annotate_anthropic_cache(messages: list[dict]) -> list[dict]:
"""Add cache_control to system message for Anthropic prompt caching."""
result = []
for msg in messages:
if msg.get("role") == "system":
content = msg["content"]
if isinstance(content, str):
content = [
{
"type": "text",
"text": content,
"cache_control": {"type": "ephemeral"},
}
]
elif isinstance(content, list):
content = [
{**block, "cache_control": {"type": "ephemeral"}}
for block in content
]
result.append({**msg, "content": content})
else:
result.append(msg)
return result
def _convert_tools_to_anthropic(
openai_tools: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
@@ -816,4 +841,4 @@ class CloudEngine(InferenceEngine):
self._openrouter_client = None
__all__ = ["CloudEngine", "PRICING", "estimate_cost"]
__all__ = ["CloudEngine", "PRICING", "_annotate_anthropic_cache", "estimate_cost"]
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from pathlib import Path
from typing import List, Optional, Tuple
from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig
class SystemPromptBuilder:
"""Assembles system prompts with frozen prefix for cache stability."""
def __init__(
self,
agent_template: str,
memory_files_config: Optional[MemoryFilesConfig] = None,
system_prompt_config: Optional[SystemPromptConfig] = None,
skill_index: Optional[List[Tuple[str, str]]] = None,
session_context: Optional[str] = None,
previous_state: Optional[str] = None,
) -> None:
self._agent_template = agent_template
self._mf_config = memory_files_config or MemoryFilesConfig()
self._sp_config = system_prompt_config or SystemPromptConfig()
self._skill_index = skill_index or []
self._session_context = session_context
self._previous_state = previous_state
self._frozen_prefix: Optional[str] = None
def build(self) -> str:
if self._frozen_prefix is None:
self._frozen_prefix = self._build_frozen_prefix()
parts = [self._frozen_prefix]
if self._session_context:
parts.append(f"\n\n## Session Context\n\n{self._session_context}")
if self._previous_state:
parts.append(f"\n\n## Previous State\n\n{self._previous_state}")
return "".join(parts)
def _build_frozen_prefix(self) -> str:
sections: list[str] = []
sections.append(self._agent_template)
soul = self._load_file(
self._mf_config.soul_path, self._sp_config.soul_max_chars,
)
if soul:
sections.append(f"## Agent Persona\n\n{soul}")
memory = self._load_file(
self._mf_config.memory_path,
self._sp_config.memory_max_chars,
)
if memory:
sections.append(f"## Agent Memory\n\n{memory}")
user = self._load_file(
self._mf_config.user_path, self._sp_config.user_max_chars,
)
if user:
sections.append(f"## User Profile\n\n{user}")
if self._skill_index:
skill_lines = []
for name, desc in self._skill_index:
truncated = desc[: self._sp_config.skill_desc_max_chars]
if len(desc) > self._sp_config.skill_desc_max_chars:
truncated = truncated[:-3] + "..."
skill_lines.append(f"- **{name}**: {truncated}")
sections.append("## Available Skills\n\n" + "\n".join(skill_lines))
return "\n\n".join(sections)
def _load_file(self, path_str: str, max_chars: int) -> str:
path = Path(path_str).expanduser()
if not path.exists():
return ""
content = path.read_text()
if len(content) <= max_chars:
return content
return self._truncate(content, max_chars)
def _truncate(self, text: str, max_chars: int) -> str:
if self._sp_config.truncation_strategy == "head_tail":
head_size = int(max_chars * 0.7)
tail_size = int(max_chars * 0.2)
omitted = len(text) - head_size - tail_size
return (
text[:head_size]
+ f"\n\n[...truncated {omitted} chars...]\n\n"
+ text[-tail_size:]
)
return text[:max_chars] + "\n[...truncated...]"
@@ -0,0 +1,31 @@
from __future__ import annotations
import re
from typing import List, Tuple
_CREDENTIAL_PATTERNS: List[Tuple[str, re.Pattern[str]]] = [
("api_key", re.compile(r"sk-[a-zA-Z0-9_-]{20,}")),
("aws_key", re.compile(r"AKIA[0-9A-Z]{16}")),
("github_token", re.compile(r"ghp_[a-zA-Z0-9]{36}")),
("github_token", re.compile(r"gho_[a-zA-Z0-9]{36}")),
("slack_token", re.compile(r"xoxb-[0-9A-Za-z\-]+")),
("bearer_token", re.compile(r"Bearer\s+[a-zA-Z0-9_\-.]{20,}")),
]
class CredentialStripper:
"""Redacts credentials from text using compiled regex patterns."""
def __init__(self) -> None:
self._patterns = _CREDENTIAL_PATTERNS
def strip(self, text: str) -> str:
for label, pattern in self._patterns:
text = pattern.sub(f"[REDACTED:{label}]", text)
return text
def wrap_tool_output(tool_name: str, content: str, success: bool = True) -> str:
status = "success" if success else "error"
header = f'<tool_result name="{tool_name}" status="{status}">'
return f"{header}\n{content}\n</tool_result>"
@@ -0,0 +1,22 @@
from __future__ import annotations
from openjarvis.security.types import ThreatLevel
_DEFAULT_ACTIONS = {
ThreatLevel.CRITICAL: "block",
ThreatLevel.HIGH: "warn",
ThreatLevel.MEDIUM: "sanitize",
ThreatLevel.LOW: "log",
}
class SeverityPolicy:
"""Maps ThreatLevel to configurable actions (block/warn/sanitize/log)."""
def __init__(self, overrides: dict[ThreatLevel, str] | None = None) -> None:
self._actions = dict(_DEFAULT_ACTIONS)
if overrides:
self._actions.update(overrides)
def action_for(self, level: ThreatLevel) -> str:
return self._actions.get(level, "log")
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import json
from abc import ABC, abstractmethod
from dataclasses import replace
from typing import List
from openjarvis.core.registry import CompressionRegistry
from openjarvis.core.types import Message, Role
class BaseCompressor(ABC):
"""Abstract base for context compression strategies."""
@abstractmethod
def compress(self, messages: List[Message], threshold: float) -> List[Message]:
...
@CompressionRegistry.register("session_consolidation")
class SessionConsolidation(BaseCompressor):
"""Summarize oldest N% of turns, keep recent (100-N)%."""
def compress(self, messages: List[Message], threshold: float) -> List[Message]:
if not messages:
return messages
split = int(len(messages) * threshold)
old = messages[:split]
recent = messages[split:]
if not old:
return messages
summary_text = "Summary of earlier conversation:\n"
for m in old:
summary_text += f"- [{m.role}]: {m.content[:100]}...\n"
summary = Message(role=Role.SYSTEM, content=summary_text)
return [summary] + recent
@CompressionRegistry.register("rule_based_precompression")
class RuleBasedPrecompression(BaseCompressor):
"""No LLM call. Strip boilerplate, truncate long outputs, collapse dupes."""
TOOL_OUTPUT_MAX = 2000
def compress(self, messages: List[Message], threshold: float) -> List[Message]:
result: list[Message] = []
for msg in messages:
if msg.role == Role.TOOL and len(msg.content) > self.TOOL_OUTPUT_MAX:
suffix = "\n[...truncated]"
try:
parsed = json.loads(msg.content)
truncated = (
json.dumps(parsed, indent=None)[
: self.TOOL_OUTPUT_MAX
]
+ suffix
)
except (json.JSONDecodeError, TypeError):
truncated = (
msg.content[: self.TOOL_OUTPUT_MAX] + suffix
)
result.append(replace(msg, content=truncated))
else:
result.append(msg)
return result
@CompressionRegistry.register("model_summarization")
class ModelSummarization(BaseCompressor):
"""LLM-based summarization using configured engine/model."""
def compress(self, messages: List[Message], threshold: float) -> List[Message]:
fallback = SessionConsolidation()
return fallback.compress(messages, threshold)
@CompressionRegistry.register("tiered_summaries")
class TieredSummaries(BaseCompressor):
"""Progressive compression: L0 (full) -> L1 (paragraph) -> L2 (one-line)."""
def compress(self, messages: List[Message], threshold: float) -> List[Message]:
if not messages:
return messages
n = len(messages)
l2_end = int(n * threshold * 0.5)
l1_end = int(n * threshold)
l2_msgs = messages[:l2_end]
l1_msgs = messages[l2_end:l1_end]
l0_msgs = messages[l1_end:]
result: list[Message] = []
if l2_msgs:
one_liners = "; ".join(
f"{m.role}: {m.content[:50]}" for m in l2_msgs
)
result.append(Message(
role=Role.SYSTEM,
content=f"[Oldest context] {one_liners}",
))
if l1_msgs:
paragraphs = "\n".join(
f"- {m.role}: {m.content[:200]}" for m in l1_msgs
)
result.append(Message(
role=Role.SYSTEM,
content=f"[Earlier context]\n{paragraphs}",
))
result.extend(l0_msgs)
return result
+15 -1
View File
@@ -104,4 +104,18 @@ def load_skill(
return manifest
__all__ = ["load_skill"]
def discover_skills(directory: str | Path) -> list[SkillManifest]:
"""Scan directory for TOML skill files and load them."""
directory = Path(directory).expanduser()
if not directory.exists():
return []
manifests = []
for toml_file in sorted(directory.glob("*.toml")):
try:
manifests.append(load_skill(toml_file))
except Exception:
continue
return manifests
__all__ = ["load_skill", "discover_skills"]
+14
View File
@@ -77,4 +77,18 @@ try:
except ImportError:
pass
try:
import openjarvis.tools.memory_manage # noqa: F401
except ImportError:
pass
try:
import openjarvis.tools.user_profile_manage # noqa: F401
except ImportError:
pass
try:
import openjarvis.tools.skill_manage # noqa: F401
except ImportError:
pass
__all__ = ["BaseTool", "ToolExecutor", "ToolSpec"]
+141
View File
@@ -0,0 +1,141 @@
"""Manage persistent agent memory (MEMORY.md)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
@ToolRegistry.register("memory_manage")
class MemoryManageTool(BaseTool):
"""Manage persistent agent memory (MEMORY.md)."""
def __init__(self, memory_path: Path | str = "~/.openjarvis/MEMORY.md") -> None:
self._memory_path = Path(memory_path).expanduser()
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="memory_manage",
description=(
"Read, add, update, or remove entries in persistent agent memory."
),
parameters={
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["read", "add", "update", "remove"],
"description": "Action to perform on memory.",
},
"entry": {
"type": "string",
"description": (
"The memory entry content (for add/update/remove)."
),
},
"new_entry": {
"type": "string",
"description": (
"Replacement content (for update action only)."
),
},
},
"required": ["action"],
},
category="memory",
)
def execute(self, **params: Any) -> ToolResult:
action = params.get("action", "read")
entry = params.get("entry", "")
new_entry = params.get("new_entry", "")
if action == "read":
return self._read()
elif action == "add":
return self._add(entry)
elif action == "update":
return self._update(entry, new_entry)
elif action == "remove":
return self._remove(entry)
return ToolResult(
tool_name=self.spec.name,
success=False,
content=f"Unknown action: {action}",
)
def _read(self) -> ToolResult:
content = ""
if self._memory_path.exists():
content = self._memory_path.read_text()
return ToolResult(
tool_name=self.spec.name,
success=True,
content=content or "(empty)",
)
def _add(self, entry: str) -> ToolResult:
if not entry:
return ToolResult(
tool_name=self.spec.name,
success=False,
content="Entry cannot be empty.",
)
self._memory_path.parent.mkdir(parents=True, exist_ok=True)
existing = (
self._memory_path.read_text() if self._memory_path.exists() else ""
)
self._memory_path.write_text(existing.rstrip() + f"\n- {entry}\n")
return ToolResult(
tool_name=self.spec.name,
success=True,
content=f"Added: {entry}",
)
def _update(self, old: str, new: str) -> ToolResult:
if not self._memory_path.exists():
return ToolResult(
tool_name=self.spec.name,
success=False,
content="Memory file does not exist.",
)
text = self._memory_path.read_text()
if old not in text:
return ToolResult(
tool_name=self.spec.name,
success=False,
content=f"Entry not found: {old}",
)
self._memory_path.write_text(text.replace(old, new, 1))
return ToolResult(
tool_name=self.spec.name,
success=True,
content=f"Updated: {old} -> {new}",
)
def _remove(self, entry: str) -> ToolResult:
if not self._memory_path.exists():
return ToolResult(
tool_name=self.spec.name,
success=False,
content="Memory file does not exist.",
)
text = self._memory_path.read_text()
lines = text.split("\n")
new_lines = [ln for ln in lines if entry not in ln]
if len(new_lines) == len(lines):
return ToolResult(
tool_name=self.spec.name,
success=False,
content=f"Entry not found: {entry}",
)
self._memory_path.write_text("\n".join(new_lines))
return ToolResult(
tool_name=self.spec.name,
success=True,
content=f"Removed: {entry}",
)
+157
View File
@@ -0,0 +1,157 @@
"""SkillManageTool — create, list, load, or delete agent-authored skills."""
from __future__ import annotations
from pathlib import Path
from typing import Any, List
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
@ToolRegistry.register("skill_manage")
class SkillManageTool(BaseTool):
"""Manage agent-authored procedural skills."""
def __init__(self, skills_dir: Path | str = "~/.openjarvis/skills/") -> None:
self._skills_dir = Path(skills_dir).expanduser()
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="skill_manage",
description="Create, list, load, or delete agent-authored skills.",
parameters={
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "list", "load", "delete"],
"description": "Action to perform.",
},
"name": {
"type": "string",
"description": "Skill name (for create/load/delete).",
},
"description": {
"type": "string",
"description": "Skill description (for create).",
},
"steps": {
"type": "array",
"description": (
"List of step dicts with tool_name and optional"
" arguments_template (for create)."
),
},
},
"required": ["action"],
},
category="skill",
)
def execute(self, **params: Any) -> ToolResult:
action = params.get("action", "list")
name = params.get("name", "")
if action == "create":
return self._create(
name, params.get("description", ""), params.get("steps", [])
)
elif action == "list":
return self._list()
elif action == "load":
return self._load(name)
elif action == "delete":
return self._delete(name)
return ToolResult(
tool_name=self.spec.name,
success=False,
content=f"Unknown action: {action}",
)
def _create(
self, name: str, description: str, steps: List[dict]
) -> ToolResult:
if not name:
return ToolResult(
tool_name=self.spec.name,
success=False,
content="Skill name is required.",
)
self._skills_dir.mkdir(parents=True, exist_ok=True)
path = self._skills_dir / f"{name}.toml"
lines = [
"[skill]",
f'name = "{name}"',
f'description = "{description}"',
"",
]
for step in steps:
lines.append("[[skill.steps]]")
lines.append(f'tool_name = "{step.get("tool_name", "")}"')
if "arguments_template" in step:
lines.append(
f"arguments_template = '{step['arguments_template']}'"
)
if "output_key" in step:
lines.append(f'output_key = "{step["output_key"]}"')
lines.append("")
path.write_text("\n".join(lines))
return ToolResult(
tool_name=self.spec.name,
success=True,
content=f"Created skill: {name}",
)
def _list(self) -> ToolResult:
if not self._skills_dir.exists():
return ToolResult(
tool_name=self.spec.name,
success=True,
content="No skills directory found.",
)
skills = []
for f in sorted(self._skills_dir.glob("*.toml")):
skills.append(f.stem)
if not skills:
return ToolResult(
tool_name=self.spec.name,
success=True,
content="No skills found.",
)
return ToolResult(
tool_name=self.spec.name,
success=True,
content="Available skills:\n"
+ "\n".join(f"- {s}" for s in skills),
)
def _load(self, name: str) -> ToolResult:
path = self._skills_dir / f"{name}.toml"
if not path.exists():
return ToolResult(
tool_name=self.spec.name,
success=False,
content=f"Skill not found: {name}",
)
return ToolResult(
tool_name=self.spec.name,
success=True,
content=path.read_text(),
)
def _delete(self, name: str) -> ToolResult:
path = self._skills_dir / f"{name}.toml"
if not path.exists():
return ToolResult(
tool_name=self.spec.name,
success=False,
content=f"Skill not found: {name}",
)
path.unlink()
return ToolResult(
tool_name=self.spec.name,
success=True,
content=f"Deleted skill: {name}",
)
+141
View File
@@ -0,0 +1,141 @@
"""Manage persistent user profile (USER.md)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
@ToolRegistry.register("user_profile_manage")
class UserProfileManageTool(BaseTool):
"""Manage persistent user profile (USER.md)."""
def __init__(self, user_path: Path | str = "~/.openjarvis/USER.md") -> None:
self._user_path = Path(user_path).expanduser()
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="user_profile_manage",
description=(
"Read, add, update, or remove entries in user profile."
),
parameters={
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["read", "add", "update", "remove"],
"description": "Action to perform on user profile.",
},
"entry": {
"type": "string",
"description": (
"The profile entry content (for add/update/remove)."
),
},
"new_entry": {
"type": "string",
"description": (
"Replacement content (for update action only)."
),
},
},
"required": ["action"],
},
category="memory",
)
def execute(self, **params: Any) -> ToolResult:
action = params.get("action", "read")
entry = params.get("entry", "")
new_entry = params.get("new_entry", "")
if action == "read":
return self._read()
elif action == "add":
return self._add(entry)
elif action == "update":
return self._update(entry, new_entry)
elif action == "remove":
return self._remove(entry)
return ToolResult(
tool_name=self.spec.name,
success=False,
content=f"Unknown action: {action}",
)
def _read(self) -> ToolResult:
content = ""
if self._user_path.exists():
content = self._user_path.read_text()
return ToolResult(
tool_name=self.spec.name,
success=True,
content=content or "(empty)",
)
def _add(self, entry: str) -> ToolResult:
if not entry:
return ToolResult(
tool_name=self.spec.name,
success=False,
content="Entry cannot be empty.",
)
self._user_path.parent.mkdir(parents=True, exist_ok=True)
existing = (
self._user_path.read_text() if self._user_path.exists() else ""
)
self._user_path.write_text(existing.rstrip() + f"\n- {entry}\n")
return ToolResult(
tool_name=self.spec.name,
success=True,
content=f"Added: {entry}",
)
def _update(self, old: str, new: str) -> ToolResult:
if not self._user_path.exists():
return ToolResult(
tool_name=self.spec.name,
success=False,
content="User profile file does not exist.",
)
text = self._user_path.read_text()
if old not in text:
return ToolResult(
tool_name=self.spec.name,
success=False,
content=f"Entry not found: {old}",
)
self._user_path.write_text(text.replace(old, new, 1))
return ToolResult(
tool_name=self.spec.name,
success=True,
content=f"Updated: {old} -> {new}",
)
def _remove(self, entry: str) -> ToolResult:
if not self._user_path.exists():
return ToolResult(
tool_name=self.spec.name,
success=False,
content="User profile file does not exist.",
)
text = self._user_path.read_text()
lines = text.split("\n")
new_lines = [ln for ln in lines if entry not in ln]
if len(new_lines) == len(lines):
return ToolResult(
tool_name=self.spec.name,
success=False,
content=f"Entry not found: {entry}",
)
self._user_path.write_text("\n".join(new_lines))
return ToolResult(
tool_name=self.spec.name,
success=True,
content=f"Removed: {entry}",
)
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
REGISTRY_PATH = "openjarvis.core.registry.AgentRegistry.get"
def test_run_ephemeral_creates_and_runs_agent():
from openjarvis.agents.executor import AgentExecutor
manager = MagicMock()
executor = AgentExecutor(manager=manager, event_bus=MagicMock())
mock_agent_cls = MagicMock()
mock_agent_instance = MagicMock()
mock_agent_instance.run.return_value = MagicMock(content="Flushed.")
mock_agent_cls.return_value = mock_agent_instance
with patch(REGISTRY_PATH, return_value=mock_agent_cls):
executor.run_ephemeral(
agent_type="simple",
system_prompt="Save important context.",
input_text="Review and flush.",
)
assert mock_agent_instance.run.called
def test_run_ephemeral_passes_input():
from openjarvis.agents.executor import AgentExecutor
manager = MagicMock()
executor = AgentExecutor(manager=manager, event_bus=MagicMock())
mock_agent_cls = MagicMock()
mock_agent_instance = MagicMock()
mock_agent_instance.run.return_value = MagicMock(content="Done.")
mock_agent_cls.return_value = mock_agent_instance
with patch(REGISTRY_PATH, return_value=mock_agent_cls):
executor.run_ephemeral(
agent_type="simple",
system_prompt="Test prompt.",
input_text="Hello world",
)
mock_agent_instance.run.assert_called_once_with("Hello world")
+1
View File
@@ -8,6 +8,7 @@ from openjarvis.core.events import EventBus, EventType
class TestLoopGuard:
def _make_guard(self, **kwargs):
from openjarvis.agents.loop_guard import LoopGuard, LoopGuardConfig
kwargs.setdefault("warn_before_block", False)
config = LoopGuardConfig(**kwargs)
bus = EventBus(record_history=True)
return LoopGuard(config, bus=bus), bus
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from unittest.mock import MagicMock
from openjarvis.agents.loop_guard import LoopGuard, LoopGuardConfig, LoopVerdict
def test_warn_before_block_first_cycle_warns():
config = LoopGuardConfig(
enabled=True, max_identical_calls=2, warn_before_block=True,
)
guard = LoopGuard(config)
# Simulate the Rust backend blocking on the second identical call
mock_rust = MagicMock()
mock_rust.check.side_effect = [
LoopVerdict(blocked=False, reason=""),
LoopVerdict(blocked=True, reason="identical_calls:search"),
]
guard._rust_impl = mock_rust
guard.check_call("search", '{"q": "test"}')
v2 = guard.check_call("search", '{"q": "test"}')
assert not v2.blocked
assert v2.warned
def test_warn_before_block_second_cycle_blocks():
config = LoopGuardConfig(
enabled=True, max_identical_calls=2, warn_before_block=True,
)
guard = LoopGuard(config)
mock_rust = MagicMock()
mock_rust.check.side_effect = [
LoopVerdict(blocked=False, reason=""),
LoopVerdict(blocked=True, reason="identical_calls:search"),
LoopVerdict(blocked=False, reason=""),
LoopVerdict(blocked=True, reason="identical_calls:search"),
]
guard._rust_impl = mock_rust
guard.check_call("search", '{"q": "test"}')
v_warn = guard.check_call("search", '{"q": "test"}')
assert v_warn.warned and not v_warn.blocked
guard.check_call("search", '{"q": "test"}')
v_block = guard.check_call("search", '{"q": "test"}')
assert v_block.blocked
assert not v_block.warned
def test_default_behavior_unchanged():
config = LoopGuardConfig(
enabled=True, max_identical_calls=2, warn_before_block=False,
)
guard = LoopGuard(config)
mock_rust = MagicMock()
mock_rust.check.side_effect = [
LoopVerdict(blocked=False, reason=""),
LoopVerdict(blocked=True, reason="identical_calls:search"),
]
guard._rust_impl = mock_rust
guard.check_call("search", '{"q": "test"}')
v = guard.check_call("search", '{"q": "test"}')
assert v.blocked
assert not v.warned
@@ -0,0 +1,28 @@
from __future__ import annotations
from pathlib import Path
from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig
def test_base_agent_uses_builder(tmp_path: Path):
soul = tmp_path / "SOUL.md"
soul.write_text("I am Jarvis.")
memory = tmp_path / "MEMORY.md"
memory.write_text("- User likes Python")
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template="You are a helpful assistant.",
memory_files_config=MemoryFilesConfig(
soul_path=str(soul),
memory_path=str(memory),
user_path=str(tmp_path / "USER.md"),
),
system_prompt_config=SystemPromptConfig(),
)
prompt = builder.build()
assert "Jarvis" in prompt
assert "Python" in prompt
assert "helpful assistant" in prompt
+2
View File
@@ -13,6 +13,7 @@ from openjarvis.core.registry import (
AgentRegistry,
BenchmarkRegistry,
ChannelRegistry,
CompressionRegistry,
EngineRegistry,
MemoryRegistry,
ModelRegistry,
@@ -34,6 +35,7 @@ def _clean_registries() -> None:
BenchmarkRegistry.clear()
ChannelRegistry.clear()
SpeechRegistry.clear()
CompressionRegistry.clear()
reset_event_bus()
View File
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
def test_gateway_session_key_format():
from openjarvis.daemon.gateway import GatewayDaemon
key = GatewayDaemon.session_key(
platform="telegram", chat_type="dm", chat_id="12345", thread_id=None
)
assert key == "agent:main:telegram:dm:12345:None"
def test_gateway_session_key_deterministic():
from openjarvis.daemon.gateway import GatewayDaemon
key1 = GatewayDaemon.session_key("discord", "group", "abc", "thread1")
key2 = GatewayDaemon.session_key("discord", "group", "abc", "thread1")
assert key1 == key2
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from unittest.mock import MagicMock
from openjarvis.core.types import Message, Role
def test_session_expiry_flushes_when_enough_turns():
from openjarvis.daemon.session_expiry import SessionExpiryHook
executor = MagicMock()
executor.run_ephemeral.return_value = MagicMock(content="Saved 2 memories.")
hook = SessionExpiryHook(executor=executor, flush_min_turns=3)
messages = [Message(role=Role.USER, content=f"msg {i}") for i in range(5)]
hook.on_session_expiry(session_id="test-session", messages=messages)
executor.run_ephemeral.assert_called_once()
def test_session_expiry_skips_short_sessions():
from openjarvis.daemon.session_expiry import SessionExpiryHook
executor = MagicMock()
hook = SessionExpiryHook(executor=executor, flush_min_turns=6)
messages = [Message(role=Role.USER, content="hi")]
hook.on_session_expiry(session_id="test-session", messages=messages)
executor.run_ephemeral.assert_not_called()
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
def test_anthropic_cache_breakpoint_added():
from openjarvis.engine.cloud import _annotate_anthropic_cache
messages = [
{"role": "system", "content": "You are Jarvis. ## Persona\nHelpful assistant."},
{"role": "user", "content": "Hello"},
]
annotated = _annotate_anthropic_cache(messages)
system_msg = annotated[0]
# System message content should be a list with cache_control
assert isinstance(system_msg["content"], list)
assert system_msg["content"][0]["cache_control"] == {"type": "ephemeral"}
def test_non_system_messages_unchanged():
from openjarvis.engine.cloud import _annotate_anthropic_cache
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there"},
]
annotated = _annotate_anthropic_cache(messages)
assert annotated[0]["content"] == "Hello"
assert annotated[1]["content"] == "Hi there"
def test_already_list_content_gets_cache_control():
from openjarvis.engine.cloud import _annotate_anthropic_cache
messages = [
{"role": "system", "content": [{"type": "text", "text": "You are Jarvis."}]},
]
annotated = _annotate_anthropic_cache(messages)
assert annotated[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
View File
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from pathlib import Path
import pytest
from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig
@pytest.fixture
def memory_dir(tmp_path: Path) -> Path:
soul = tmp_path / "SOUL.md"
soul.write_text("You are a helpful research assistant.")
memory = tmp_path / "MEMORY.md"
memory.write_text("- User prefers concise answers\n- User is a data scientist")
user = tmp_path / "USER.md"
user.write_text("- Name: Alice\n- Role: ML Engineer")
return tmp_path
def test_build_frozen_prefix(memory_dir: Path):
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template="You are Jarvis.",
memory_files_config=MemoryFilesConfig(
soul_path=str(memory_dir / "SOUL.md"),
memory_path=str(memory_dir / "MEMORY.md"),
user_path=str(memory_dir / "USER.md"),
),
system_prompt_config=SystemPromptConfig(),
)
prompt = builder.build()
assert "Jarvis" in prompt
assert "helpful research assistant" in prompt
assert "concise answers" in prompt
assert "Alice" in prompt
def test_frozen_prefix_stability(memory_dir: Path):
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template="You are Jarvis.",
memory_files_config=MemoryFilesConfig(
soul_path=str(memory_dir / "SOUL.md"),
memory_path=str(memory_dir / "MEMORY.md"),
user_path=str(memory_dir / "USER.md"),
),
system_prompt_config=SystemPromptConfig(),
)
first = builder.build()
(memory_dir / "MEMORY.md").write_text("- CHANGED CONTENT")
second = builder.build()
assert first == second
def test_char_limit_truncation(memory_dir: Path):
from openjarvis.prompt.builder import SystemPromptBuilder
(memory_dir / "SOUL.md").write_text("x" * 10000)
builder = SystemPromptBuilder(
agent_template="You are Jarvis.",
memory_files_config=MemoryFilesConfig(
soul_path=str(memory_dir / "SOUL.md"),
memory_path=str(memory_dir / "MEMORY.md"),
user_path=str(memory_dir / "USER.md"),
),
system_prompt_config=SystemPromptConfig(soul_max_chars=100),
)
prompt = builder.build()
assert prompt.count("x") <= 100
assert "truncated" in prompt.lower()
def test_skill_index_in_prompt(memory_dir: Path):
from openjarvis.prompt.builder import SystemPromptBuilder
skills = [("api_health_check", "Check API health across all endpoints")]
builder = SystemPromptBuilder(
agent_template="You are Jarvis.",
memory_files_config=MemoryFilesConfig(
soul_path=str(memory_dir / "SOUL.md"),
memory_path=str(memory_dir / "MEMORY.md"),
user_path=str(memory_dir / "USER.md"),
),
system_prompt_config=SystemPromptConfig(),
skill_index=skills,
)
prompt = builder.build()
assert "api_health_check" in prompt
assert "Check API health" in prompt
def test_dynamic_section_appended(memory_dir: Path):
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template="You are Jarvis.",
memory_files_config=MemoryFilesConfig(
soul_path=str(memory_dir / "SOUL.md"),
memory_path=str(memory_dir / "MEMORY.md"),
user_path=str(memory_dir / "USER.md"),
),
system_prompt_config=SystemPromptConfig(),
session_context="Platform: CLI | Session: abc123",
)
prompt = builder.build()
assert "Platform: CLI" in prompt
def test_missing_files_handled(tmp_path: Path):
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template="You are Jarvis.",
memory_files_config=MemoryFilesConfig(
soul_path=str(tmp_path / "missing_soul.md"),
memory_path=str(tmp_path / "missing_memory.md"),
user_path=str(tmp_path / "missing_user.md"),
),
system_prompt_config=SystemPromptConfig(),
)
prompt = builder.build()
assert "Jarvis" in prompt
@@ -0,0 +1,47 @@
from __future__ import annotations
def test_strips_openai_key():
from openjarvis.security.credential_stripper import CredentialStripper
stripper = CredentialStripper()
text = (
"Error: auth failed with key "
"sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx234"
)
result = stripper.strip(text)
assert "sk-proj-" not in result
assert "[REDACTED:" in result
def test_strips_aws_key():
from openjarvis.security.credential_stripper import CredentialStripper
stripper = CredentialStripper()
text = "Using credentials AKIAIOSFODNN7EXAMPLE for access"
result = stripper.strip(text)
assert "AKIA" not in result
assert "[REDACTED:" in result
def test_strips_github_token():
from openjarvis.security.credential_stripper import CredentialStripper
stripper = CredentialStripper()
text = "Token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"
result = stripper.strip(text)
assert "ghp_" not in result
def test_preserves_normal_text():
from openjarvis.security.credential_stripper import CredentialStripper
stripper = CredentialStripper()
text = "The function returned 42 results."
result = stripper.strip(text)
assert result == text
def test_tool_output_wrapping():
from openjarvis.security.credential_stripper import wrap_tool_output
content = "Search results: found 3 items"
wrapped = wrap_tool_output("web_search", content, success=True)
assert '<tool_result name="web_search" status="success">' in wrapped
assert "Search results" in wrapped
assert "</tool_result>" in wrapped
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
def test_severity_policy_block():
from openjarvis.security.severity_policy import SeverityPolicy
from openjarvis.security.types import ThreatLevel
policy = SeverityPolicy()
assert policy.action_for(ThreatLevel.CRITICAL) == "block"
def test_severity_policy_warn():
from openjarvis.security.severity_policy import SeverityPolicy
from openjarvis.security.types import ThreatLevel
policy = SeverityPolicy()
assert policy.action_for(ThreatLevel.HIGH) == "warn"
def test_severity_policy_sanitize():
from openjarvis.security.severity_policy import SeverityPolicy
from openjarvis.security.types import ThreatLevel
policy = SeverityPolicy()
assert policy.action_for(ThreatLevel.MEDIUM) == "sanitize"
def test_severity_policy_log():
from openjarvis.security.severity_policy import SeverityPolicy
from openjarvis.security.types import ThreatLevel
policy = SeverityPolicy()
assert policy.action_for(ThreatLevel.LOW) == "log"
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import pytest
from openjarvis.core.registry import CompressionRegistry
from openjarvis.core.types import Message, Role
@pytest.fixture(autouse=True)
def _register_compressors():
"""Re-register compression strategies after registry clear."""
from openjarvis.sessions.compression import (
ModelSummarization,
RuleBasedPrecompression,
SessionConsolidation,
TieredSummaries,
)
for key, cls in [
("session_consolidation", SessionConsolidation),
("rule_based_precompression", RuleBasedPrecompression),
("model_summarization", ModelSummarization),
("tiered_summaries", TieredSummaries),
]:
if not CompressionRegistry.contains(key):
CompressionRegistry.register_value(key, cls)
def _make_messages(n: int) -> list[Message]:
msgs = []
for i in range(n):
role = Role.USER if i % 2 == 0 else Role.ASSISTANT
msgs.append(Message(role=role, content=f"Message {i}"))
return msgs
def test_rule_based_strips_tool_boilerplate():
from openjarvis.sessions.compression import RuleBasedPrecompression
compressor = RuleBasedPrecompression()
long_snippet = "x" * 5000
tool_output = (
'{"results": [{"title": "Result 1",'
f' "snippet": "A very long snippet {long_snippet}"'
"}]}"
)
msgs = [
Message(role=Role.ASSISTANT, content="Let me search."),
Message(role=Role.TOOL, content=tool_output),
Message(
role=Role.ASSISTANT,
content="Based on the search, here is the answer.",
),
]
result = compressor.compress(msgs, threshold=0.5)
total_len = sum(len(m.content) for m in result)
original_len = sum(len(m.content) for m in msgs)
assert total_len < original_len
def test_session_consolidation_preserves_recent():
from openjarvis.sessions.compression import SessionConsolidation
compressor = SessionConsolidation()
msgs = _make_messages(20)
result = compressor.compress(msgs, threshold=0.5)
assert len(result) < 20
assert result[-1].content == msgs[-1].content
def test_compression_registry():
from openjarvis.core.registry import CompressionRegistry
from openjarvis.sessions.compression import RuleBasedPrecompression
assert CompressionRegistry.contains("rule_based_precompression")
cls = CompressionRegistry.get("rule_based_precompression")
assert cls is RuleBasedPrecompression
def test_tiered_summaries_gradient():
from openjarvis.sessions.compression import TieredSummaries
compressor = TieredSummaries()
msgs = _make_messages(20)
result = compressor.compress(msgs, threshold=0.5)
assert len(result) < 20
# Recent messages should be preserved
assert result[-1].content == msgs[-1].content
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from pathlib import Path
import pytest
@pytest.fixture
def memory_file(tmp_path: Path) -> Path:
p = tmp_path / "MEMORY.md"
p.write_text("## Knowledge\n\n- User prefers dark mode\n")
return p
def test_memory_read(memory_file: Path):
from openjarvis.tools.memory_manage import MemoryManageTool
tool = MemoryManageTool(memory_path=memory_file)
result = tool.execute(action="read")
assert "dark mode" in result.content
def test_memory_add(memory_file: Path):
from openjarvis.tools.memory_manage import MemoryManageTool
tool = MemoryManageTool(memory_path=memory_file)
result = tool.execute(action="add", entry="User works at Acme Corp")
assert result.success
assert "Acme Corp" in memory_file.read_text()
def test_memory_remove(memory_file: Path):
from openjarvis.tools.memory_manage import MemoryManageTool
tool = MemoryManageTool(memory_path=memory_file)
tool.execute(action="add", entry="temporary fact")
result = tool.execute(action="remove", entry="temporary fact")
assert result.success
assert "temporary fact" not in memory_file.read_text()
def test_memory_create_if_missing(tmp_path: Path):
from openjarvis.tools.memory_manage import MemoryManageTool
path = tmp_path / "MEMORY.md"
tool = MemoryManageTool(memory_path=path)
result = tool.execute(action="add", entry="new fact")
assert result.success
assert path.exists()
assert "new fact" in path.read_text()
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from pathlib import Path
import pytest
@pytest.fixture
def skills_dir(tmp_path: Path) -> Path:
d = tmp_path / "skills"
d.mkdir()
return d
def test_skill_create(skills_dir: Path):
from openjarvis.tools.skill_manage import SkillManageTool
tool = SkillManageTool(skills_dir=skills_dir)
result = tool.execute(
action="create",
name="api_health",
description="Check API health",
steps=[
{
"tool_name": "http_request",
"arguments_template": '{"url": "{endpoint}/health"}',
}
],
)
assert result.success
assert (skills_dir / "api_health.toml").exists()
def test_skill_list(skills_dir: Path):
from openjarvis.tools.skill_manage import SkillManageTool
tool = SkillManageTool(skills_dir=skills_dir)
tool.execute(
action="create",
name="skill_a",
description="Skill A",
steps=[{"tool_name": "calculator"}],
)
tool.execute(
action="create",
name="skill_b",
description="Skill B",
steps=[{"tool_name": "calculator"}],
)
result = tool.execute(action="list")
assert "skill_a" in result.content
assert "skill_b" in result.content
def test_skill_delete(skills_dir: Path):
from openjarvis.tools.skill_manage import SkillManageTool
tool = SkillManageTool(skills_dir=skills_dir)
tool.execute(
action="create",
name="temp_skill",
description="Temp",
steps=[{"tool_name": "calculator"}],
)
assert (skills_dir / "temp_skill.toml").exists()
result = tool.execute(action="delete", name="temp_skill")
assert result.success
assert not (skills_dir / "temp_skill.toml").exists()
def test_skill_load(skills_dir: Path):
from openjarvis.tools.skill_manage import SkillManageTool
tool = SkillManageTool(skills_dir=skills_dir)
tool.execute(
action="create",
name="my_skill",
description="My skill desc",
steps=[
{
"tool_name": "web_search",
"arguments_template": '{"q": "test"}',
}
],
)
result = tool.execute(action="load", name="my_skill")
assert "web_search" in result.content
assert "My skill desc" in result.content
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
import pytest
@pytest.fixture
def user_file(tmp_path: Path) -> Path:
p = tmp_path / "USER.md"
p.write_text("## User Profile\n\n- Name: Alice\n")
return p
def test_user_read(user_file: Path):
from openjarvis.tools.user_profile_manage import UserProfileManageTool
tool = UserProfileManageTool(user_path=user_file)
result = tool.execute(action="read")
assert "Alice" in result.content
def test_user_add(user_file: Path):
from openjarvis.tools.user_profile_manage import UserProfileManageTool
tool = UserProfileManageTool(user_path=user_file)
result = tool.execute(action="add", entry="Role: Engineer")
assert result.success
assert "Engineer" in user_file.read_text()
def test_user_update(user_file: Path):
from openjarvis.tools.user_profile_manage import UserProfileManageTool
tool = UserProfileManageTool(user_path=user_file)
result = tool.execute(action="update", entry="Name: Alice", new_entry="Name: Bob")
assert result.success
assert "Bob" in user_file.read_text()
assert "Alice" not in user_file.read_text()