diff --git a/src/echo_memory/server.py b/src/echo_memory/server.py index ba39ed8..b97a1e8 100644 --- a/src/echo_memory/server.py +++ b/src/echo_memory/server.py @@ -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": - "" | "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": "" | "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) diff --git a/tests/unit/test_tool_descriptions.py b/tests/unit/test_tool_descriptions.py new file mode 100644 index 0000000..12e0219 --- /dev/null +++ b/tests/unit/test_tool_descriptions.py @@ -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