fix(mem0-agent): non-blocking stdin, clean session_state, lint

Found by running the CLI live against a scratch project:
- hook_input() blocked forever on an open stdin pipe with no data. A real hook
  writes JSON and closes the pipe, but any manual invocation hung; now polled
  with select() and never blocks.
- the open-thread snapshot was filling with progress lines and file-modification
  lists -- v1's exact failure -- so it now filters turns through the same
  hard-drop rules the capture gate uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Deshraj Yadav
2026-07-28 02:12:23 -07:00
co-authored by Claude Fable 5
parent 4d5f2e653c
commit ba1de5f87b
6 changed files with 76 additions and 9 deletions
+24 -4
View File
@@ -24,12 +24,32 @@ from .settings import CAPTURE_LEVELS, MEMORY_MODES, RETRIEVAL_LEVELS, Settings
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# hook plumbing # hook plumbing
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def hook_input() -> dict: def hook_input(timeout: float = 0.25) -> dict:
"""Editors hand hooks a JSON payload on stdin. Absent or malformed is fine.""" """Editors hand hooks a JSON payload on stdin. Absent or malformed is fine.
if sys.stdin is None or sys.stdin.isatty():
Never blocks. A hook's stdin is written and closed, but a manual invocation inherits
an open pipe with nothing in it -- reading that would hang the command forever.
"""
stream = sys.stdin
if stream is None or not hasattr(stream, "read"):
return {} return {}
try: try:
raw = sys.stdin.read() if stream.isatty():
return {}
except Exception:
return {}
# StringIO and friends (tests) are readable immediately; real pipes get a poll.
if hasattr(stream, "fileno"):
try:
import select
if not select.select([stream], [], [], timeout)[0]:
return {}
except Exception:
pass # not selectable (e.g. StringIO) -- fall through and read
try:
raw = stream.read()
return json.loads(raw) if raw.strip() else {} return json.loads(raw) if raw.strip() else {}
except Exception: except Exception:
return {} return {}
@@ -12,7 +12,7 @@ from __future__ import annotations
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from itertools import combinations from itertools import combinations
from typing import Any, Iterable from typing import Iterable
from .api import expiry_date, results_of from .api import expiry_date, results_of
from .config import filters as F from .config import filters as F
@@ -231,7 +231,7 @@ class SessionState:
def read_lines(self, name: str) -> list[dict]: def read_lines(self, name: str) -> list[dict]:
try: try:
return [json.loads(l) for l in self._p(name).read_text().splitlines() if l.strip()] return [json.loads(line) for line in self._p(name).read_text().splitlines() if line.strip()]
except Exception: except Exception:
return [] return []
@@ -86,10 +86,35 @@ def windows_since(turns: list[dict], processed: int, size: int = 4) -> list[list
return [fresh[i:i + size] for i in range(0, len(fresh), size) if fresh[i:i + size]] return [fresh[i:i + size] for i in range(0, len(fresh), size) if fresh[i:i + size]]
MIN_THREAD_WORDS = 4
def _signal_turns(turns: list[dict]) -> list[dict]:
"""Drop the same mechanical noise the capture gate drops.
Without this the open-thread snapshot fills up with progress lines and
"I modified VERSION, cli.py, ..." -- the exact content the audit found made v1's
session summaries worthless.
"""
from .triggers import _turn_drop_reason, natural_words
kept = []
for t in turns:
if t.get("tool_only") or not t.get("content"):
continue
if _turn_drop_reason([t]):
continue
if natural_words(t["content"]) < MIN_THREAD_WORDS:
continue # "ok", "good catch" -- acknowledgements, not state
kept.append(t)
return kept
def summarize_open_thread(turns: list[dict], limit: int = 3) -> str: def summarize_open_thread(turns: list[dict], limit: int = 3) -> str:
"""A plain-language snapshot for session_state: what we were doing, where it stopped.""" """A plain-language snapshot for session_state: what we were doing, where it stopped."""
users = [t["content"] for t in turns if t["role"] == "user" and t["content"]] signal = _signal_turns(turns)
assistants = [t["content"] for t in turns if t["role"] == "assistant" and t["content"]] users = [t["content"] for t in signal if t["role"] == "user"]
assistants = [t["content"] for t in signal if t["role"] == "assistant"]
if not users and not assistants: if not users and not assistants:
return "" return ""
goal = users[0][:300] if users else "" goal = users[0][:300] if users else ""
@@ -313,3 +313,26 @@ def test_full_session_produces_few_memories(tmp_path):
assert summary["sent"] >= 1 assert summary["sent"] >= 1
for added in ctx.api.added: for added in ctx.api.added:
assert "epoch" not in added["messages"][0]["content"].lower() assert "epoch" not in added["messages"][0]["content"].lower()
# --------------------------------------------------------------------------
# session_state quality
# --------------------------------------------------------------------------
def test_open_thread_excludes_mechanical_noise():
"""v1's session summaries were file lists. The snapshot must carry intent instead."""
turns = [
{"role": "assistant", "content": HEARTBEAT, "tool_only": False},
{"role": "user", "content": "Let's get the sandbox e2e suite passing today.", "tool_only": False},
{"role": "user", "content": "ok", "tool_only": False},
{"role": "assistant", "content": FILE_LIST, "tool_only": False},
]
thread = transcript.summarize_open_thread(turns)
assert "sandbox e2e" in thread
assert "VERSION" not in thread and "chat.py" not in thread
assert "epoch" not in thread.lower()
def test_open_thread_is_empty_when_there_is_only_noise():
turns = [{"role": "assistant", "content": HEARTBEAT, "tool_only": False},
{"role": "assistant", "content": FILE_LIST, "tool_only": False}]
assert transcript.summarize_open_thread(turns) == ""
@@ -1,6 +1,5 @@
"""Consolidation must never lose data, and must never touch a pinned memory.""" """Consolidation must never lose data, and must never touch a pinned memory."""
import pytest
from mem0_agent import maintain from mem0_agent import maintain
from mem0_agent.maintain import jaccard, tokens from mem0_agent.maintain import jaccard, tokens