Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions deeptutor/services/demo/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
11 changes: 11 additions & 0 deletions deeptutor/services/demo/rate_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions deeptutor/services/memory/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
6 changes: 6 additions & 0 deletions deeptutor/services/memory/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(",", ":"))
Expand Down
7 changes: 7 additions & 0 deletions deeptutor/services/session/turn_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
125 changes: 125 additions & 0 deletions tests/services/demo/test_demo_ephemeral_persistence.py
Original file line number Diff line number Diff line change
@@ -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()
Loading