fix(ohmo): isolate personal memory from project memory
This commit is contained in:
@@ -29,6 +29,7 @@ The format is based on Keep a Changelog, and this project currently tracks chang
|
||||
|
||||
- Compaction now detects llama.cpp/OpenAI-compatible context overflow errors, accounts for image blocks in auto-compact token estimates, and strips image payloads from summarizer-only compaction requests.
|
||||
- Large tool results are now bounded in conversation history: oversized outputs are saved under `tool_artifacts`, old MCP results become microcompactable, and context collapse trims stale tool-result payloads.
|
||||
- ohmo now keeps personal memory isolated from OpenHarness project memory: `/memory` in ohmo sessions targets the ohmo workspace memory store, and ohmo runtime prompt refreshes no longer inject project memory unless explicitly requested.
|
||||
- Fixed `glob` and `grep` tools hanging indefinitely when the `rg` subprocess produced enough stderr output to fill the OS pipe buffer. `stderr` is now redirected to `DEVNULL` so it is discarded rather than blocking the child process.
|
||||
- Fixed `bash_tool` hanging after a timed-out command when the subprocess stdout stream stayed open. `_read_remaining_output` now applies a 2-second `asyncio.wait_for` timeout so the tool always returns promptly.
|
||||
- Fixed `session_runner` background task deadlock caused by an unread `stderr=PIPE` stream. The subprocess now uses `stderr=STDOUT` so all output merges into the single readable stdout pipe.
|
||||
|
||||
@@ -33,6 +33,7 @@ from openharness.prompts import build_runtime_system_prompt
|
||||
from openharness.ui.runtime import RuntimeBundle, _last_user_text, build_runtime, close_runtime, start_runtime
|
||||
|
||||
from ohmo.gateway.config import load_gateway_config
|
||||
from ohmo.memory import create_memory_command_backend
|
||||
from ohmo.prompts import build_ohmo_system_prompt
|
||||
from ohmo.session_storage import OhmoSessionBackend
|
||||
from ohmo.workspace import get_plugins_dir, get_skills_dir, initialize_workspace
|
||||
@@ -138,6 +139,8 @@ class OhmoSessionRuntimePool:
|
||||
restore_tool_metadata=snapshot.get("tool_metadata") if snapshot else None,
|
||||
extra_skill_dirs=(str(get_skills_dir(self._workspace)),),
|
||||
extra_plugin_roots=(str(get_plugins_dir(self._workspace)),),
|
||||
memory_backend=create_memory_command_backend(self._workspace),
|
||||
include_project_memory=False,
|
||||
)
|
||||
if snapshot and snapshot.get("session_id"):
|
||||
bundle.session_id = str(snapshot["session_id"])
|
||||
@@ -206,6 +209,8 @@ class OhmoSessionRuntimePool:
|
||||
session_id=bundle.session_id,
|
||||
extra_skill_dirs=bundle.extra_skill_dirs,
|
||||
extra_plugin_roots=bundle.extra_plugin_roots,
|
||||
memory_backend=create_memory_command_backend(self._workspace),
|
||||
include_project_memory=False,
|
||||
),
|
||||
)
|
||||
async for update in self._stream_command_result(
|
||||
@@ -508,6 +513,8 @@ class OhmoSessionRuntimePool:
|
||||
restore_tool_metadata=getattr(bundle.engine, "tool_metadata", {}) or {},
|
||||
extra_skill_dirs=(str(get_skills_dir(self._workspace)),),
|
||||
extra_plugin_roots=(str(get_plugins_dir(self._workspace)),),
|
||||
memory_backend=create_memory_command_backend(self._workspace),
|
||||
include_project_memory=False,
|
||||
)
|
||||
refreshed.session_id = prior_session_id
|
||||
await start_runtime(refreshed)
|
||||
@@ -533,6 +540,7 @@ class OhmoSessionRuntimePool:
|
||||
latest_user_prompt=latest_user_prompt,
|
||||
extra_skill_dirs=getattr(bundle, "extra_skill_dirs", ()),
|
||||
extra_plugin_roots=getattr(bundle, "extra_plugin_roots", ()),
|
||||
include_project_memory=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from re import sub
|
||||
|
||||
from openharness.commands import MemoryCommandBackend
|
||||
|
||||
from ohmo.workspace import get_memory_dir, get_memory_index_path
|
||||
|
||||
|
||||
@@ -67,3 +69,16 @@ def load_memory_prompt(workspace: str | Path | None = None, *, max_files: int =
|
||||
lines.extend(["", f"## {path.name}", "```md", content[:4000], "```"])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_memory_command_backend(workspace: str | Path | None = None) -> MemoryCommandBackend:
|
||||
"""Return a ``/memory`` backend bound to ohmo's personal memory store."""
|
||||
|
||||
return MemoryCommandBackend(
|
||||
label="ohmo personal memory",
|
||||
get_memory_dir=lambda: get_memory_dir(workspace),
|
||||
get_entrypoint=lambda: get_memory_index_path(workspace),
|
||||
list_files=lambda: list_memory_files(workspace),
|
||||
add_entry=lambda title, content: add_memory_entry(workspace, title, content),
|
||||
remove_entry=lambda name: remove_memory_entry(workspace, name),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from openharness.ui.backend_host import run_backend_host
|
||||
from openharness.ui.runtime import build_runtime, close_runtime, handle_line, start_runtime
|
||||
from openharness.ui.react_launcher import _resolve_npm, _resolve_tsx, get_frontend_dir
|
||||
|
||||
from ohmo.memory import create_memory_command_backend
|
||||
from ohmo.prompts import build_ohmo_system_prompt
|
||||
from ohmo.session_storage import OhmoSessionBackend
|
||||
from ohmo.workspace import get_plugins_dir, get_skills_dir, initialize_workspace
|
||||
@@ -54,6 +55,8 @@ async def run_ohmo_backend(
|
||||
session_backend=OhmoSessionBackend(workspace_root),
|
||||
extra_skill_dirs=extra_skill_dirs,
|
||||
extra_plugin_roots=extra_plugin_roots,
|
||||
memory_backend=create_memory_command_backend(workspace_root),
|
||||
include_project_memory=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -160,6 +163,8 @@ async def run_ohmo_print_mode(
|
||||
enforce_max_turns=max_turns is not None,
|
||||
extra_skill_dirs=extra_skill_dirs,
|
||||
extra_plugin_roots=extra_plugin_roots,
|
||||
memory_backend=create_memory_command_backend(workspace_root),
|
||||
include_project_memory=False,
|
||||
)
|
||||
await start_runtime(bundle)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from openharness.commands.registry import (
|
||||
CommandContext,
|
||||
CommandRegistry,
|
||||
CommandResult,
|
||||
MemoryCommandBackend,
|
||||
SlashCommand,
|
||||
create_default_command_registry,
|
||||
)
|
||||
@@ -12,6 +13,7 @@ __all__ = [
|
||||
"CommandContext",
|
||||
"CommandRegistry",
|
||||
"CommandResult",
|
||||
"MemoryCommandBackend",
|
||||
"SlashCommand",
|
||||
"create_default_command_registry",
|
||||
]
|
||||
|
||||
@@ -75,6 +75,18 @@ class CommandResult:
|
||||
submit_model: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryCommandBackend:
|
||||
"""Storage backend used by the generic ``/memory`` slash command."""
|
||||
|
||||
label: str
|
||||
get_memory_dir: Callable[[], Path]
|
||||
get_entrypoint: Callable[[], Path]
|
||||
list_files: Callable[[], list[Path]]
|
||||
add_entry: Callable[[str, str], Path]
|
||||
remove_entry: Callable[[str], bool]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandContext:
|
||||
"""Context available to command handlers."""
|
||||
@@ -90,6 +102,8 @@ class CommandContext:
|
||||
session_id: str | None = None
|
||||
extra_skill_dirs: Iterable[str | Path] | None = None
|
||||
extra_plugin_roots: Iterable[str | Path] | None = None
|
||||
memory_backend: MemoryCommandBackend | None = None
|
||||
include_project_memory: bool = True
|
||||
|
||||
|
||||
CommandHandler = Callable[[str, CommandContext], Awaitable[CommandResult]]
|
||||
@@ -286,7 +300,11 @@ def create_default_command_registry(
|
||||
|
||||
async def _context_handler(_: str, context: CommandContext) -> CommandResult:
|
||||
settings = load_settings()
|
||||
prompt = build_runtime_system_prompt(settings, cwd=context.cwd)
|
||||
prompt = build_runtime_system_prompt(
|
||||
settings,
|
||||
cwd=context.cwd,
|
||||
include_project_memory=context.include_project_memory,
|
||||
)
|
||||
return CommandResult(message=prompt)
|
||||
|
||||
async def _summary_handler(args: str, context: CommandContext) -> CommandResult:
|
||||
@@ -360,7 +378,8 @@ def create_default_command_registry(
|
||||
|
||||
async def _stats_handler(_: str, context: CommandContext) -> CommandResult:
|
||||
settings = load_settings()
|
||||
memory_count = len(list_memory_files(context.cwd))
|
||||
memory_backend = _memory_backend_for_context(context)
|
||||
memory_count = len(memory_backend.list_files())
|
||||
task_count = len(get_task_manager().list_tasks())
|
||||
tool_count = len(context.tool_registry.list_tools()) if context.tool_registry is not None else 0
|
||||
style = settings.output_style
|
||||
@@ -380,25 +399,28 @@ def create_default_command_registry(
|
||||
)
|
||||
|
||||
async def _memory_handler(args: str, context: CommandContext) -> CommandResult:
|
||||
backend = _memory_backend_for_context(context)
|
||||
tokens = args.split(maxsplit=1)
|
||||
if not tokens:
|
||||
memory_dir = get_project_memory_dir(context.cwd)
|
||||
entrypoint = get_memory_entrypoint(context.cwd)
|
||||
return CommandResult(
|
||||
message=f"Memory directory: {memory_dir}\nEntrypoint: {entrypoint}"
|
||||
message=(
|
||||
f"Memory store: {backend.label}\n"
|
||||
f"Memory directory: {backend.get_memory_dir()}\n"
|
||||
f"Entrypoint: {backend.get_entrypoint()}"
|
||||
)
|
||||
)
|
||||
action = tokens[0]
|
||||
rest = tokens[1] if len(tokens) == 2 else ""
|
||||
if action == "list":
|
||||
memory_files = list_memory_files(context.cwd)
|
||||
memory_files = backend.list_files()
|
||||
if not memory_files:
|
||||
return CommandResult(message="No memory files.")
|
||||
return CommandResult(message="\n".join(path.name for path in memory_files))
|
||||
if action == "show" and rest:
|
||||
memory_dir = get_project_memory_dir(context.cwd)
|
||||
memory_dir = backend.get_memory_dir()
|
||||
path, invalid = _resolve_memory_entry_path(memory_dir, rest)
|
||||
if invalid:
|
||||
return CommandResult(message="Memory entry path must stay within the project memory directory.")
|
||||
return CommandResult(message="Memory entry path must stay within the configured memory directory.")
|
||||
if path is None:
|
||||
return CommandResult(message=f"Memory entry not found: {rest}")
|
||||
if not path.exists():
|
||||
@@ -408,10 +430,10 @@ def create_default_command_registry(
|
||||
title, separator, content = rest.partition("::")
|
||||
if not separator or not title.strip() or not content.strip():
|
||||
return CommandResult(message="Usage: /memory add TITLE :: CONTENT")
|
||||
path = add_memory_entry(context.cwd, title.strip(), content.strip())
|
||||
path = backend.add_entry(title.strip(), content.strip())
|
||||
return CommandResult(message=f"Added memory entry {path.name}")
|
||||
if action == "remove" and rest:
|
||||
if remove_memory_entry(context.cwd, rest.strip()):
|
||||
if backend.remove_entry(rest.strip()):
|
||||
return CommandResult(message=f"Removed memory entry {rest.strip()}")
|
||||
return CommandResult(message=f"Memory entry not found: {rest.strip()}")
|
||||
return CommandResult(message="Usage: /memory [list|show NAME|add TITLE :: CONTENT|remove NAME]")
|
||||
@@ -508,7 +530,11 @@ def create_default_command_registry(
|
||||
snapshot_path = context.session_backend.save_snapshot(
|
||||
cwd=context.cwd,
|
||||
model=context.app_state.get().model if context.app_state is not None else load_settings().model,
|
||||
system_prompt=build_runtime_system_prompt(load_settings(), cwd=context.cwd),
|
||||
system_prompt=build_runtime_system_prompt(
|
||||
load_settings(),
|
||||
cwd=context.cwd,
|
||||
include_project_memory=context.include_project_memory,
|
||||
),
|
||||
messages=context.engine.messages,
|
||||
usage=context.engine.total_usage,
|
||||
)
|
||||
@@ -853,7 +879,13 @@ def create_default_command_registry(
|
||||
return CommandResult(message="Usage: /effort [show|low|medium|high]")
|
||||
settings.effort = value
|
||||
save_settings(settings)
|
||||
context.engine.set_system_prompt(build_runtime_system_prompt(settings, cwd=context.cwd))
|
||||
context.engine.set_system_prompt(
|
||||
build_runtime_system_prompt(
|
||||
settings,
|
||||
cwd=context.cwd,
|
||||
include_project_memory=context.include_project_memory,
|
||||
)
|
||||
)
|
||||
if context.app_state is not None:
|
||||
context.app_state.set(effort=value)
|
||||
return CommandResult(message=f"Reasoning effort set to {value}.")
|
||||
@@ -870,7 +902,13 @@ def create_default_command_registry(
|
||||
return CommandResult(message="Usage: /passes [show|COUNT]")
|
||||
settings.passes = passes
|
||||
save_settings(settings)
|
||||
context.engine.set_system_prompt(build_runtime_system_prompt(settings, cwd=context.cwd))
|
||||
context.engine.set_system_prompt(
|
||||
build_runtime_system_prompt(
|
||||
settings,
|
||||
cwd=context.cwd,
|
||||
include_project_memory=context.include_project_memory,
|
||||
)
|
||||
)
|
||||
if context.app_state is not None:
|
||||
context.app_state.set(passes=passes)
|
||||
return CommandResult(message=f"Pass count set to {passes}.")
|
||||
@@ -1983,6 +2021,22 @@ def _resolve_memory_entry_path(memory_dir: Path, candidate: str) -> tuple[Path |
|
||||
return None, False
|
||||
|
||||
|
||||
def _memory_backend_for_context(context: CommandContext) -> MemoryCommandBackend:
|
||||
"""Return the active slash-command memory backend for this command context."""
|
||||
|
||||
if context.memory_backend is not None:
|
||||
return context.memory_backend
|
||||
cwd = context.cwd
|
||||
return MemoryCommandBackend(
|
||||
label="OpenHarness project memory",
|
||||
get_memory_dir=lambda: get_project_memory_dir(cwd),
|
||||
get_entrypoint=lambda: get_memory_entrypoint(cwd),
|
||||
list_files=lambda: list_memory_files(cwd),
|
||||
add_entry=lambda title, content: add_memory_entry(cwd, title, content),
|
||||
remove_entry=lambda name: remove_memory_entry(cwd, name),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_memory_candidate(memory_dir: Path, candidate: str) -> tuple[Path | None, bool]:
|
||||
path = Path(candidate).expanduser()
|
||||
if not path.is_absolute():
|
||||
|
||||
@@ -78,6 +78,7 @@ def build_runtime_system_prompt(
|
||||
latest_user_prompt: str | None = None,
|
||||
extra_skill_dirs: Iterable[str | Path] | None = None,
|
||||
extra_plugin_roots: Iterable[str | Path] | None = None,
|
||||
include_project_memory: bool = True,
|
||||
) -> str:
|
||||
"""Build the runtime system prompt with project instructions and memory."""
|
||||
if is_coordinator_mode():
|
||||
@@ -130,7 +131,7 @@ def build_runtime_system_prompt(
|
||||
if content:
|
||||
sections.append(f"# {title}\n\n```md\n{content[:12000]}\n```")
|
||||
|
||||
if settings.memory.enabled:
|
||||
if include_project_memory and settings.memory.enabled:
|
||||
memory_section = load_memory_prompt(
|
||||
cwd,
|
||||
max_entrypoint_lines=settings.memory.max_entrypoint_lines,
|
||||
|
||||
@@ -15,6 +15,7 @@ from uuid import uuid4
|
||||
|
||||
from openharness.api.client import SupportsStreamingMessages
|
||||
from openharness.auth.manager import AuthManager
|
||||
from openharness.commands import MemoryCommandBackend
|
||||
from openharness.config.settings import CLAUDE_MODEL_ALIAS_OPTIONS, resolve_model_setting
|
||||
from openharness.bridge import get_bridge_manager
|
||||
from openharness.coordinator.coordinator_mode import is_coordinator_mode
|
||||
@@ -63,6 +64,8 @@ class BackendHostConfig:
|
||||
session_backend: SessionBackend | None = None
|
||||
extra_skill_dirs: tuple[str, ...] = ()
|
||||
extra_plugin_roots: tuple[str, ...] = ()
|
||||
memory_backend: MemoryCommandBackend | None = None
|
||||
include_project_memory: bool = True
|
||||
|
||||
|
||||
class ReactBackendHost:
|
||||
@@ -102,6 +105,8 @@ class ReactBackendHost:
|
||||
session_backend=self._config.session_backend,
|
||||
extra_skill_dirs=self._config.extra_skill_dirs,
|
||||
extra_plugin_roots=self._config.extra_plugin_roots,
|
||||
memory_backend=self._config.memory_backend,
|
||||
include_project_memory=self._config.include_project_memory,
|
||||
)
|
||||
await start_runtime(self._bundle)
|
||||
await self._emit(
|
||||
@@ -800,6 +805,8 @@ async def run_backend_host(
|
||||
session_backend: SessionBackend | None = None,
|
||||
extra_skill_dirs: tuple[str | Path, ...] = (),
|
||||
extra_plugin_roots: tuple[str | Path, ...] = (),
|
||||
memory_backend: MemoryCommandBackend | None = None,
|
||||
include_project_memory: bool = True,
|
||||
) -> int:
|
||||
"""Run the structured React backend host."""
|
||||
if cwd:
|
||||
@@ -822,6 +829,8 @@ async def run_backend_host(
|
||||
session_backend=session_backend,
|
||||
extra_skill_dirs=tuple(str(Path(path).expanduser().resolve()) for path in extra_skill_dirs),
|
||||
extra_plugin_roots=tuple(str(Path(path).expanduser().resolve()) for path in extra_plugin_roots),
|
||||
memory_backend=memory_backend,
|
||||
include_project_memory=include_project_memory,
|
||||
)
|
||||
)
|
||||
return await host.run()
|
||||
|
||||
@@ -134,6 +134,7 @@ async def submit_follow_up(
|
||||
latest_user_prompt=prompt_seed,
|
||||
extra_skill_dirs=bundle.extra_skill_dirs,
|
||||
extra_plugin_roots=bundle.extra_plugin_roots,
|
||||
include_project_memory=bundle.include_project_memory,
|
||||
)
|
||||
bundle.engine.set_system_prompt(system_prompt)
|
||||
try:
|
||||
|
||||
@@ -14,7 +14,7 @@ from openharness.api.copilot_client import CopilotClient
|
||||
from openharness.api.openai_client import OpenAICompatibleClient
|
||||
from openharness.api.provider import auth_status, detect_provider
|
||||
from openharness.bridge import get_bridge_manager
|
||||
from openharness.commands import CommandContext, CommandResult, create_default_command_registry
|
||||
from openharness.commands import CommandContext, CommandResult, MemoryCommandBackend, create_default_command_registry
|
||||
from openharness.config import get_config_file_path, load_settings
|
||||
from openharness.engine import QueryEngine
|
||||
from openharness.engine.messages import (
|
||||
@@ -63,6 +63,8 @@ class RuntimeBundle:
|
||||
session_backend: SessionBackend = DEFAULT_SESSION_BACKEND
|
||||
extra_skill_dirs: tuple[str, ...] = ()
|
||||
extra_plugin_roots: tuple[str, ...] = ()
|
||||
memory_backend: MemoryCommandBackend | None = None
|
||||
include_project_memory: bool = True
|
||||
|
||||
def current_settings(self):
|
||||
"""Return the effective settings for this session.
|
||||
@@ -188,6 +190,8 @@ async def build_runtime(
|
||||
permission_mode: str | None = None,
|
||||
extra_skill_dirs: Iterable[str | Path] | None = None,
|
||||
extra_plugin_roots: Iterable[str | Path] | None = None,
|
||||
memory_backend: MemoryCommandBackend | None = None,
|
||||
include_project_memory: bool = True,
|
||||
) -> RuntimeBundle:
|
||||
"""Build the shared runtime for an OpenHarness session."""
|
||||
settings_overrides: dict[str, Any] = {
|
||||
@@ -260,6 +264,7 @@ async def build_runtime(
|
||||
latest_user_prompt=prompt,
|
||||
extra_skill_dirs=normalized_skill_dirs,
|
||||
extra_plugin_roots=normalized_plugin_roots,
|
||||
include_project_memory=include_project_memory,
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -348,6 +353,8 @@ async def build_runtime(
|
||||
session_backend=session_backend or DEFAULT_SESSION_BACKEND,
|
||||
extra_skill_dirs=normalized_skill_dirs,
|
||||
extra_plugin_roots=normalized_plugin_roots,
|
||||
memory_backend=memory_backend,
|
||||
include_project_memory=include_project_memory,
|
||||
)
|
||||
|
||||
|
||||
@@ -515,6 +522,8 @@ async def handle_line(
|
||||
session_id=bundle.session_id,
|
||||
extra_skill_dirs=bundle.extra_skill_dirs,
|
||||
extra_plugin_roots=bundle.extra_plugin_roots,
|
||||
memory_backend=bundle.memory_backend,
|
||||
include_project_memory=bundle.include_project_memory,
|
||||
),
|
||||
)
|
||||
if result.refresh_runtime:
|
||||
@@ -532,6 +541,7 @@ async def handle_line(
|
||||
latest_user_prompt=submit_prompt,
|
||||
extra_skill_dirs=bundle.extra_skill_dirs,
|
||||
extra_plugin_roots=bundle.extra_plugin_roots,
|
||||
include_project_memory=bundle.include_project_memory,
|
||||
)
|
||||
bundle.engine.set_system_prompt(system_prompt)
|
||||
try:
|
||||
@@ -564,6 +574,7 @@ async def handle_line(
|
||||
latest_user_prompt=_last_user_text(bundle.engine.messages),
|
||||
extra_skill_dirs=bundle.extra_skill_dirs,
|
||||
extra_plugin_roots=bundle.extra_plugin_roots,
|
||||
include_project_memory=bundle.include_project_memory,
|
||||
)
|
||||
bundle.engine.set_system_prompt(system_prompt)
|
||||
turns = result.continue_turns if result.continue_turns is not None else bundle.engine.max_turns
|
||||
@@ -596,6 +607,7 @@ async def handle_line(
|
||||
latest_user_prompt=line,
|
||||
extra_skill_dirs=bundle.extra_skill_dirs,
|
||||
extra_plugin_roots=bundle.extra_plugin_roots,
|
||||
include_project_memory=bundle.include_project_memory,
|
||||
)
|
||||
bundle.engine.set_system_prompt(system_prompt)
|
||||
try:
|
||||
|
||||
@@ -158,7 +158,7 @@ async def test_memory_show_rejects_path_traversal(tmp_path: Path, monkeypatch):
|
||||
|
||||
result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path)))
|
||||
|
||||
assert result.message == "Memory entry path must stay within the project memory directory."
|
||||
assert result.message == "Memory entry path must stay within the configured memory directory."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -14,14 +14,19 @@ from openharness.channels.bus.events import InboundMessage
|
||||
from openharness.channels.bus.queue import MessageBus
|
||||
from openharness.commands import CommandResult
|
||||
from openharness.commands.registry import SlashCommand, create_default_command_registry
|
||||
from openharness.config.settings import Settings
|
||||
from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock, ToolUseBlock
|
||||
from openharness.engine.stream_events import AssistantTextDelta, CompactProgressEvent, ToolExecutionStarted
|
||||
from openharness.memory import add_memory_entry as add_project_memory_entry
|
||||
from openharness.memory import list_memory_files as list_project_memory_files
|
||||
|
||||
from ohmo.gateway.bridge import OhmoGatewayBridge, _format_gateway_error
|
||||
from ohmo.gateway.config import save_gateway_config
|
||||
from ohmo.gateway.models import GatewayConfig, GatewayState
|
||||
from ohmo.gateway.runtime import OhmoSessionRuntimePool, _build_inbound_user_message, _format_channel_progress
|
||||
from ohmo.gateway.service import OhmoGatewayService, gateway_status, stop_gateway_process
|
||||
from ohmo.memory import add_memory_entry as add_ohmo_memory_entry
|
||||
from ohmo.memory import list_memory_files as list_ohmo_memory_files
|
||||
from ohmo.gateway.router import session_key_for_message
|
||||
from ohmo.session_storage import save_session_snapshot
|
||||
from ohmo.workspace import get_gateway_restart_notice_path, initialize_workspace
|
||||
@@ -236,6 +241,7 @@ async def test_runtime_pool_stream_message_emits_progress_and_tool_hint(tmp_path
|
||||
|
||||
return SimpleNamespace(
|
||||
engine=FakeEngine(),
|
||||
cwd=str(tmp_path),
|
||||
session_id="sess123",
|
||||
current_settings=lambda: SimpleNamespace(model="gpt-5.4"),
|
||||
commands=SimpleNamespace(lookup=lambda raw: None),
|
||||
@@ -279,6 +285,7 @@ async def test_runtime_pool_stream_message_formats_auto_compact_status_for_feish
|
||||
|
||||
return SimpleNamespace(
|
||||
engine=FakeEngine(),
|
||||
cwd=str(tmp_path),
|
||||
session_id="sess123",
|
||||
current_settings=lambda: SimpleNamespace(model="gpt-5.4"),
|
||||
commands=SimpleNamespace(lookup=lambda raw: None),
|
||||
@@ -536,6 +543,7 @@ async def test_runtime_pool_blocks_registered_bridge_spawn_without_shelling_out(
|
||||
|
||||
return SimpleNamespace(
|
||||
engine=FakeEngine(),
|
||||
cwd=str(tmp_path),
|
||||
session_id="sess123",
|
||||
current_settings=lambda: SimpleNamespace(model="gpt-5.4"),
|
||||
commands=registry,
|
||||
@@ -559,6 +567,97 @@ async def test_runtime_pool_blocks_registered_bridge_spawn_without_shelling_out(
|
||||
assert marker.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pool_memory_command_uses_ohmo_personal_memory(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / ".ohmo-home"
|
||||
initialize_workspace(workspace)
|
||||
registry = create_default_command_registry()
|
||||
|
||||
async def fake_build_runtime(**kwargs):
|
||||
class FakeEngine:
|
||||
messages = []
|
||||
total_usage = UsageSnapshot()
|
||||
|
||||
def set_system_prompt(self, prompt):
|
||||
self.system_prompt = prompt
|
||||
|
||||
return SimpleNamespace(
|
||||
engine=FakeEngine(),
|
||||
cwd=str(tmp_path),
|
||||
session_id="sess123",
|
||||
current_settings=lambda: SimpleNamespace(model="gpt-5.4"),
|
||||
commands=registry,
|
||||
tool_registry=None,
|
||||
app_state=None,
|
||||
session_backend=None,
|
||||
extra_skill_dirs=(),
|
||||
extra_plugin_roots=(),
|
||||
hook_summary=lambda: "",
|
||||
mcp_summary=lambda: "",
|
||||
plugin_summary=lambda: "",
|
||||
)
|
||||
|
||||
async def fake_start_runtime(bundle):
|
||||
return None
|
||||
|
||||
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
|
||||
monkeypatch.setattr("ohmo.gateway.runtime.build_runtime", fake_build_runtime)
|
||||
monkeypatch.setattr("ohmo.gateway.runtime.start_runtime", fake_start_runtime)
|
||||
|
||||
pool = OhmoSessionRuntimePool(cwd=tmp_path, workspace=workspace, provider_profile="codex")
|
||||
message = InboundMessage(
|
||||
channel="feishu",
|
||||
sender_id="u1",
|
||||
chat_id="c1",
|
||||
content="/memory add Profile :: prefers concise answers",
|
||||
)
|
||||
updates = [u async for u in pool.stream_message(message, "feishu:c1")]
|
||||
|
||||
assert updates[-1].text == "Added memory entry profile.md"
|
||||
assert [path.name for path in list_ohmo_memory_files(workspace)] == ["profile.md"]
|
||||
assert "prefers concise answers" in (workspace / "memory" / "profile.md").read_text(encoding="utf-8")
|
||||
assert list_project_memory_files(tmp_path) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pool_prompt_excludes_project_memory(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / ".ohmo-home"
|
||||
initialize_workspace(workspace)
|
||||
add_ohmo_memory_entry(workspace, "personal", "ohmo-only personal fact")
|
||||
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
|
||||
add_project_memory_entry(tmp_path, "project", "project memory should not leak")
|
||||
|
||||
async def fake_build_runtime(**kwargs):
|
||||
class FakeEngine:
|
||||
messages = []
|
||||
total_usage = UsageSnapshot()
|
||||
|
||||
def set_system_prompt(self, prompt):
|
||||
self.system_prompt = prompt
|
||||
|
||||
engine = FakeEngine()
|
||||
return SimpleNamespace(
|
||||
engine=engine,
|
||||
session_id="sess123",
|
||||
current_settings=lambda: Settings(system_prompt=kwargs["system_prompt"]),
|
||||
commands=create_default_command_registry(),
|
||||
)
|
||||
|
||||
async def fake_start_runtime(bundle):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("ohmo.gateway.runtime.build_runtime", fake_build_runtime)
|
||||
monkeypatch.setattr("ohmo.gateway.runtime.start_runtime", fake_start_runtime)
|
||||
|
||||
pool = OhmoSessionRuntimePool(cwd=tmp_path, workspace=workspace, provider_profile="codex")
|
||||
bundle = await pool.get_bundle("feishu:c1", latest_user_prompt="hello")
|
||||
|
||||
assert "ohmo-only personal fact" in bundle.engine.system_prompt
|
||||
assert "project memory should not leak" not in bundle.engine.system_prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pool_allows_opted_in_remote_admin_commands(tmp_path, monkeypatch, caplog):
|
||||
workspace = tmp_path / ".ohmo-home"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ohmo.memory import add_memory_entry
|
||||
from openharness.config.settings import Settings
|
||||
from openharness.memory import add_memory_entry as add_project_memory_entry
|
||||
from openharness.prompts import build_runtime_system_prompt
|
||||
|
||||
from ohmo.memory import add_memory_entry as add_ohmo_memory_entry
|
||||
from ohmo.prompts import build_ohmo_system_prompt
|
||||
from ohmo.workspace import (
|
||||
get_bootstrap_path,
|
||||
@@ -18,7 +22,7 @@ def test_ohmo_prompt_includes_persona_and_memory(tmp_path: Path):
|
||||
get_identity_path(workspace).write_text("# identity\nName: ohmo\n", encoding="utf-8")
|
||||
get_user_path(workspace).write_text("# user\nPrefers terse answers.\n", encoding="utf-8")
|
||||
get_bootstrap_path(workspace).write_text("# bootstrap\nAsk a few high-value questions.\n", encoding="utf-8")
|
||||
add_memory_entry(workspace, "timezone", "The user prefers UTC timestamps.")
|
||||
add_ohmo_memory_entry(workspace, "timezone", "The user prefers UTC timestamps.")
|
||||
|
||||
prompt = build_ohmo_system_prompt(tmp_path, workspace=workspace)
|
||||
|
||||
@@ -29,3 +33,22 @@ def test_ohmo_prompt_includes_persona_and_memory(tmp_path: Path):
|
||||
assert "Ask a few high-value questions." in prompt
|
||||
assert "timezone.md" in prompt
|
||||
assert "UTC timestamps" in prompt
|
||||
|
||||
|
||||
def test_ohmo_runtime_prompt_can_exclude_project_memory(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
|
||||
workspace = tmp_path / ".ohmo-home"
|
||||
initialize_workspace(workspace)
|
||||
add_ohmo_memory_entry(workspace, "personal", "ohmo-only personal fact")
|
||||
add_project_memory_entry(tmp_path, "project", "project memory should not leak")
|
||||
|
||||
base_prompt = build_ohmo_system_prompt(tmp_path, workspace=workspace)
|
||||
runtime_prompt = build_runtime_system_prompt(
|
||||
Settings(system_prompt=base_prompt),
|
||||
cwd=tmp_path,
|
||||
latest_user_prompt="hello",
|
||||
include_project_memory=False,
|
||||
)
|
||||
|
||||
assert "ohmo-only personal fact" in runtime_prompt
|
||||
assert "project memory should not leak" not in runtime_prompt
|
||||
|
||||
Reference in New Issue
Block a user