clinical-agent-memory-bench's picture
Upload 99 files
750b88d verified
Raw History Blame Contribute Delete
8.82 kB
"""Base contract for memory methods.
A method implements memorize() for each visit and build_memory_context() for each question.
The runner owns the answering call, so every method is answered with the same prompt, except
methods whose own agent produces the answer (ANSWERS_INTERNALLY). memorize() receives the
rendered visit text with its visit number and date.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
def as_list(x) -> list:
"""Coerce anything list-like (a numpy array included) to a plain list."""
if x is None:
return []
try:
return list(x)
except Exception:
return []
class Tokenizer:
"""Token counter backed by the tiktoken encoding named in backbone.yaml (`answer.tokenizer` for context budgets
and chunk sizes, `embedding.tokenizer` for embedding inputs); run_eval sets it before any method runs."""
def __init__(self, role: str):
self.role, self._enc = role, None
def set(self, name: str | None) -> None:
if name and not (isinstance(name, str) and name.startswith("<")):
import tiktoken
self._enc = tiktoken.get_encoding(name)
else:
raise SystemExit(f"set {self.role}.tokenizer in backbone.yaml")
def _get(self):
if self._enc is None:
raise RuntimeError(f"{self.role}.tokenizer is not set (backbone.yaml)")
return self._enc
def encode(self, text: str, **kw) -> list[int]:
return self._get().encode(text, **kw)
def decode(self, tokens) -> str:
return self._get().decode(tokens)
ENC = Tokenizer("answer")
EMBED_ENC = Tokenizer("embedding")
@dataclass
class MemoryBuildResult:
"""Result of one memorize() call."""
success: bool = True
method: str = ""
action: str = ""
visit_number: int = -1
chunk_count: int = 0
added_chunks: int = 0
time_cost: float = 0.0
extra: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {"success": self.success, "method": self.method, "action": self.action,
"visit_number": self.visit_number, "chunk_count": self.chunk_count,
"added_chunks": self.added_chunks, "time_cost": round(self.time_cost, 4),
**self.extra}
@dataclass
class MemoryContext:
"""The MEMORY block handed to the runner, or the answer of an internally answering method."""
mem: str
n_tok: int = 0
retrieved_count: int = 0
retrieved_visits: list = field(default_factory=list)
retrieved_memories: list = field(default_factory=list)
context_truncated: bool = False
error: bool = False
answer: str | None = None
extra: dict = field(default_factory=dict)
class BaseAgent(ABC):
ANSWERS_INTERNALLY = False
MEMORIZE_WITH_LLM = False
def __init__(self, **params):
self.params = params
self._context_id: str | None = None
def set_trace_sink(self, sink) -> None:
"""Install the callback that appends one event per dict to `memory_trace.jsonl`."""
self._trace_sink = sink
def _trace(self, event: str, **fields) -> None:
sink = getattr(self, "_trace_sink", None)
if sink is None:
return
try:
from ..llm_client import USAGE
sink({"method": getattr(self, "METHOD_NAME", type(self).__name__),
"event": event, "phase": getattr(USAGE, "phase", None), **fields})
except Exception as e:
n = getattr(self, "_trace_fail", 0) + 1
self._trace_fail = n
if n == 1:
try:
sink({"method": getattr(self, "METHOD_NAME", type(self).__name__),
"event": "trace_error", "error": f"{type(e).__name__}: {e}"[:300],
"failed_event": event})
except Exception:
pass
def consolidate(self) -> dict | None:
"""Sleep-time memory maintenance, run at each checkpoint before answering."""
return None
def dump_memory(self, out_dir) -> dict | None:
"""Export the current memory state into out_dir; return a small stats dict, or None."""
return None
def set_context_id(self, context_id: str) -> None:
"""Set the per-patient namespace."""
self._context_id = str(context_id)
@abstractmethod
def memorize(self, text: str, visit_number: int, date: str) -> MemoryBuildResult:
"""Store one rendered visit into memory."""
def memorize_session(self, session: dict,
rendered_text: str | None = None) -> MemoryBuildResult:
"""Memorize one session from dataset.load_sessions()."""
if rendered_text is None:
from ..dataset import render_visit
rendered_text = render_visit(session)
return self.memorize(rendered_text, session["visit_number"], session["date"])
@abstractmethod
def build_memory_context(self, question: str, as_of: str = "",
display_question: str | None = None) -> MemoryContext:
"""Produce the MEMORY block for one question."""
@staticmethod
def count_tokens(text: str) -> int:
return len(ENC.encode(text)) if text else 0
@staticmethod
def cut_tokens(text: str, max_tokens: int) -> str:
"""The first `max_tokens` tokens of `text`."""
toks = ENC.encode(text)
return text if len(toks) <= max_tokens else ENC.decode(toks[:max_tokens])
@staticmethod
def split_tokens(text: str, chunk_size: int, chunk_overlap: int,
header: str = "") -> list[str]:
"""Split text into token windows; continuation chunks repeat `header` + '(cont.)'."""
if chunk_overlap >= chunk_size:
raise ValueError(f"chunk_overlap ({chunk_overlap}) must be smaller "
f"than chunk_size ({chunk_size})")
if not text.strip():
return []
tokens = ENC.encode(text)
if len(tokens) <= chunk_size:
return [text]
chunks, start = [], 0
while start < len(tokens):
end = min(start + chunk_size, len(tokens))
piece = ENC.decode(tokens[start:end])
if start > 0 and header:
piece = f"{header} (cont.)\n{piece}"
chunks.append(piece)
if end == len(tokens):
break
start = end - chunk_overlap
return chunks
def snapshot_jsonl_gz(path, rows) -> int:
"""Write rows as gzipped JSONL; returns row count."""
import gzip
import json as _json
with gzip.open(path, "wt", encoding="utf-8") as fh:
n = 0
for r in rows:
fh.write(_json.dumps(r, ensure_ascii=False, default=str) + "\n")
n += 1
return n
def snapshot_sqlite(src, dst) -> None:
"""Copy an open SQLite database with the backup API."""
import sqlite3
a = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
try:
b = sqlite3.connect(str(dst))
try:
with b:
a.backup(b)
finally:
b.close()
finally:
a.close()
def snapshot_vectors_delta(out_dir, pairs, seen: set) -> int:
"""Write the vectors this checkpoint added, and record them as seen."""
import numpy as np
ids, vecs = [], []
for mid, v in pairs:
mid = str(mid)
if not mid or mid in seen or v is None:
continue
seen.add(mid)
ids.append(mid)
vecs.append(np.asarray(v, dtype="float32"))
if not ids:
np.savez_compressed(out_dir / "vectors.npz",
ids=np.array([], dtype=object), vecs=np.zeros((0, 0), dtype="float32"))
return 0
np.savez_compressed(out_dir / "vectors.npz",
ids=np.array(ids, dtype=object), vecs=np.vstack(vecs))
return len(ids)
def snapshot_tree(src, dst) -> int:
"""Copy a store directory, SQLite files through the backup API, skipping lock and tmp files; return the count."""
import shutil
from pathlib import Path as _P
src, dst = _P(src), _P(dst)
n = 0
for f in src.rglob("*"):
if not f.is_file() or f.name.endswith((".lock", "-wal", "-shm")):
continue
if "tmp" in f.relative_to(src).parts[:-1]:
continue
rel = dst / f.relative_to(src)
rel.parent.mkdir(parents=True, exist_ok=True)
if f.suffix in (".db", ".sqlite") or f.name.endswith(".sqlite3") or f.name == "storage.sqlite":
try:
snapshot_sqlite(f, rel)
except Exception:
shutil.copy2(f, rel)
else:
shutil.copy2(f, rel)
n += 1
return n