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:
co-authored by
Claude Fable 5
parent
4d5f2e653c
commit
ba1de5f87b
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user