Skip to content

Commit 67c90c3

Browse files
authored
Merge pull request #2034 from mvalentsev/fix/2023-checkpoint-added-by
fix(mcp): preserve agent attribution in mempalace_checkpoint (#2023)
2 parents ec8788c + de74cad commit 67c90c3

4 files changed

Lines changed: 224 additions & 2 deletions

File tree

integrations/openclaw/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ tool-specific workflow below says to.
121121
- `items` (required): array of `{wing, room, content}`; content must be verbatim
122122
- `diary`: optional `{agent_name, entry, topic?, wing?}`; entry should use AAAK format
123123
- `dedup_threshold`: similarity threshold (default 0.9)
124+
- `added_by`: optional filing agent label (defaults to the diary `agent_name`, else `checkpoint`)
124125
- `mempalace_update_drawer` — Update an existing drawer's content and/or move it to a different wing/room
125126
- `drawer_id` (required)
126127
- `content`, `wing`, `room`: at least one must be provided (no-op otherwise)

mempalace/mcp_server.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3898,7 +3898,7 @@ def tool_reconnect():
38983898
return {"success": False, "error": str(e)}
38993899

39003900

3901-
def tool_checkpoint(items, diary=None, dedup_threshold=0.9):
3901+
def tool_checkpoint(items, diary=None, dedup_threshold=0.9, added_by=None):
39023902
"""Batch session save in a single call.
39033903
39043904
Semantic-dedups each item, files the non-duplicates as drawers, then
@@ -3909,6 +3909,9 @@ def tool_checkpoint(items, diary=None, dedup_threshold=0.9):
39093909
39103910
``items`` is a list of ``{"wing", "room", "content"}`` dicts. ``diary``
39113911
is an optional ``{"agent_name", "entry", "topic"?, "wing"?}`` dict.
3912+
``added_by`` attributes the filed drawers; when omitted it falls back to
3913+
the diary's ``agent_name`` (and then to ``"checkpoint"``), so the agent
3914+
that filed the session is recorded instead of a generic label.
39123915
Reuses the existing single-item handlers so dedup/idempotency/WAL
39133916
behaviour is identical to calling them directly.
39143917
"""
@@ -3925,6 +3928,20 @@ def tool_checkpoint(items, diary=None, dedup_threshold=0.9):
39253928
out = {"added": [], "duplicates": [], "errors": []}
39263929
if not isinstance(items, list):
39273930
return {"error": "items must be a list of {wing, room, content} objects"}
3931+
# Drawer attribution: an explicit ``added_by`` wins; otherwise fall back to
3932+
# the diary's ``agent_name`` (the agent filing this session); otherwise the
3933+
# legacy ``"checkpoint"`` label. A blank, whitespace-only, or non-string
3934+
# value counts as unspecified at each step, so an empty explicit argument
3935+
# still defers to the diary instead of masking it. The chosen name is stored
3936+
# verbatim (tool_add_drawer strips lone surrogates but does not case-fold),
3937+
# matching how every other caller records ``added_by``; the diary index
3938+
# lowercases the same name separately for case-insensitive reads.
3939+
resolved_added_by = added_by if isinstance(added_by, str) and added_by.strip() else None
3940+
if resolved_added_by is None and isinstance(diary, dict):
3941+
agent = diary.get("agent_name")
3942+
resolved_added_by = agent if isinstance(agent, str) and agent.strip() else None
3943+
if resolved_added_by is None:
3944+
resolved_added_by = "checkpoint"
39283945
for item in items:
39293946
if not isinstance(item, dict):
39303947
out["errors"].append({"item": item, "error": "item must be an object"})
@@ -3947,7 +3964,7 @@ def tool_checkpoint(items, diary=None, dedup_threshold=0.9):
39473964
# string by the guard above) we still file rather than drop the
39483965
# memory: verbatim recall is the priority and add_drawer's own
39493966
# idempotency blocks exact duplicates.
3950-
res = tool_add_drawer(wing=wing, room=room, content=content, added_by="checkpoint")
3967+
res = tool_add_drawer(wing=wing, room=room, content=content, added_by=resolved_added_by)
39513968
if res.get("success"):
39523969
out["added"].append(res)
39533970
else:
@@ -4352,6 +4369,10 @@ def tool_checkpoint(items, diary=None, dedup_threshold=0.9):
43524369
"type": "number",
43534370
"description": "Similarity threshold 0-1 for the per-item dedup check (default 0.9)",
43544371
},
4372+
"added_by": {
4373+
"type": "string",
4374+
"description": "Who is filing these drawers. An explicit value takes precedence; otherwise the diary agent_name, else 'checkpoint'.",
4375+
},
43554376
},
43564377
"required": ["items"],
43574378
},

tests/test_mcp_server.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1997,6 +1997,205 @@ def test_checkpoint_registered_in_tools(self):
19971997
assert "mempalace_checkpoint" in mcp_server.TOOLS
19981998
assert mcp_server.TOOLS["mempalace_checkpoint"]["handler"] is mcp_server.tool_checkpoint
19991999

2000+
def test_checkpoint_added_by_defaults_to_diary_agent(
2001+
self, monkeypatch, config, palace_path, kg
2002+
):
2003+
"""#2023: with no explicit ``added_by``, each filed drawer is attributed
2004+
to the diary ``agent_name`` (verbatim case) rather than the generic
2005+
``checkpoint`` label, so the filing agent survives in provenance."""
2006+
_patch_mcp_server(monkeypatch, config, kg)
2007+
_client, _col = _get_collection(palace_path, create=True)
2008+
_client.close() # release file handles; a bare del leaks them on Windows (#1128)
2009+
from mempalace.mcp_server import tool_checkpoint
2010+
2011+
result = tool_checkpoint(
2012+
items=[{"wing": "w", "room": "decisions", "content": "Use PostgreSQL for storage."}],
2013+
diary={"agent_name": "DeepSeek", "wing": "w", "entry": "SESSION|did.stuff|star"},
2014+
)
2015+
assert len(result["added"]) == 1
2016+
2017+
client, col = _get_collection(palace_path)
2018+
try:
2019+
metas = col.get(include=["metadatas"])["metadatas"]
2020+
finally:
2021+
client.close()
2022+
drawers = [m for m in metas if m.get("room") == "decisions"]
2023+
assert len(drawers) == 1
2024+
# Verbatim case, not the lowercased diary-index form of agent_name.
2025+
assert drawers[0]["added_by"] == "DeepSeek"
2026+
2027+
def test_checkpoint_explicit_added_by_overrides_diary(self, monkeypatch):
2028+
"""An explicit ``added_by`` wins over the diary ``agent_name`` fallback."""
2029+
from mempalace import mcp_server
2030+
2031+
monkeypatch.setattr(
2032+
mcp_server, "tool_check_duplicate", lambda *a, **k: {"is_duplicate": False}
2033+
)
2034+
monkeypatch.setattr(mcp_server, "tool_diary_write", lambda **k: {"success": True})
2035+
filed = {}
2036+
2037+
def _add(**kwargs):
2038+
filed.update(kwargs)
2039+
return {"success": True, "drawer_id": "d1"}
2040+
2041+
monkeypatch.setattr(mcp_server, "tool_add_drawer", _add)
2042+
2043+
mcp_server.tool_checkpoint(
2044+
items=[{"wing": "w", "room": "r", "content": "keep me"}],
2045+
diary={"agent_name": "deepseek", "entry": "SESSION|x|star"},
2046+
added_by="alice",
2047+
)
2048+
assert filed["added_by"] == "alice"
2049+
2050+
def test_checkpoint_added_by_falls_back_to_checkpoint_label(self, monkeypatch):
2051+
"""Neither an explicit ``added_by`` nor a diary ``agent_name`` -> the
2052+
drawer keeps the legacy ``checkpoint`` attribution (backward compatible)."""
2053+
from mempalace import mcp_server
2054+
2055+
monkeypatch.setattr(
2056+
mcp_server, "tool_check_duplicate", lambda *a, **k: {"is_duplicate": False}
2057+
)
2058+
monkeypatch.setattr(mcp_server, "tool_diary_write", lambda **k: {"success": True})
2059+
seen = []
2060+
2061+
def _add(**kwargs):
2062+
seen.append(kwargs["added_by"])
2063+
return {"success": True, "drawer_id": "d1"}
2064+
2065+
monkeypatch.setattr(mcp_server, "tool_add_drawer", _add)
2066+
2067+
# No diary block at all.
2068+
mcp_server.tool_checkpoint(items=[{"wing": "w", "room": "r", "content": "a"}])
2069+
# Diary present but without an ``agent_name``.
2070+
mcp_server.tool_checkpoint(
2071+
items=[{"wing": "w", "room": "r", "content": "b"}],
2072+
diary={"entry": "SESSION|y|star"},
2073+
)
2074+
assert seen == ["checkpoint", "checkpoint"]
2075+
2076+
def test_checkpoint_added_by_accepted_via_dispatch(self, monkeypatch):
2077+
"""#2023: ``added_by`` passes the tools/call schema whitelist (the
2078+
reporter's HTTP MCP transport reuses this dispatcher) and the real
2079+
handler forwards it, for both the explicit value and the diary fallback."""
2080+
from mempalace import mcp_server
2081+
2082+
monkeypatch.setattr(
2083+
mcp_server, "tool_check_duplicate", lambda *a, **k: {"is_duplicate": False}
2084+
)
2085+
monkeypatch.setattr(mcp_server, "tool_diary_write", lambda **k: {"success": True})
2086+
filed = {}
2087+
2088+
def _add(**kwargs):
2089+
filed.update(kwargs)
2090+
return {"success": True, "drawer_id": "d1"}
2091+
2092+
monkeypatch.setattr(mcp_server, "tool_add_drawer", _add)
2093+
2094+
resp = mcp_server.handle_request(
2095+
{
2096+
"method": "tools/call",
2097+
"id": 1,
2098+
"params": {
2099+
"name": "mempalace_checkpoint",
2100+
"arguments": {
2101+
"items": [{"wing": "w", "room": "r", "content": "hi"}],
2102+
"added_by": "alice",
2103+
},
2104+
},
2105+
}
2106+
)
2107+
assert "error" not in resp
2108+
assert filed["added_by"] == "alice"
2109+
2110+
filed.clear()
2111+
resp2 = mcp_server.handle_request(
2112+
{
2113+
"method": "tools/call",
2114+
"id": 2,
2115+
"params": {
2116+
"name": "mempalace_checkpoint",
2117+
"arguments": {
2118+
"items": [{"wing": "w", "room": "r", "content": "yo"}],
2119+
"diary": {"agent_name": "DeepSeek", "entry": "SESSION|z|star"},
2120+
},
2121+
},
2122+
}
2123+
)
2124+
assert "error" not in resp2
2125+
assert filed["added_by"] == "DeepSeek"
2126+
2127+
def test_checkpoint_schema_exposes_added_by(self):
2128+
"""``added_by`` is declared in the checkpoint tool schema so the
2129+
dispatch whitelist admits it instead of rejecting it as unknown."""
2130+
from mempalace import mcp_server
2131+
2132+
props = mcp_server.TOOLS["mempalace_checkpoint"]["input_schema"]["properties"]
2133+
assert "added_by" in props
2134+
assert props["added_by"]["type"] == "string"
2135+
2136+
def test_checkpoint_blank_or_invalid_added_by_defers_to_diary(self, monkeypatch):
2137+
"""A blank, whitespace-only, non-string, or None explicit ``added_by``
2138+
counts as unspecified, so it defers to the diary ``agent_name`` rather
2139+
than masking it; with no usable diary name it falls to ``checkpoint``."""
2140+
from mempalace import mcp_server
2141+
2142+
monkeypatch.setattr(
2143+
mcp_server, "tool_check_duplicate", lambda *a, **k: {"is_duplicate": False}
2144+
)
2145+
monkeypatch.setattr(mcp_server, "tool_diary_write", lambda **k: {"success": True})
2146+
seen = []
2147+
2148+
def _add(**kwargs):
2149+
seen.append(kwargs["added_by"])
2150+
return {"success": True, "drawer_id": "d1"}
2151+
2152+
monkeypatch.setattr(mcp_server, "tool_add_drawer", _add)
2153+
2154+
diary = {"agent_name": "deepseek", "entry": "SESSION|x|star"}
2155+
for bad in ("", " ", 123, None):
2156+
mcp_server.tool_checkpoint(
2157+
items=[{"wing": "w", "room": "r", "content": f"c{bad!r}"}],
2158+
diary=diary,
2159+
added_by=bad,
2160+
)
2161+
# Every unusable explicit value defers to the diary agent.
2162+
assert seen == ["deepseek", "deepseek", "deepseek", "deepseek"]
2163+
2164+
# Blank explicit AND a blank diary name -> the legacy label.
2165+
seen.clear()
2166+
mcp_server.tool_checkpoint(
2167+
items=[{"wing": "w", "room": "r", "content": "z"}],
2168+
diary={"agent_name": " ", "entry": "SESSION|y|star"},
2169+
added_by="",
2170+
)
2171+
assert seen == ["checkpoint"]
2172+
2173+
def test_checkpoint_added_by_uniform_across_items(self, monkeypatch):
2174+
"""All items in one checkpoint share a single resolved author (a
2175+
checkpoint is one agent's session save; attribution is resolved once)."""
2176+
from mempalace import mcp_server
2177+
2178+
monkeypatch.setattr(
2179+
mcp_server, "tool_check_duplicate", lambda *a, **k: {"is_duplicate": False}
2180+
)
2181+
monkeypatch.setattr(mcp_server, "tool_diary_write", lambda **k: {"success": True})
2182+
seen = []
2183+
2184+
def _add(**kwargs):
2185+
seen.append(kwargs["added_by"])
2186+
return {"success": True, "drawer_id": kwargs["content"]}
2187+
2188+
monkeypatch.setattr(mcp_server, "tool_add_drawer", _add)
2189+
2190+
mcp_server.tool_checkpoint(
2191+
items=[
2192+
{"wing": "w", "room": "r", "content": "one"},
2193+
{"wing": "w", "room": "r", "content": "two"},
2194+
],
2195+
diary={"agent_name": "DeepSeek", "entry": "SESSION|q|star"},
2196+
)
2197+
assert seen == ["DeepSeek", "DeepSeek"]
2198+
20002199
def test_get_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
20012200
_patch_mcp_server(monkeypatch, config, kg)
20022201
from mempalace.mcp_server import tool_get_drawer

website/reference/mcp-tools.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ Save a whole session in one call. Semantic-dedups each item, files the non-dupli
111111
| `items` | array | **Yes** | Verbatim items to file. Each is `{ wing, room, content }` |
112112
| `diary` | object | No | Diary entry written after filing: `{ agent_name, entry, topic?, wing? }` (`entry` is AAAK-format) |
113113
| `dedup_threshold` | number | No | Similarity threshold 0–1 for the per-item dedup check (default 0.9) |
114+
| `added_by` | string | No | Who is filing these drawers. An explicit value takes precedence; otherwise the diary `agent_name`, else `checkpoint` |
114115

115116
**Returns:** `{ added: [...], duplicates: [...], errors: [...], diary? }`
116117

0 commit comments

Comments
 (0)