From 62e0331fe3781ebd67b3cb122bc9d2b9ab1aca68 Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Mon, 13 Jul 2026 22:48:48 -0700 Subject: [PATCH] Keep chat history fully ephemeral in demo mode Demo mode was only ephemeral at the session-store layer (get_sqlite_session_store returns an in-memory DB). But the turn runtime still mirrored every turn's full transcript to data/user/workspace///events.jsonl on disk on every turn, and the memory subsystem still wrote L1 traces and L3 preferences to disk when RAG search or the write_memory tool ran. So conversation history kept landing on disk even with DEMO_MODE=1. Add a shared is_demo_mode() guard and skip the three conversation-derived disk-write paths when demo mode is on: - turn_runtime._mirror_event_to_workspace (events.jsonl transcript mirror) - memory.trace.append (L1 trace of queries / stated preferences) - memory.store.write_preference (L3 preferences.md) Co-Authored-By: Claude Opus 4.8 (1M context) --- deeptutor/services/demo/__init__.py | 16 ++- deeptutor/services/demo/rate_limiter.py | 11 ++ deeptutor/services/memory/store.py | 6 + deeptutor/services/memory/trace.py | 6 + deeptutor/services/session/turn_runtime.py | 7 + .../demo/test_demo_ephemeral_persistence.py | 125 ++++++++++++++++++ 6 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 tests/services/demo/test_demo_ephemeral_persistence.py diff --git a/deeptutor/services/demo/__init__.py b/deeptutor/services/demo/__init__.py index fab6fa0d..6a55efc0 100644 --- a/deeptutor/services/demo/__init__.py +++ b/deeptutor/services/demo/__init__.py @@ -1,5 +1,17 @@ """Demo-mode safety features (per-IP rate limiting for a public demo).""" -from .rate_limiter import DemoRateLimiter, RateDecision, client_ip, get_demo_limiter +from .rate_limiter import ( + DemoRateLimiter, + RateDecision, + client_ip, + get_demo_limiter, + is_demo_mode, +) -__all__ = ["DemoRateLimiter", "RateDecision", "client_ip", "get_demo_limiter"] +__all__ = [ + "DemoRateLimiter", + "RateDecision", + "client_ip", + "get_demo_limiter", + "is_demo_mode", +] diff --git a/deeptutor/services/demo/rate_limiter.py b/deeptutor/services/demo/rate_limiter.py index 3ee83372..c7168915 100644 --- a/deeptutor/services/demo/rate_limiter.py +++ b/deeptutor/services/demo/rate_limiter.py @@ -210,3 +210,14 @@ def get_demo_limiter() -> DemoRateLimiter: if _limiter is None: _limiter = DemoRateLimiter() return _limiter + + +def is_demo_mode() -> bool: + """True when ``DEMO_MODE`` is enabled. + + Shared guard for the demo's "history stays ephemeral" contract: any code + path that would otherwise write conversation-derived state to disk checks + this and skips the write. Reuses the process-wide limiter so the answer is + consistent everywhere and honours test overrides of ``_limiter``. + """ + return get_demo_limiter().enabled diff --git a/deeptutor/services/memory/store.py b/deeptutor/services/memory/store.py index 4696fc04..7a6c43fc 100644 --- a/deeptutor/services/memory/store.py +++ b/deeptutor/services/memory/store.py @@ -182,6 +182,12 @@ async def write_preference( """Write the chat-mode preference signal. The ``write_memory`` tool is the only caller; ``trace_id`` is the current chat turn's L1 id injected by runtime.""" + from deeptutor.services.demo import is_demo_mode + + # Demo mode: persistent memory derived from the chat must stay + # ephemeral, so skip writing ``L3/preferences.md`` to disk entirely. + if is_demo_mode(): + return ApplyReport(accepted=False, reason="demo mode: memory not persisted") path = paths.l3_file("preferences") async with self._lock_for(path): doc = ( diff --git a/deeptutor/services/memory/trace.py b/deeptutor/services/memory/trace.py index d9f26d0b..ffdbc0b7 100644 --- a/deeptutor/services/memory/trace.py +++ b/deeptutor/services/memory/trace.py @@ -65,6 +65,12 @@ def new( async def append(event: TraceEvent) -> None: """Append one event to today's surface trace file. Never raises.""" + from deeptutor.services.demo import is_demo_mode + + # Demo mode: conversation-derived signals (queries, stated preferences) + # must stay ephemeral, so never write the L1 trace to disk. + if is_demo_mode(): + return try: path = trace_file(event.surface, datetime.now(tz=timezone.utc).date()) line = json.dumps(asdict(event), ensure_ascii=False, separators=(",", ":")) diff --git a/deeptutor/services/session/turn_runtime.py b/deeptutor/services/session/turn_runtime.py index 3581eedf..1b468c46 100644 --- a/deeptutor/services/session/turn_runtime.py +++ b/deeptutor/services/session/turn_runtime.py @@ -2011,6 +2011,13 @@ async def _flush_buffered_events(self, execution: _TurnExecution) -> None: @staticmethod def _mirror_event_to_workspace(execution: _TurnExecution, payload: dict[str, Any]) -> None: """Mirror turn events to task-local ``events.jsonl`` files under ``data/user/workspace``.""" + from deeptutor.services.demo import is_demo_mode + + # Demo mode: chat history must stay ephemeral. The session store is + # already in-memory (see ``get_sqlite_session_store``); skip this + # on-disk transcript mirror so the full turn is never written to disk. + if is_demo_mode(): + return try: path_service = get_path_service() task_dir = path_service.get_task_workspace(execution.capability, execution.turn_id) diff --git a/tests/services/demo/test_demo_ephemeral_persistence.py b/tests/services/demo/test_demo_ephemeral_persistence.py new file mode 100644 index 00000000..44347199 --- /dev/null +++ b/tests/services/demo/test_demo_ephemeral_persistence.py @@ -0,0 +1,125 @@ +"""Demo mode keeps chat history ephemeral across *every* per-turn disk write. + +Gating the session-store factory (see ``test_session_store_demo.py``) makes the +chat-history DB in-memory, but the turn runtime and the memory subsystem write +conversation-derived state to disk through other paths. In demo mode those must +be skipped too, otherwise history is still persisted on disk. Each test below +pins the culprit write path and asserts it is a no-op when ``DEMO_MODE`` is on +and still writes when it is off. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +import deeptutor.services.demo.rate_limiter as rate_limiter +from deeptutor.services.memory import store as memory_store +from deeptutor.services.memory import trace as memory_trace +from deeptutor.services.memory.paths import trace_file +from deeptutor.services.path_service import PathService +from deeptutor.services.session.turn_runtime import TurnRuntimeManager, _TurnExecution + + +@pytest.fixture +def tmp_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + """Point every persistence root at a tmp dir and reset the demo limiter.""" + monkeypatch.setattr(rate_limiter, "_limiter", None) + + service = PathService.get_instance() + saved = (service._workspace_root, service._project_root, service._user_data_dir) + service._workspace_root = (tmp_path / "data").resolve() + service._project_root = tmp_path.resolve() + service._user_data_dir = (tmp_path / "data" / "user").resolve() + try: + yield service + finally: + (service._workspace_root, service._project_root, service._user_data_dir) = saved + + +def _execution() -> _TurnExecution: + return _TurnExecution( + turn_id="turn_abc", + session_id="sess_1", + capability="chat", + payload={}, + ) + + +def test_workspace_event_mirror_skipped_in_demo(tmp_paths, monkeypatch): + monkeypatch.setenv("DEMO_MODE", "true") + + TurnRuntimeManager._mirror_event_to_workspace( + _execution(), {"type": "assistant", "content": "secret history"} + ) + + event_file = tmp_paths.get_task_workspace("chat", "turn_abc") / "events.jsonl" + assert not event_file.exists() + + +def test_workspace_event_mirror_written_when_demo_off(tmp_paths, monkeypatch): + monkeypatch.delenv("DEMO_MODE", raising=False) + + TurnRuntimeManager._mirror_event_to_workspace( + _execution(), {"type": "assistant", "content": "kept history"} + ) + + event_file = tmp_paths.get_task_workspace("chat", "turn_abc") / "events.jsonl" + assert event_file.exists() + assert "kept history" in event_file.read_text(encoding="utf-8") + + +@pytest.mark.asyncio +async def test_memory_trace_append_skipped_in_demo(tmp_paths, monkeypatch): + monkeypatch.setenv("DEMO_MODE", "true") + + await memory_trace.append( + memory_trace.TraceEvent.new("chat", "preference_stated", {"text": "likes X"}) + ) + + day = datetime.now(tz=timezone.utc).date() + assert not trace_file("chat", day).exists() + + +@pytest.mark.asyncio +async def test_memory_trace_append_written_when_demo_off(tmp_paths, monkeypatch): + monkeypatch.delenv("DEMO_MODE", raising=False) + + await memory_trace.append( + memory_trace.TraceEvent.new("chat", "preference_stated", {"text": "likes X"}) + ) + + day = datetime.now(tz=timezone.utc).date() + assert trace_file("chat", day).exists() + + +@pytest.mark.asyncio +async def test_write_preference_skipped_in_demo(tmp_paths, monkeypatch): + monkeypatch.setenv("DEMO_MODE", "true") + + store = memory_store.MemoryStore() + report = await store.write_preference( + op="add", text="prefers dark mode", trace_id=memory_trace.new_trace_id("chat") + ) + + from deeptutor.services.memory.paths import l3_file + + assert not l3_file("preferences").exists() + assert report.accepted is False + + +@pytest.mark.asyncio +async def test_write_preference_written_when_demo_off(tmp_paths, monkeypatch): + monkeypatch.delenv("DEMO_MODE", raising=False) + + store = memory_store.MemoryStore() + report = await store.write_preference( + op="add", text="prefers dark mode", trace_id=memory_trace.new_trace_id("chat") + ) + + from deeptutor.services.memory.paths import l3_file + + assert report.accepted is True + assert l3_file("preferences").exists()