fix: remove automatic session JSON snapshots
This commit is contained in:
@@ -210,7 +210,7 @@ hermes-agent/
|
||||
| `~/.hermes/skills/` | Todas las habilidades activas (incluidas + instaladas desde hub + creadas por el agente) |
|
||||
| `~/.hermes/memories/` | Memoria persistente (MEMORY.md, USER.md) |
|
||||
| `~/.hermes/state.db` | Base de datos de sesiones SQLite |
|
||||
| `~/.hermes/sessions/` | Índice de enrutamiento del gateway (`sessions.json`), migas de pan de solicitudes, transcripciones `*.jsonl` del gateway y (opcionalmente) snapshots JSON por sesión cuando `sessions.write_json_snapshots: true` está configurado. Los snapshots por sesión están desactivados por defecto; state.db es canónica. |
|
||||
| `~/.hermes/sessions/` | Índice de enrutamiento del gateway (`sessions.json`), migas de pan de solicitudes, transcripciones `*.jsonl` del gateway y exportaciones explícitas con `/save`. Ya no se escriben snapshots JSON automáticos; los archivos existentes se conservan y state.db es canónica. |
|
||||
| `~/.hermes/cron/` | Datos de trabajos programados |
|
||||
| `~/.hermes/whatsapp/session/` | Credenciales del puente WhatsApp |
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ hermes-agent/
|
||||
| `~/.hermes/skills/` | All active skills (bundled + hub-installed + agent-created) |
|
||||
| `~/.hermes/memories/` | Persistent memory (MEMORY.md, USER.md) |
|
||||
| `~/.hermes/state.db` | SQLite session database |
|
||||
| `~/.hermes/sessions/` | Gateway routing index (`sessions.json`), request-dump breadcrumbs, gateway `*.jsonl` transcripts, and (optionally) per-session JSON snapshots when `sessions.write_json_snapshots: true` is set. The per-session snapshots are off by default; state.db is canonical. |
|
||||
| `~/.hermes/sessions/` | Gateway routing index (`sessions.json`), request-dump breadcrumbs, gateway `*.jsonl` transcripts, and explicit `/save` exports. Automatic per-session JSON snapshots are no longer written; state.db is canonical. |
|
||||
| `~/.hermes/cron/` | Scheduled job data |
|
||||
| `~/.hermes/whatsapp/session/` | WhatsApp bridge credentials |
|
||||
|
||||
@@ -329,7 +329,7 @@ User message → AIAgent._run_agent_loop()
|
||||
|
||||
- **Self-registering tools**: Each tool file calls `registry.register()` at import time. `model_tools.py` triggers discovery by importing all tool modules.
|
||||
- **Toolset grouping**: Tools are grouped into toolsets (`web`, `terminal`, `file`, `browser`, etc.) that can be enabled/disabled per platform.
|
||||
- **Session persistence**: All conversations are stored in SQLite (`hermes_state.py`) with full-text search and unique session titles. Per-session JSON snapshots in `~/.hermes/sessions/` were superseded by the SQLite store and are off by default; opt back in with `sessions.write_json_snapshots: true` if you have external tooling that consumes the JSON files directly.
|
||||
- **Session persistence**: All conversations are stored in SQLite (`hermes_state.py`) with full-text search and unique session titles. Automatic per-session JSON snapshots have been removed. Existing files are left untouched; use `/save json` or `hermes sessions export` for an explicit export.
|
||||
- **Ephemeral injection**: System prompts and prefill messages are injected at API call time, never persisted to the database or logs.
|
||||
- **Provider abstraction**: The agent works with any OpenAI-compatible API. Provider resolution happens at init time (Nous Portal OAuth, OpenRouter API key, or custom endpoint).
|
||||
- **Provider routing**: When using OpenRouter, `provider_routing` in config.yaml controls provider selection (sort by throughput/latency/price, allow/ignore specific providers, data retention policies). These are injected as `extra_body.provider` in API requests.
|
||||
|
||||
@@ -1131,13 +1131,6 @@ def _init_session_state(agent, session_id, session_db, parent_session_id, reason
|
||||
# ~/.hermes/sessions/ — kept unconditionally for request_dump_*.json debug breadcrumbs.
|
||||
agent.logs_dir = get_hermes_home() / "sessions"
|
||||
agent.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Per-session JSON snapshot is opt-in (sessions.write_json_snapshots); state.db is canonical.
|
||||
agent._session_json_enabled = False
|
||||
with suppress(Exception):
|
||||
from hermes_cli.config import load_config_readonly as _load_sess_cfg
|
||||
_sess_cfg = (_load_sess_cfg().get("sessions") or {})
|
||||
agent._session_json_enabled = bool(_sess_cfg.get("write_json_snapshots", False))
|
||||
|
||||
_set_defaults(agent, _SESSION_STATE)
|
||||
|
||||
# Filesystem checkpoint manager (transparent — not a tool)
|
||||
|
||||
@@ -851,7 +851,7 @@ def build_cache_parity_fork(
|
||||
# finalize the parent's still-active session row. suppress_status_output: fork status/warning
|
||||
# emits go via _print_fn/status_callback, which bypass the stdout redirect.
|
||||
review_agent._skip_mcp_refresh = review_agent._persist_disabled = review_agent.suppress_status_output = True
|
||||
review_agent._session_json_enabled = review_agent._end_session_on_close = False
|
||||
review_agent._end_session_on_close = False
|
||||
review_agent._session_db = None
|
||||
review_agent.session_id = agent.session_id
|
||||
# Same model only: share the warm cached system prompt (~26% cost cut; a rebuilt prompt misses
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Durable transcript persistence for ``AIAgent`` (mixin; MRO-resolved from ``run_agent``): SQLite flush
|
||||
with intrinsic ``_DB_PERSISTED_MARKER`` dedup, ephemeral-scaffolding filtering, optional JSON session log,
|
||||
with intrinsic ``_DB_PERSISTED_MARKER`` dedup, ephemeral-scaffolding filtering, explicit
|
||||
trajectory export."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import logging
|
||||
import re
|
||||
from contextlib import nullcontext
|
||||
from datetime import datetime
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.context_compressor import (
|
||||
@@ -17,11 +17,11 @@ from agent.context_compressor import (
|
||||
)
|
||||
from agent.lazy_forward import forward as _forward, forward_static as _forward_static
|
||||
from agent.memory_manager import sanitize_context
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
from agent.tool_dispatch_helpers import _is_multimodal_tool_result, _multimodal_text_summary
|
||||
from agent.trajectory import convert_scratchpad_to_think, save_trajectory as _save_trajectory_to_file
|
||||
from agent.trajectory import save_trajectory as _save_trajectory_to_file
|
||||
from agent.transcript_repair import sync_flushed_message_markers
|
||||
from utils import atomic_json_write
|
||||
|
||||
|
||||
logger = logging.getLogger("run_agent") # origin module's name: log records / caplog filters unchanged
|
||||
|
||||
@@ -262,34 +262,8 @@ def _db_flush_failed(agent, e: Exception, batch_rows: List[Dict[str, Any]], adop
|
||||
return False
|
||||
|
||||
|
||||
def _session_log_entry(agent, msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Copy of ``msg`` with scratchpad tags normalised and credentials redacted (honours HERMES_REDACT_SECRETS)."""
|
||||
if "content" not in msg:
|
||||
return msg
|
||||
content = msg["content"]
|
||||
if msg.get("role") == "assistant" and content:
|
||||
content = agent._clean_session_content(content)
|
||||
return {**msg, "content": agent._redact_message_content(content)}
|
||||
|
||||
|
||||
def _existing_log_is_larger(log_file, count: int) -> bool:
|
||||
"""Never overwrite a larger log with fewer messages (resumed agent with partial history); a corrupted
|
||||
existing file allows the overwrite."""
|
||||
if not log_file.exists():
|
||||
return False
|
||||
try:
|
||||
existing = json.loads(log_file.read_text(encoding="utf-8"))
|
||||
existing_count = existing.get("message_count", len(existing.get("messages", [])))
|
||||
except Exception:
|
||||
return False
|
||||
if existing_count > count:
|
||||
logging.debug("Skipping session log overwrite: existing has %d messages, current has %d", existing_count, count)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class SessionPersistenceMixin:
|
||||
"""Session DB flush, session log and trajectory persistence (see module docstring)."""
|
||||
"""Session DB flush and trajectory persistence (see module docstring)."""
|
||||
|
||||
def _apply_persist_user_message_override(self, messages: List[Dict]) -> None:
|
||||
"""Rewrite the current-turn user message in place: some paths send an API-only variant that must not
|
||||
@@ -311,7 +285,7 @@ class SessionPersistenceMixin:
|
||||
msg["platform_message_id"] = platform_id
|
||||
|
||||
def _persist_session(self, messages: List[Dict], conversation_history: List[Dict] = None):
|
||||
"""Save to JSON log and SQLite on any exit path. Trailing empty-response scaffolding is dropped from
|
||||
"""Save to SQLite on any exit path. Trailing empty-response scaffolding is dropped from
|
||||
the live list; the persist override is applied to the DB row only.
|
||||
|
||||
The persist user-message *override* is NOT applied here — it is resolved inside
|
||||
@@ -322,7 +296,6 @@ class SessionPersistenceMixin:
|
||||
with _persist_lock(self):
|
||||
self._drop_trailing_empty_response_scaffolding(messages)
|
||||
self._session_messages = messages
|
||||
self._save_session_log(messages)
|
||||
self._flush_messages_to_session_db(messages, conversation_history)
|
||||
# Drain async token-accounting deltas at every persist point; cheap no-op when nothing queued.
|
||||
if self._session_db is not None:
|
||||
@@ -413,49 +386,3 @@ class SessionPersistenceMixin:
|
||||
|
||||
_extract_api_error_context = _forward_static("agent.agent_runtime_helpers", "extract_api_error_context")
|
||||
_dump_api_request_debug = _forward("agent.agent_runtime_helpers", "dump_api_request_debug")
|
||||
|
||||
@staticmethod
|
||||
def _clean_session_content(content: str) -> str:
|
||||
"""Convert REASONING_SCRATCHPAD to think tags and clean up whitespace."""
|
||||
if not content:
|
||||
return content
|
||||
content = re.sub(r'\n+(<think>)', r'\n\1', convert_scratchpad_to_think(content))
|
||||
return re.sub(r'(</think>)\n+', r'\1\n', content).strip()
|
||||
|
||||
@staticmethod
|
||||
def _redact_message_content(content):
|
||||
"""Redact secrets in str or list-of-parts content (text fields only; honours HERMES_REDACT_SECRETS)."""
|
||||
if isinstance(content, str):
|
||||
return redact_sensitive_text(content)
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
return [{**p, **{k: redact_sensitive_text(p[k]) for k in ("text", "content") if isinstance(p.get(k), str)}}
|
||||
if isinstance(p, dict) else p for p in content]
|
||||
|
||||
def _save_session_log(self, messages: List[Dict[str, Any]] = None):
|
||||
"""Optional per-session JSON snapshot (``sessions.write_json_snapshots``, default False) for external
|
||||
tooling; state.db is canonical. Rewrites the full list after every persistence point."""
|
||||
if not getattr(self, "_session_json_enabled", False):
|
||||
return
|
||||
messages = messages or self._session_messages
|
||||
if not messages:
|
||||
return
|
||||
try: # re-derive the path each call so /branch and /compress land in the right file
|
||||
log_file = self.logs_dir / f"session_{_safe_session_filename_component(self.session_id)}.json"
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
# Mirror the SQLite flush: scaffolding is never durable transcript content.
|
||||
cleaned = [_session_log_entry(self, msg) for msg in messages if not _is_ephemeral_scaffolding(msg)]
|
||||
if _existing_log_is_larger(log_file, len(cleaned)):
|
||||
return
|
||||
entry = {
|
||||
"session_id": self.session_id, "model": self.model, "base_url": self.base_url, "platform": self.platform,
|
||||
"session_start": self.session_start.isoformat(), "last_updated": datetime.now().isoformat(),
|
||||
"system_prompt": redact_sensitive_text(self._cached_system_prompt or ""), "tools": self.tools or [],
|
||||
"message_count": len(cleaned), "messages": cleaned,
|
||||
}
|
||||
atomic_json_write(log_file, entry, indent=2, default=str)
|
||||
except Exception as e:
|
||||
if self.verbose_logging:
|
||||
logging.warning(f"Failed to save session log: {e}")
|
||||
|
||||
36
evals/session_snapshot_removal.py
Normal file
36
evals/session_snapshot_removal.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Real config → SQLite persistence → explicit /save A/B, without provider calls.
|
||||
|
||||
Run with a clean HOME/HERMES_HOME and PYTHONPATH pointing at the tree under test.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
home = Path(os.environ["HERMES_HOME"])
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("sessions:\n write_json_snapshots: true\n", encoding="utf-8")
|
||||
from agent.agent_init import _init_session_state
|
||||
from agent.session_persistence import SessionPersistenceMixin
|
||||
from hermes_cli.cli_session_mixin import CLISessionMixin
|
||||
from hermes_state import SessionDB
|
||||
|
||||
with SessionDB(db_path=home / "state.db") as db:
|
||||
agent = SessionPersistenceMixin()
|
||||
agent.max_iterations = 1
|
||||
_init_session_state(agent, "snapshot-probe", db, None, None, None, False, 1, 1, 1)
|
||||
agent.model, agent.base_url, agent.platform = "fixture", "http://127.0.0.1:1/v1", "cli"
|
||||
agent.tools, agent.verbose_logging = [], True
|
||||
db.create_session(agent.session_id, source="cli", model=agent.model)
|
||||
agent._session_db_created = True
|
||||
messages = [{"role": "user", "content": "retained question"},
|
||||
{"role": "assistant", "content": "retained answer"}]
|
||||
agent._persist_session(messages)
|
||||
snapshots = [p.name for p in agent.logs_dir.glob("session_*.json")]
|
||||
cli = SimpleNamespace(_session_db=db, session_id=agent.session_id)
|
||||
explicit = home / "explicit.json"
|
||||
CLISessionMixin.save_conversation(cli, f"/save json {explicit}")
|
||||
saved = json.loads(explicit.read_text(encoding="utf-8"))
|
||||
print(json.dumps({"automatic_snapshots": snapshots,
|
||||
"sqlite_messages": len(db.get_messages_as_conversation(agent.session_id)),
|
||||
"explicit_saved_messages": [m["content"] for m in saved["messages"]]}))
|
||||
@@ -2023,10 +2023,7 @@ DEFAULT_CONFIG = {
|
||||
# Minimum hours between auto-maintenance runs (tracked in state.db state_meta, shared across
|
||||
# processes).
|
||||
"min_interval_hours": 24,
|
||||
# Legacy ~/.hermes/sessions/session_{sid}.json snapshots rewritten every turn. state.db is
|
||||
# canonical (superset); snapshots consumed GBs on heavy users. Enable only for an external
|
||||
# tool that reads the JSON files directly.
|
||||
"write_json_snapshots": False,
|
||||
|
||||
# Notice about the compact FTS layout (reclaims ~60%+ of state.db). OPT-IN: legacy indexes
|
||||
# stay until `hermes sessions optimize-storage` runs, since the rebuild is disk-heavy on
|
||||
# large DBs. advise = `hermes update` prints a one-line notice with reclaimable size when a
|
||||
|
||||
42
tests/agent/test_session_snapshot_removal.py
Normal file
42
tests/agent/test_session_snapshot_removal.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Automatic persistence stays in SQLite; explicit exports remain available."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.agent_init import _init_session_state
|
||||
from agent.session_persistence import SessionPersistenceMixin
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_enabled", [False, True])
|
||||
def test_persistence_never_snapshots_but_explicit_save_works(tmp_path, monkeypatch, legacy_enabled):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
f"sessions:\n write_json_snapshots: {str(legacy_enabled).lower()}\n", encoding="utf-8")
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
agent = SessionPersistenceMixin()
|
||||
agent.max_iterations = 1
|
||||
_init_session_state(agent, "snapshot-removal", db, None, None, None, False, 1, 1, 1)
|
||||
agent.model = "fixture"
|
||||
agent.base_url = "http://127.0.0.1:1/v1"
|
||||
agent.platform = "cli"
|
||||
agent.tools = []
|
||||
agent.verbose_logging = True
|
||||
db.create_session(agent.session_id, source="cli", model=agent.model)
|
||||
agent._session_db_created = True
|
||||
messages = [{"role": "user", "content": "keep this conversation"},
|
||||
{"role": "assistant", "content": "durable answer"}]
|
||||
try:
|
||||
agent._persist_session(messages)
|
||||
assert len(db.get_messages_as_conversation(agent.session_id)) == 2
|
||||
assert not list(agent.logs_dir.glob("session_*.json"))
|
||||
from hermes_cli.cli_session_mixin import CLISessionMixin
|
||||
cli = SimpleNamespace(_session_db=db, session_id=agent.session_id)
|
||||
output = tmp_path / "explicit.json"
|
||||
CLISessionMixin.save_conversation(cli, f"/save json {output}")
|
||||
saved = json.loads(output.read_text(encoding="utf-8"))
|
||||
assert [m["content"] for m in saved["messages"]] == [m["content"] for m in messages]
|
||||
finally:
|
||||
db.close()
|
||||
@@ -12,7 +12,6 @@ gets stripped from the durable transcript. This test file verifies:
|
||||
- The JSON log drops only the nudge, keeping the assistant candidate.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -85,7 +84,7 @@ def _make_agent(ra, session_id, tmp_path):
|
||||
)
|
||||
agent._session_db = MagicMock()
|
||||
agent._session_db_created = True
|
||||
agent._session_json_enabled = True
|
||||
|
||||
agent.logs_dir = tmp_path / "logs"
|
||||
agent.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
return agent
|
||||
@@ -120,34 +119,3 @@ def test_db_flush_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch):
|
||||
assert "premature done" in persisted
|
||||
# Only the nudge is dropped.
|
||||
assert "[System: run tests]" not in persisted
|
||||
|
||||
|
||||
def test_json_log_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch):
|
||||
"""The assistant candidate is NOT flagged synthetic, so it persists in the
|
||||
JSON log. Only the nudge (flagged synthetic) is dropped."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
ra = _fresh_run_agent(tmp_path)
|
||||
agent = _make_agent(ra, "sess_json", tmp_path)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
# Assistant candidate — NOT flagged synthetic, persists.
|
||||
{"role": "assistant", "content": "premature done"},
|
||||
# Nudge — flagged synthetic, gets dropped.
|
||||
{"role": "user", "content": "[System: run tests]", "_pre_verify_synthetic": True},
|
||||
{"role": "assistant", "content": "verified and clean"},
|
||||
]
|
||||
|
||||
agent._save_session_log(messages)
|
||||
|
||||
log_file = agent.logs_dir / "session_sess_json.json"
|
||||
assert log_file.exists()
|
||||
data = json.loads(log_file.read_text(encoding="utf-8"))
|
||||
contents = [m.get("content") for m in data["messages"]]
|
||||
# The assistant candidate persists — it is real content.
|
||||
assert "premature done" in contents
|
||||
assert "verified and clean" in contents
|
||||
assert "hi" in contents
|
||||
# Only the nudge is dropped.
|
||||
assert "[System: run tests]" not in contents
|
||||
assert all(not m.get("_pre_verify_synthetic") for m in data["messages"])
|
||||
|
||||
@@ -270,7 +270,6 @@ def test_chat_multimodal_note_persists_clean_input_once(tmp_path, monkeypatch):
|
||||
agent._cached_system_prompt = "test system prompt"
|
||||
agent._session_init_model_config = None
|
||||
agent._parent_session_id = None
|
||||
agent._session_json_enabled = False
|
||||
agent._pending_cli_user_message = None
|
||||
agent._session_persist_lock = threading.RLock()
|
||||
agent._persist_user_message_idx = None
|
||||
@@ -455,7 +454,6 @@ def test_close_waits_for_atomic_cli_staging_before_snapshot(tmp_path, monkeypatc
|
||||
agent._cached_system_prompt = "test system prompt"
|
||||
agent._session_init_model_config = None
|
||||
agent._parent_session_id = None
|
||||
agent._session_json_enabled = False
|
||||
agent._pending_cli_user_message = None
|
||||
agent._session_persist_lock = threading.RLock()
|
||||
agent._persist_user_message_idx = None
|
||||
|
||||
@@ -17,7 +17,6 @@ other tests keep their existing no-arg behaviour.
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import types
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -98,7 +97,7 @@ def _real_agent(db, session_id, session_messages):
|
||||
agent._cached_system_prompt = "test system prompt"
|
||||
agent._session_init_model_config = None
|
||||
agent._parent_session_id = None
|
||||
agent._session_json_enabled = False
|
||||
|
||||
agent._pending_cli_user_message = None
|
||||
agent._session_persist_lock = threading.RLock()
|
||||
return agent
|
||||
|
||||
@@ -5,12 +5,9 @@ pieces. The OpenAI client and tool loading are mocked so no network calls
|
||||
are made.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -164,7 +161,7 @@ def test_direct_session_db_flushes_share_marker_claim(agent):
|
||||
agent._persist_user_message_timestamp = None
|
||||
agent._persist_disabled = False
|
||||
agent._session_persist_lock = threading.RLock()
|
||||
agent._session_json_enabled = False
|
||||
|
||||
|
||||
message = {"role": "user", "content": "exactly once"}
|
||||
normal = threading.Thread(target=lambda: agent._persist_session([message], []))
|
||||
@@ -500,67 +497,7 @@ class TestExtractReasoning:
|
||||
|
||||
|
||||
|
||||
class TestSessionJsonSnapshotOptIn:
|
||||
"""Regression: per-session JSON snapshot writer is opt-in via config.
|
||||
|
||||
state.db is canonical (PR #29182). ``sessions.write_json_snapshots``
|
||||
defaults to False, so the agent must NOT write ``session_{sid}.json``
|
||||
files by default — that behavior caused multi-GB sessions directories
|
||||
on heavy users. Users can opt back in for external tooling that reads
|
||||
the JSON files directly.
|
||||
"""
|
||||
|
||||
def test_session_json_disabled_by_default(self, agent):
|
||||
# Default config: writer is gated off.
|
||||
assert getattr(agent, "_session_json_enabled", False) is False, (
|
||||
"sessions.write_json_snapshots must default to False"
|
||||
)
|
||||
|
||||
def test_save_session_log_noops_when_disabled(self, agent, tmp_path):
|
||||
# When disabled, calling the method must not write any file even
|
||||
# if logs_dir is writable and messages are non-empty.
|
||||
agent._session_json_enabled = False
|
||||
agent.logs_dir = tmp_path
|
||||
agent._session_messages = [{"role": "user", "content": "hello"}]
|
||||
agent._save_session_log()
|
||||
# No session_*.json must appear under logs_dir.
|
||||
assert list(tmp_path.glob("session_*.json")) == []
|
||||
|
||||
def test_save_session_log_writes_when_enabled(self, agent, tmp_path):
|
||||
# Opt-in path: with the flag on and a session_id, the writer must
|
||||
# produce ``session_{sid}.json`` under logs_dir.
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
agent._save_session_log(messages)
|
||||
expected = tmp_path / f"session_{agent.session_id}.json"
|
||||
assert expected.exists(), (
|
||||
"Opt-in writer must produce session_{sid}.json under logs_dir"
|
||||
)
|
||||
|
||||
def test_logs_dir_retained_for_request_dumps(self, agent):
|
||||
# logs_dir is kept unconditionally because
|
||||
# agent_runtime_helpers.dump_api_request_debug still writes
|
||||
# request_dump_*.json there (debug breadcrumb path), independent of
|
||||
# the session JSON opt-in.
|
||||
assert hasattr(agent, "logs_dir")
|
||||
|
||||
def test_traversal_session_id_cannot_escape_logs_dir(self, agent, tmp_path):
|
||||
# Security regression (#5958): a traversal-shaped session ID (which can
|
||||
# originate from the untrusted X-Hermes-Session-Id API header) must not
|
||||
# redirect the session snapshot outside the sessions directory.
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
agent.session_id = "../../../../outside_dir/pwned"
|
||||
agent._save_session_log([{"role": "user", "content": "hello"}])
|
||||
|
||||
# Exactly one snapshot, and it lives directly under logs_dir.
|
||||
written = list(tmp_path.glob("session_*.json"))
|
||||
assert len(written) == 1, "writer must produce a single contained snapshot"
|
||||
assert written[0].resolve().parent == tmp_path.resolve()
|
||||
# Nothing escaped to the traversal target.
|
||||
assert not (tmp_path.parent.parent / "outside_dir").exists()
|
||||
|
||||
class TestSessionFilenameSafety:
|
||||
def test_safe_session_filename_component_contains_traversal(self):
|
||||
# The sanitizer is the chokepoint: every session-ID-derived artifact
|
||||
# path goes through it, so it must always yield a single, traversal-free
|
||||
@@ -574,76 +511,6 @@ class TestSessionJsonSnapshotOptIn:
|
||||
assert f("../a") != f("../b")
|
||||
|
||||
|
||||
class TestSaveSessionLogRedactsSecrets:
|
||||
"""Regression: session_*.json must not contain plaintext credentials (#19798, #19845)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ensure_redaction_enabled(self, monkeypatch):
|
||||
"""Force redaction on regardless of host HERMES_REDACT_SECRETS state.
|
||||
The hermetic conftest blanks the env var; the module-level
|
||||
``_REDACT_ENABLED`` constant is captured at import time, so we
|
||||
flip it directly for the duration of these tests."""
|
||||
monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False)
|
||||
monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
|
||||
|
||||
def test_redacts_api_key_in_tool_content(self, agent, tmp_path):
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "Response: Authorization: Bearer sk-proj-abc123def456ghi789jkl012mno",
|
||||
},
|
||||
]
|
||||
agent._save_session_log(messages)
|
||||
|
||||
snapshot = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8")
|
||||
assert "sk-proj-abc123def456ghi789jkl012mno" not in snapshot
|
||||
|
||||
def test_redacts_api_key_in_user_message(self, agent, tmp_path):
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
messages = [
|
||||
{"role": "user", "content": "My key is sk-ant-api03-abc123def456ghi789jkl012mno please use it"},
|
||||
]
|
||||
agent._save_session_log(messages)
|
||||
|
||||
snapshot = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8")
|
||||
assert "sk-ant-api03-abc123def456ghi789jkl012mno" not in snapshot
|
||||
|
||||
def test_redacts_system_prompt_credentials(self, agent, tmp_path):
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
agent._cached_system_prompt = "Use key sk-proj-realkey1234567890123456 for API calls"
|
||||
agent._save_session_log([{"role": "user", "content": "test"}])
|
||||
|
||||
snapshot = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8")
|
||||
assert "sk-proj-realkey1234567890123456" not in snapshot
|
||||
|
||||
def test_redacts_list_type_multimodal_content(self, agent, tmp_path):
|
||||
"""OpenAI/Anthropic multimodal shape: content = list of {type, text|image_url} parts."""
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Key: gsk_abc123def456ghi789jkl012mno"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
||||
],
|
||||
},
|
||||
]
|
||||
agent._save_session_log(messages)
|
||||
|
||||
snapshot_text = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8")
|
||||
snapshot = json.loads(snapshot_text)
|
||||
parts = snapshot["messages"][0]["content"]
|
||||
assert "gsk_abc123def456ghi789jkl012mno" not in parts[0]["text"]
|
||||
# Image part preserved untouched
|
||||
assert parts[1]["image_url"]["url"].startswith("data:image")
|
||||
|
||||
|
||||
class TestGetMessagesUpToLastAssistant:
|
||||
def test_empty_list(self, agent):
|
||||
assert agent._get_messages_up_to_last_assistant([]) == []
|
||||
@@ -6760,7 +6627,6 @@ class TestAnthropicInterruptHandler:
|
||||
Replaces the former source-reading assertion (which asserted the old,
|
||||
now-removed rebuild-on-interrupt behavior) with a behavior test.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
from run_agent import AIAgent
|
||||
|
||||
@@ -38,7 +38,7 @@ def agent(tmp_path, monkeypatch):
|
||||
)
|
||||
instance._cached_system_prompt = "stable test prompt"
|
||||
instance._session_db = None
|
||||
instance._session_json_enabled = False
|
||||
|
||||
instance.save_trajectories = False
|
||||
instance.compression_enabled = False
|
||||
instance._cleanup_task_resources = lambda *_a, **_kw: None
|
||||
@@ -251,5 +251,3 @@ def test_streamed_interim_then_different_summary_not_marked_previewed(agent, mon
|
||||
# CRITICAL: response_previewed must be False — the interim narration was
|
||||
# NOT the final response, so the CLI must render the summary.
|
||||
assert result["response_previewed"] is False
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.delegate_tool as dt
|
||||
|
||||
@@ -83,7 +82,7 @@ def _make_real_child():
|
||||
# Keep the test hermetic: no session persistence.
|
||||
child._persist_disabled = True
|
||||
child._session_db = None
|
||||
child._session_json_enabled = False
|
||||
|
||||
return child
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,8 @@ Scenarios:
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -177,7 +175,7 @@ class TestFinalizeSessionPersistE2E:
|
||||
agent._cached_system_prompt = None
|
||||
agent._session_init_model_config = None
|
||||
agent._parent_session_id = None
|
||||
agent._session_json_enabled = False
|
||||
|
||||
agent.quiet_mode = True
|
||||
# commit_memory_session runs heavy machinery we don't exercise here.
|
||||
agent.commit_memory_session = lambda *a, **k: None
|
||||
@@ -270,4 +268,3 @@ class TestOnSessionEndHook:
|
||||
model="claude-sonnet-4",
|
||||
platform="tui",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user