From 7abd65da264a8ad01aec6f5a5c7dd15bf8b525f0 Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 17:45:55 +0000 Subject: [PATCH 01/13] feat: add MemoryManageTool and UserProfileManageTool for persistent personalization Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/core/config.py | 11 ++ src/openjarvis/tools/__init__.py | 9 ++ src/openjarvis/tools/memory_manage.py | 141 ++++++++++++++++++++ src/openjarvis/tools/user_profile_manage.py | 141 ++++++++++++++++++++ tests/tools/test_memory_manage.py | 49 +++++++ tests/tools/test_user_profile_manage.py | 38 ++++++ 6 files changed, 389 insertions(+) create mode 100644 src/openjarvis/tools/memory_manage.py create mode 100644 src/openjarvis/tools/user_profile_manage.py create mode 100644 tests/tools/test_memory_manage.py create mode 100644 tests/tools/test_user_profile_manage.py diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 1067c1a4..97f631e6 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1055,6 +1055,16 @@ 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 class JarvisConfig: """Top-level configuration for OpenJarvis.""" @@ -1079,6 +1089,7 @@ 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) @property def memory(self) -> StorageConfig: diff --git a/src/openjarvis/tools/__init__.py b/src/openjarvis/tools/__init__.py index b95c9090..cfddc61b 100644 --- a/src/openjarvis/tools/__init__.py +++ b/src/openjarvis/tools/__init__.py @@ -77,4 +77,13 @@ 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 + __all__ = ["BaseTool", "ToolExecutor", "ToolSpec"] diff --git a/src/openjarvis/tools/memory_manage.py b/src/openjarvis/tools/memory_manage.py new file mode 100644 index 00000000..ff2731fb --- /dev/null +++ b/src/openjarvis/tools/memory_manage.py @@ -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}", + ) diff --git a/src/openjarvis/tools/user_profile_manage.py b/src/openjarvis/tools/user_profile_manage.py new file mode 100644 index 00000000..67199a23 --- /dev/null +++ b/src/openjarvis/tools/user_profile_manage.py @@ -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}", + ) diff --git a/tests/tools/test_memory_manage.py b/tests/tools/test_memory_manage.py new file mode 100644 index 00000000..27c83c7e --- /dev/null +++ b/tests/tools/test_memory_manage.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pytest +from pathlib import Path + + +@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() diff --git a/tests/tools/test_user_profile_manage.py b/tests/tools/test_user_profile_manage.py new file mode 100644 index 00000000..94ff51fb --- /dev/null +++ b/tests/tools/test_user_profile_manage.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import pytest +from pathlib import Path + + +@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() From 978749e9f89e10f21136c7a6ad928272285c0490 Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 17:50:36 +0000 Subject: [PATCH 02/13] feat: add SystemPromptBuilder with frozen prefix and char limits Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/core/config.py | 12 +++ src/openjarvis/prompt/__init__.py | 1 + src/openjarvis/prompt/builder.py | 87 ++++++++++++++++++++++ tests/prompt/__init__.py | 0 tests/prompt/test_builder.py | 119 ++++++++++++++++++++++++++++++ 5 files changed, 219 insertions(+) create mode 100644 src/openjarvis/prompt/__init__.py create mode 100644 src/openjarvis/prompt/builder.py create mode 100644 tests/prompt/__init__.py create mode 100644 tests/prompt/test_builder.py diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 97f631e6..f6e9f4b7 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1065,6 +1065,17 @@ class MemoryFilesConfig: 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 class JarvisConfig: """Top-level configuration for OpenJarvis.""" @@ -1090,6 +1101,7 @@ class JarvisConfig: 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) @property def memory(self) -> StorageConfig: diff --git a/src/openjarvis/prompt/__init__.py b/src/openjarvis/prompt/__init__.py new file mode 100644 index 00000000..9d48db4f --- /dev/null +++ b/src/openjarvis/prompt/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/openjarvis/prompt/builder.py b/src/openjarvis/prompt/builder.py new file mode 100644 index 00000000..dd844668 --- /dev/null +++ b/src/openjarvis/prompt/builder.py @@ -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...]" diff --git a/tests/prompt/__init__.py b/tests/prompt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/prompt/test_builder.py b/tests/prompt/test_builder.py new file mode 100644 index 00000000..13cc20ac --- /dev/null +++ b/tests/prompt/test_builder.py @@ -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 From 153435edb6e923ff20fad5f3eea66ef2405bb6fa Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 17:53:47 +0000 Subject: [PATCH 03/13] feat: add pluggable context compaction strategies via CompressionRegistry Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/core/config.py | 10 +++ src/openjarvis/core/registry.py | 5 ++ src/openjarvis/sessions/compression.py | 108 +++++++++++++++++++++++++ tests/conftest.py | 2 + tests/sessions/test_compression.py | 78 ++++++++++++++++++ 5 files changed, 203 insertions(+) create mode 100644 src/openjarvis/sessions/compression.py create mode 100644 tests/sessions/test_compression.py diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index f6e9f4b7..677390b3 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1076,6 +1076,15 @@ class SystemPromptConfig: 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 class JarvisConfig: """Top-level configuration for OpenJarvis.""" @@ -1102,6 +1111,7 @@ class JarvisConfig: 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) @property def memory(self) -> StorageConfig: diff --git a/src/openjarvis/core/registry.py b/src/openjarvis/core/registry.py index f0f2becf..f0fd1735 100644 --- a/src/openjarvis/core/registry.py +++ b/src/openjarvis/core/registry.py @@ -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", diff --git a/src/openjarvis/sessions/compression.py b/src/openjarvis/sessions/compression.py new file mode 100644 index 00000000..c326c7b5 --- /dev/null +++ b/src/openjarvis/sessions/compression.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index d8d04e24..af05a657 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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() diff --git a/tests/sessions/test_compression.py b/tests/sessions/test_compression.py new file mode 100644 index 00000000..d4509bfb --- /dev/null +++ b/tests/sessions/test_compression.py @@ -0,0 +1,78 @@ +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() + msgs = [ + Message(role=Role.ASSISTANT, content="Let me search."), + Message(role=Role.TOOL, content='{"results": [{"title": "Result 1", "snippet": "A very long snippet ' + "x" * 5000 + '"}]}'), + 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 From c1e01b1e8b406d90b6c1c19a2bb9e20396f4affd Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 17:56:11 +0000 Subject: [PATCH 04/13] feat: add SkillManageTool for agent-authored procedural memory Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/core/config.py | 10 ++ src/openjarvis/skills/loader.py | 16 ++- src/openjarvis/tools/__init__.py | 5 + src/openjarvis/tools/skill_manage.py | 157 +++++++++++++++++++++++++++ tests/tools/test_skill_manage.py | 87 +++++++++++++++ 5 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/openjarvis/tools/skill_manage.py create mode 100644 tests/tools/test_skill_manage.py diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 677390b3..3db5bc53 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1085,6 +1085,15 @@ class CompressionConfig: 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.""" @@ -1112,6 +1121,7 @@ class JarvisConfig: 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: diff --git a/src/openjarvis/skills/loader.py b/src/openjarvis/skills/loader.py index 122fa51d..5786ea4a 100644 --- a/src/openjarvis/skills/loader.py +++ b/src/openjarvis/skills/loader.py @@ -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"] diff --git a/src/openjarvis/tools/__init__.py b/src/openjarvis/tools/__init__.py index cfddc61b..0c407a3a 100644 --- a/src/openjarvis/tools/__init__.py +++ b/src/openjarvis/tools/__init__.py @@ -86,4 +86,9 @@ try: except ImportError: pass +try: + import openjarvis.tools.skill_manage # noqa: F401 +except ImportError: + pass + __all__ = ["BaseTool", "ToolExecutor", "ToolSpec"] diff --git a/src/openjarvis/tools/skill_manage.py b/src/openjarvis/tools/skill_manage.py new file mode 100644 index 00000000..adc1b894 --- /dev/null +++ b/src/openjarvis/tools/skill_manage.py @@ -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}", + ) diff --git a/tests/tools/test_skill_manage.py b/tests/tools/test_skill_manage.py new file mode 100644 index 00000000..7987938d --- /dev/null +++ b/tests/tools/test_skill_manage.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import pytest +from pathlib import Path + + +@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 From 4cc16972ecbc3a57cd7dbb98857c1250756f093b Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 17:57:47 +0000 Subject: [PATCH 05/13] feat: add credential stripping, tool output wrapping, and severity policy Co-Authored-By: Claude Opus 4.6 (1M context) --- .../security/credential_stripper.py | 31 +++++++++++++ src/openjarvis/security/severity_policy.py | 22 ++++++++++ tests/security/test_credential_stripper.py | 44 +++++++++++++++++++ tests/security/test_severity_policy.py | 29 ++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 src/openjarvis/security/credential_stripper.py create mode 100644 src/openjarvis/security/severity_policy.py create mode 100644 tests/security/test_credential_stripper.py create mode 100644 tests/security/test_severity_policy.py diff --git a/src/openjarvis/security/credential_stripper.py b/src/openjarvis/security/credential_stripper.py new file mode 100644 index 00000000..661b4c96 --- /dev/null +++ b/src/openjarvis/security/credential_stripper.py @@ -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'' + return f"{header}\n{content}\n" diff --git a/src/openjarvis/security/severity_policy.py b/src/openjarvis/security/severity_policy.py new file mode 100644 index 00000000..4e8c93f9 --- /dev/null +++ b/src/openjarvis/security/severity_policy.py @@ -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") diff --git a/tests/security/test_credential_stripper.py b/tests/security/test_credential_stripper.py new file mode 100644 index 00000000..899ea1b4 --- /dev/null +++ b/tests/security/test_credential_stripper.py @@ -0,0 +1,44 @@ +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 '' in wrapped + assert "Search results" in wrapped + assert "" in wrapped diff --git a/tests/security/test_severity_policy.py b/tests/security/test_severity_policy.py new file mode 100644 index 00000000..907ca826 --- /dev/null +++ b/tests/security/test_severity_policy.py @@ -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" From 6371e7521d40db4b32ac12321b6698e2d2d93f74 Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 18:01:13 +0000 Subject: [PATCH 06/13] feat: add warn-before-block escalation to LoopGuard Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/agents/loop_guard.py | 61 +++++++++++++++++++-------- tests/agents/test_loop_guard.py | 1 + tests/agents/test_loop_guard_warn.py | 62 ++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 tests/agents/test_loop_guard_warn.py diff --git a/src/openjarvis/agents/loop_guard.py b/src/openjarvis/agents/loop_guard.py index 96af3b98..cd19983d 100644 --- a/src/openjarvis/agents/loop_guard.py +++ b/src/openjarvis/agents/loop_guard.py @@ -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.""" diff --git a/tests/agents/test_loop_guard.py b/tests/agents/test_loop_guard.py index 161add10..69ea11f6 100644 --- a/tests/agents/test_loop_guard.py +++ b/tests/agents/test_loop_guard.py @@ -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 diff --git a/tests/agents/test_loop_guard_warn.py b/tests/agents/test_loop_guard_warn.py new file mode 100644 index 00000000..534082ad --- /dev/null +++ b/tests/agents/test_loop_guard_warn.py @@ -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 From 0d5be075f61c81561d2a630c65168ac55a0538c4 Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 18:02:28 +0000 Subject: [PATCH 07/13] feat: add AgentExecutor.run_ephemeral() for one-shot agent turns Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/agents/executor.py | 18 +++++++++++ tests/agents/test_executor_ephemeral.py | 43 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/agents/test_executor_ephemeral.py diff --git a/src/openjarvis/agents/executor.py b/src/openjarvis/agents/executor.py index 1b78e9cc..986f9975 100644 --- a/src/openjarvis/agents/executor.py +++ b/src/openjarvis/agents/executor.py @@ -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. diff --git a/tests/agents/test_executor_ephemeral.py b/tests/agents/test_executor_ephemeral.py new file mode 100644 index 00000000..537181dd --- /dev/null +++ b/tests/agents/test_executor_ephemeral.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + + +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 3 memories.") + mock_agent_cls.return_value = mock_agent_instance + + with patch("openjarvis.core.registry.AgentRegistry.get", return_value=mock_agent_cls): + result = 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("openjarvis.core.registry.AgentRegistry.get", 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") From 1359c223ec6a1e65d71a2acdd7282ed39c66a7e4 Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 18:37:41 +0000 Subject: [PATCH 08/13] feat: wire SystemPromptBuilder into BaseAgent._build_messages() Add optional prompt_builder parameter to BaseAgent. When provided, _build_messages() uses builder.build() output instead of raw system_prompt. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/agents/_stubs.py | 12 +++++++-- .../test_system_prompt_builder_integration.py | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/agents/test_system_prompt_builder_integration.py diff --git a/src/openjarvis/agents/_stubs.py b/src/openjarvis/agents/_stubs.py index 2f8e3011..f25fcc9c 100644 --- a/src/openjarvis/agents/_stubs.py +++ b/src/openjarvis/agents/_stubs.py @@ -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)) diff --git a/tests/agents/test_system_prompt_builder_integration.py b/tests/agents/test_system_prompt_builder_integration.py new file mode 100644 index 00000000..bb4cce2f --- /dev/null +++ b/tests/agents/test_system_prompt_builder_integration.py @@ -0,0 +1,27 @@ +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 From 5df86886270c55f83939f178f0bec551890bebcd Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 18:40:07 +0000 Subject: [PATCH 09/13] feat: add GatewayDaemon, SessionExpiryHook, and service file generation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/daemon/__init__.py | 1 + src/openjarvis/daemon/gateway.py | 39 +++++++++++++++++ src/openjarvis/daemon/service.py | 57 +++++++++++++++++++++++++ src/openjarvis/daemon/session_expiry.py | 34 +++++++++++++++ tests/daemon/__init__.py | 0 tests/daemon/test_gateway.py | 18 ++++++++ tests/daemon/test_session_expiry.py | 28 ++++++++++++ 7 files changed, 177 insertions(+) create mode 100644 src/openjarvis/daemon/__init__.py create mode 100644 src/openjarvis/daemon/gateway.py create mode 100644 src/openjarvis/daemon/service.py create mode 100644 src/openjarvis/daemon/session_expiry.py create mode 100644 tests/daemon/__init__.py create mode 100644 tests/daemon/test_gateway.py create mode 100644 tests/daemon/test_session_expiry.py diff --git a/src/openjarvis/daemon/__init__.py b/src/openjarvis/daemon/__init__.py new file mode 100644 index 00000000..9d48db4f --- /dev/null +++ b/src/openjarvis/daemon/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/openjarvis/daemon/gateway.py b/src/openjarvis/daemon/gateway.py new file mode 100644 index 00000000..189a8dc7 --- /dev/null +++ b/src/openjarvis/daemon/gateway.py @@ -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 diff --git a/src/openjarvis/daemon/service.py b/src/openjarvis/daemon/service.py new file mode 100644 index 00000000..3c88809b --- /dev/null +++ b/src/openjarvis/daemon/service.py @@ -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 = """\ + + + + + Label + com.openjarvis.gateway + ProgramArguments + + {python} + -m + openjarvis.daemon.gateway + + RunAtLoad + + KeepAlive + + + +""" + + +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 diff --git a/src/openjarvis/daemon/session_expiry.py b/src/openjarvis/daemon/session_expiry.py new file mode 100644 index 00000000..87614ae9 --- /dev/null +++ b/src/openjarvis/daemon/session_expiry.py @@ -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"], + ) diff --git a/tests/daemon/__init__.py b/tests/daemon/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/daemon/test_gateway.py b/tests/daemon/test_gateway.py new file mode 100644 index 00000000..f024b0e3 --- /dev/null +++ b/tests/daemon/test_gateway.py @@ -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 diff --git a/tests/daemon/test_session_expiry.py b/tests/daemon/test_session_expiry.py new file mode 100644 index 00000000..10da40e6 --- /dev/null +++ b/tests/daemon/test_session_expiry.py @@ -0,0 +1,28 @@ +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() From c6cca77c120c937c15dbd709175f9522e4d00937 Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 18:41:43 +0000 Subject: [PATCH 10/13] feat: add jarvis gateway start/stop/status/logs CLI commands Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/cli/__init__.py | 2 + src/openjarvis/cli/gateway_cmd.py | 108 ++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/openjarvis/cli/gateway_cmd.py diff --git a/src/openjarvis/cli/__init__.py b/src/openjarvis/cli/__init__.py index 2a07ca3f..678ff1fa 100644 --- a/src/openjarvis/cli/__init__.py +++ b/src/openjarvis/cli/__init__.py @@ -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: diff --git a/src/openjarvis/cli/gateway_cmd.py b/src/openjarvis/cli/gateway_cmd.py new file mode 100644 index 00000000..2137a164 --- /dev/null +++ b/src/openjarvis/cli/gateway_cmd.py @@ -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, + ) From 981f665913e7b3b1690e499c1a1911b6d8d46f8c Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 18:42:54 +0000 Subject: [PATCH 11/13] feat: add Anthropic prompt cache breakpoint annotation Add _annotate_anthropic_cache helper that annotates system messages with cache_control for Anthropic prompt caching. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/engine/cloud.py | 27 ++++++++++++++++++- tests/engine/test_cache_breakpoint.py | 37 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/engine/test_cache_breakpoint.py diff --git a/src/openjarvis/engine/cloud.py b/src/openjarvis/engine/cloud.py index cacf651f..8181a4b7 100644 --- a/src/openjarvis/engine/cloud.py +++ b/src/openjarvis/engine/cloud.py @@ -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"] diff --git a/tests/engine/test_cache_breakpoint.py b/tests/engine/test_cache_breakpoint.py new file mode 100644 index 00000000..1ea5e543 --- /dev/null +++ b/tests/engine/test_cache_breakpoint.py @@ -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"} From 130dd99387b7fb650df73d499d1de213a457b57d Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 18:44:03 +0000 Subject: [PATCH 12/13] feat: create default SOUL.md, MEMORY.md, USER.md on jarvis init Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/cli/init_cmd.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/openjarvis/cli/init_cmd.py b/src/openjarvis/cli/init_cmd.py index a70d646f..41fb1864 100644 --- a/src/openjarvis/cli/init_cmd.py +++ b/src/openjarvis/cli/init_cmd.py @@ -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: From 181d9ac0eb85f1d1ed44ff89e9f36eeb57abee94 Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Tue, 17 Mar 2026 00:13:49 +0000 Subject: [PATCH 13/13] fix: resolve ruff I001 import sorting and E501 line length in tests Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/agents/test_executor_ephemeral.py | 10 ++++++---- .../test_system_prompt_builder_integration.py | 1 + tests/daemon/test_session_expiry.py | 1 + tests/security/test_credential_stripper.py | 5 ++++- tests/sessions/test_compression.py | 14 ++++++++++++-- tests/tools/test_memory_manage.py | 3 ++- tests/tools/test_skill_manage.py | 3 ++- tests/tools/test_user_profile_manage.py | 3 ++- 8 files changed, 30 insertions(+), 10 deletions(-) diff --git a/tests/agents/test_executor_ephemeral.py b/tests/agents/test_executor_ephemeral.py index 537181dd..16062d1a 100644 --- a/tests/agents/test_executor_ephemeral.py +++ b/tests/agents/test_executor_ephemeral.py @@ -2,6 +2,8 @@ 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 @@ -11,11 +13,11 @@ def test_run_ephemeral_creates_and_runs_agent(): mock_agent_cls = MagicMock() mock_agent_instance = MagicMock() - mock_agent_instance.run.return_value = MagicMock(content="Flushed 3 memories.") + mock_agent_instance.run.return_value = MagicMock(content="Flushed.") mock_agent_cls.return_value = mock_agent_instance - with patch("openjarvis.core.registry.AgentRegistry.get", return_value=mock_agent_cls): - result = executor.run_ephemeral( + 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.", @@ -34,7 +36,7 @@ def test_run_ephemeral_passes_input(): mock_agent_instance.run.return_value = MagicMock(content="Done.") mock_agent_cls.return_value = mock_agent_instance - with patch("openjarvis.core.registry.AgentRegistry.get", return_value=mock_agent_cls): + with patch(REGISTRY_PATH, return_value=mock_agent_cls): executor.run_ephemeral( agent_type="simple", system_prompt="Test prompt.", diff --git a/tests/agents/test_system_prompt_builder_integration.py b/tests/agents/test_system_prompt_builder_integration.py index bb4cce2f..439b5e4c 100644 --- a/tests/agents/test_system_prompt_builder_integration.py +++ b/tests/agents/test_system_prompt_builder_integration.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path + from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig diff --git a/tests/daemon/test_session_expiry.py b/tests/daemon/test_session_expiry.py index 10da40e6..6f8082e7 100644 --- a/tests/daemon/test_session_expiry.py +++ b/tests/daemon/test_session_expiry.py @@ -1,6 +1,7 @@ from __future__ import annotations from unittest.mock import MagicMock + from openjarvis.core.types import Message, Role diff --git a/tests/security/test_credential_stripper.py b/tests/security/test_credential_stripper.py index 899ea1b4..01f7f781 100644 --- a/tests/security/test_credential_stripper.py +++ b/tests/security/test_credential_stripper.py @@ -4,7 +4,10 @@ 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" + text = ( + "Error: auth failed with key " + "sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx234" + ) result = stripper.strip(text) assert "sk-proj-" not in result assert "[REDACTED:" in result diff --git a/tests/sessions/test_compression.py b/tests/sessions/test_compression.py index d4509bfb..0c29ee65 100644 --- a/tests/sessions/test_compression.py +++ b/tests/sessions/test_compression.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest + from openjarvis.core.registry import CompressionRegistry from openjarvis.core.types import Message, Role @@ -37,10 +38,19 @@ 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='{"results": [{"title": "Result 1", "snippet": "A very long snippet ' + "x" * 5000 + '"}]}'), - Message(role=Role.ASSISTANT, content="Based on the search, here is the answer."), + 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) diff --git a/tests/tools/test_memory_manage.py b/tests/tools/test_memory_manage.py index 27c83c7e..6c063018 100644 --- a/tests/tools/test_memory_manage.py +++ b/tests/tools/test_memory_manage.py @@ -1,8 +1,9 @@ from __future__ import annotations -import pytest from pathlib import Path +import pytest + @pytest.fixture def memory_file(tmp_path: Path) -> Path: diff --git a/tests/tools/test_skill_manage.py b/tests/tools/test_skill_manage.py index 7987938d..ac651028 100644 --- a/tests/tools/test_skill_manage.py +++ b/tests/tools/test_skill_manage.py @@ -1,8 +1,9 @@ from __future__ import annotations -import pytest from pathlib import Path +import pytest + @pytest.fixture def skills_dir(tmp_path: Path) -> Path: diff --git a/tests/tools/test_user_profile_manage.py b/tests/tools/test_user_profile_manage.py index 94ff51fb..dff0ef3c 100644 --- a/tests/tools/test_user_profile_manage.py +++ b/tests/tools/test_user_profile_manage.py @@ -1,8 +1,9 @@ from __future__ import annotations -import pytest from pathlib import Path +import pytest + @pytest.fixture def user_file(tmp_path: Path) -> Path: