Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 28 additions & 33 deletions src/echo_memory/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,42 +76,37 @@ def write_episode(
"""Record something worth remembering later: a decision, a correction,
a stated preference, or context that would otherwise have to be
re-explained to a different tool or a future session. Call this
proactively and immediately when you notice one of these - don't wait
to be asked, and don't batch it up for later in the conversation. The
cost of a missed memory (re-explaining something later) is higher than
the cost of one extra call.
proactively and immediately when you notice one - don't wait to be
asked, don't batch it for later. A missed memory costs more than an
extra call.

You (the calling agent) extract entities/facts yourself - this server
never calls an LLM. Exact shape, every key required unless marked
optional:
You extract the entities and facts yourself; this server never calls
an LLM.

entities: [{"name": "Postgres", "type": "tool"}, ...]
- name: non-empty string, unique per entity in this call
- type: any short string describing what kind of thing this is
(e.g. "tool", "person", "decision", "preference") - your choice,
not a fixed enum

facts: [{"source": "Decision", "target": "Postgres", "relation_type": "uses",
"fact": "decided to use Postgres for storage", "confidence": "extracted"}, ...]
- source/target: must each exactly match a "name" in entities above
- relation_type: any short string describing the relationship
(e.g. "uses", "prefers", "caused_by") - your choice, not a fixed enum
- fact: the actual sentence to remember, plain text
- confidence: MUST be exactly one of "extracted" (directly stated),
"inferred" (you deduced it), or "ambiguous" (uncertain) - any other
value, including numbers or omitting it, is rejected

entity_resolutions (optional): only needed when a previous call
returned ambiguous_entities and you're now confirming which candidate
a mention refers to, or that it's new: {"mention name": {"resolved_to":
"<node_id from ambiguous_entities>" | "new"}}. Omit entirely on a call
with no prior ambiguity to resolve.

Example call:
write_episode(scope="solo", session_id="sess-1",
entities=[{"name": "Postgres", "type": "tool"}, {"name": "Decision", "type": "decision"}],
facts=[{"source": "Decision", "target": "Postgres", "relation_type": "uses",
"fact": "decided to use Postgres for storage", "confidence": "extracted"}])
name non-empty, unique within this call
type any short string ("tool", "person", "decision") - not an enum

facts: [{"source": ..., "target": ..., "relation_type": ...,
"fact": ..., "confidence": ...}, ...]
source/target must each exactly match a name in entities
relation_type any short string ("uses", "caused_by") - not an enum
fact the sentence to remember, plain text
confidence exactly one of "extracted" (stated), "inferred"
(deduced), "ambiguous" (uncertain). Anything else,
including a number or omitting it, is rejected.

entity_resolutions (optional): only after a call returned
ambiguous_entities, to say which candidate a mention meant:
{"mention": {"resolved_to": "<node_id>" | "new"}}. Omit otherwise.

Example:
write_episode(scope="solo", session_id="s1",
entities=[{"name": "Postgres", "type": "tool"},
{"name": "Decision", "type": "decision"}],
facts=[{"source": "Decision", "target": "Postgres",
"relation_type": "uses", "confidence": "extracted",
"fact": "decided to use Postgres for storage"}])
"""
try:
group_id = _state.config.group_id(scope)
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/test_tool_descriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Tool descriptions have to survive the client that reads them.

Claude Code truncates an MCP tool description at 2048 characters and logs it
as a debug line nobody reads. On 2026-09-03 that log revealed write_episode's
description was 2252 characters, so every session saw it cut mid-word:

...entities=[{"name": "Postgres", "type": "tool"}, {"name": "Decisi

The whole `facts=[...]` argument - source, target, relation_type, fact,
confidence - was never shown. The one worked example for the tool stopped
before demonstrating half the call, in the tool whose adoption is the entire
problem this project exists to solve.

Length is therefore a correctness property here, not style. These tests read
the descriptions the server actually publishes, so they measure what a client
receives rather than what the source looks like."""


import pytest

from echo_memory import server

# Claude Code's limit. Other clients differ; this is the tightest one in use,
# so it is the one worth holding to.
MAX_DESCRIPTION = 2048

# Enough headroom that an ordinary edit cannot silently cross the line - the
# failure is invisible at runtime, so the margin is the early warning.
HEADROOM = 100


def _tools():
return {t.name: (t.description or "") for t in server.server._tool_manager.list_tools()}


def test_every_tool_description_fits_the_client_limit():
too_long = {n: len(d) for n, d in _tools().items() if len(d) > MAX_DESCRIPTION}
assert not too_long, f"truncated by Claude Code at {MAX_DESCRIPTION}: {too_long}"


def test_descriptions_keep_headroom_below_the_limit():
"""Not the same test. Crossing the limit is silent - a debug log line and
a cut string - so the margin has to fail before a real edit does."""
tight = {
n: len(d) for n, d in _tools().items() if len(d) > MAX_DESCRIPTION - HEADROOM
}
assert not tight, f"within {HEADROOM} chars of the {MAX_DESCRIPTION} limit: {tight}"


@pytest.mark.parametrize("tool", ["write_episode"])
def test_the_worked_example_survives_truncation(tool):
"""The example is the part a model copies. It has to be inside the window
AND syntactically whole - a cut example is worse than none, because it
reads as authoritative."""
description = _tools()[tool]
visible = description[:MAX_DESCRIPTION]
example = visible[visible.index("write_episode(") :]
assert example.count("(") == example.count(")"), "example call is unbalanced"
assert example.count("[") == example.count("]"), "example arrays are unbalanced"
assert example.count("{") == example.count("}"), "example objects are unbalanced"


def test_the_example_shows_every_required_argument():
"""The 2026-09-03 truncation cut `facts` entirely, so the only example
demonstrated entities and nothing else."""
visible = _tools()["write_episode"][:MAX_DESCRIPTION]
for argument in ("scope=", "session_id=", "entities=", "facts="):
assert argument in visible, f"{argument} missing from the visible example"
for key in ("source", "target", "relation_type", "fact", "confidence"):
assert f'"{key}"' in visible, f"fact key {key!r} never shown"


def test_the_confidence_enum_is_stated_in_full():
"""The one field the server rejects outright. All three values have to be
inside the window or a model guesses, and a guess is a failed write."""
visible = _tools()["write_episode"][:MAX_DESCRIPTION]
for value in ("extracted", "inferred", "ambiguous"):
assert value in visible
Loading