From 5fcfd32385cc7c1ce6096f6bc8a063fbbda1d872 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 3 Sep 2026 16:22:29 +0530 Subject: [PATCH] keep the write_episode example inside the client's 2048-char window Claude Code truncates an MCP tool description at 2048 characters and reports it as a debug line nobody reads. Restarting the eigen session surfaced one: Tool "write_episode" description truncated from 2252 to 2048 chars Every session that has ever connected saw the description cut mid-word: ...entities=[{"name": "Postgres", "type": "tool"}, {"name": "Decisi The whole facts=[...] argument - source, target, relation_type, fact, confidence - was never shown. The only 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. A cut example is worse than no example, because it still reads as authoritative. The docstring is now 1723 characters, 325 under the limit, with the example whole. Nothing was dropped that a caller needs; the prose around it was tightened. Length is a correctness property here, so it is now tested against the descriptions the server actually publishes rather than the source text: every tool under the limit, 100 characters of headroom so an ordinary edit fails the build before it fails a client, the example balanced and complete, every required argument and all three confidence values inside the window. Each guard was verified to fail against the bug reintroduced, not merely to pass - the practice the eigen session adopted after a guard that had only ever passed turned out to assert nothing. 411 tests pass, 5 added. --- src/echo_memory/server.py | 61 ++++++++++------------ tests/unit/test_tool_descriptions.py | 78 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 33 deletions(-) create mode 100644 tests/unit/test_tool_descriptions.py 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