112 lines
4.2 KiB
Python
112 lines
4.2 KiB
Python
|
|
"""
|
||
|
|
Interaction Logger — JSONL-based observation log for RSO Phase 1.
|
||
|
|
|
||
|
|
Writes are performed on daemon background threads so logging never
|
||
|
|
blocks response delivery. All log files live under:
|
||
|
|
|
||
|
|
memory_workspace/observation/logs/YYYY-MM-DD.jsonl
|
||
|
|
memory_workspace/observation/errors/YYYY-MM-DD.jsonl
|
||
|
|
|
||
|
|
Sub-agents MUST NOT instantiate this class. Only the main Agent
|
||
|
|
(is_sub_agent=False) creates and uses an InteractionLogger.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import json
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
from datetime import date
|
||
|
|
from datetime import datetime
|
||
|
|
from datetime import timezone
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
from typing import Dict
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
|
||
|
|
class InteractionLogger:
|
||
|
|
"""Thread-safe, async JSONL interaction logger."""
|
||
|
|
|
||
|
|
def __init__(self, workspace_dir: Path) -> None:
|
||
|
|
self._base = Path(workspace_dir) / "observation"
|
||
|
|
self._logs_dir = self._base / "logs"
|
||
|
|
self._errors_dir = self._base / "errors"
|
||
|
|
self._summaries_dir = self._base / "summaries"
|
||
|
|
|
||
|
|
# Create directories eagerly — they must exist before the first
|
||
|
|
# background write fires.
|
||
|
|
for d in (self._logs_dir, self._errors_dir, self._summaries_dir):
|
||
|
|
d.mkdir(parents=True, exist_ok=True)
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# Public API
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
|
||
|
|
def log_interaction(self, entry: Dict[str, Any]) -> None:
|
||
|
|
"""Append an interaction entry to today's JSONL log (non-blocking)."""
|
||
|
|
path = self._logs_dir / f"{date.today().isoformat()}.jsonl"
|
||
|
|
self._fire_and_forget(path, entry)
|
||
|
|
|
||
|
|
def log_error(self, entry: Dict[str, Any]) -> None:
|
||
|
|
"""Append a structured error entry to today's error JSONL (non-blocking)."""
|
||
|
|
path = self._errors_dir / f"{date.today().isoformat()}.jsonl"
|
||
|
|
self._fire_and_forget(path, entry)
|
||
|
|
|
||
|
|
def update_signal(
|
||
|
|
self,
|
||
|
|
interaction_id: str,
|
||
|
|
signal_dict: Dict[str, Any],
|
||
|
|
) -> None:
|
||
|
|
"""Append a signal-patch record referencing a prior interaction.
|
||
|
|
|
||
|
|
Rather than mutating the original record (which would require a
|
||
|
|
read-rewrite that is neither atomic nor safe under concurrent
|
||
|
|
access), we append a lightweight patch record. The analysis
|
||
|
|
layer merges patches when it reads the log.
|
||
|
|
"""
|
||
|
|
patch = {
|
||
|
|
"record_type": "signal_patch",
|
||
|
|
"interaction_id": interaction_id,
|
||
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||
|
|
"signal": signal_dict,
|
||
|
|
}
|
||
|
|
self.log_interaction(patch)
|
||
|
|
|
||
|
|
def cleanup_old_logs(self, retention_days: int = 90) -> None:
|
||
|
|
"""Delete JSONL files older than retention_days.
|
||
|
|
|
||
|
|
Called synchronously at agent startup — not on the hot path.
|
||
|
|
"""
|
||
|
|
cutoff = time.time() - (retention_days * 86400)
|
||
|
|
for directory in (self._logs_dir, self._errors_dir):
|
||
|
|
for f in directory.glob("*.jsonl"):
|
||
|
|
try:
|
||
|
|
if f.stat().st_mtime < cutoff:
|
||
|
|
f.unlink()
|
||
|
|
print(f"[ObsLogger] Deleted old log: {f.name}")
|
||
|
|
except OSError as e:
|
||
|
|
print(f"[ObsLogger] Could not delete {f}: {e}")
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# Internal helpers
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
|
||
|
|
def _fire_and_forget(self, path: Path, record: Dict[str, Any]) -> None:
|
||
|
|
"""Launch a daemon thread to append one JSON line to *path*."""
|
||
|
|
t = threading.Thread(
|
||
|
|
target=self._append_jsonl,
|
||
|
|
args=(path, record),
|
||
|
|
daemon=True,
|
||
|
|
)
|
||
|
|
t.start()
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _append_jsonl(path: Path, record: Dict[str, Any]) -> None:
|
||
|
|
"""Append one JSON line. Called only from background threads."""
|
||
|
|
try:
|
||
|
|
line = json.dumps(record, default=str, ensure_ascii=False)
|
||
|
|
with open(path, "a", encoding="utf-8") as fh:
|
||
|
|
fh.write(line + "\n")
|
||
|
|
except Exception as e:
|
||
|
|
# Last-resort console output — never raises back to caller.
|
||
|
|
print(f"[ObsLogger] Write failed ({path.name}): {e}")
|