mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user