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:
@@ -24,12 +24,32 @@ from .settings import CAPTURE_LEVELS, MEMORY_MODES, RETRIEVAL_LEVELS, Settings
|
||||
# --------------------------------------------------------------------------
|
||||
# hook plumbing
|
||||
# --------------------------------------------------------------------------
|
||||
def hook_input() -> dict:
|
||||
"""Editors hand hooks a JSON payload on stdin. Absent or malformed is fine."""
|
||||
if sys.stdin is None or sys.stdin.isatty():
|
||||
def hook_input(timeout: float = 0.25) -> dict:
|
||||
"""Editors hand hooks a JSON payload on stdin. Absent or malformed is fine.
|
||||
|
||||
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 {}
|
||||
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 {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import combinations
|
||||
from typing import Any, Iterable
|
||||
from typing import Iterable
|
||||
|
||||
from .api import expiry_date, results_of
|
||||
from .config import filters as F
|
||||
|
||||
@@ -231,7 +231,7 @@ class SessionState:
|
||||
|
||||
def read_lines(self, name: str) -> list[dict]:
|
||||
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:
|
||||
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]]
|
||||
|
||||
|
||||
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:
|
||||
"""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"]]
|
||||
assistants = [t["content"] for t in turns if t["role"] == "assistant" and t["content"]]
|
||||
signal = _signal_turns(turns)
|
||||
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:
|
||||
return ""
|
||||
goal = users[0][:300] if users else ""
|
||||
|
||||
@@ -313,3 +313,26 @@ def test_full_session_produces_few_memories(tmp_path):
|
||||
assert summary["sent"] >= 1
|
||||
for added in ctx.api.added:
|
||||
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."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0_agent import maintain
|
||||
from mem0_agent.maintain import jaccard, tokens
|
||||
|
||||
Reference in New Issue
Block a user