Make bundled memory behavior reliable

Carry delayed recall results across prompt objects without crossing profile, project, or memory-store scope, and reuse one query embedding across the memory and solution searches. Expose every bundled memory operation to native Responses models and cover the recall, vector-search, and prompt contracts with regressions.

Alessandro committed Aug 23, 2026 at 14:15 UTC baa97dcb8778df5f21d96371b553af06590d6f8c
8 files changed +401 -42
plugins/_memory/AGENTS.md
+3
@@ -16,7 +16,10 @@
16
17 - Keep memory scoped by configured subdirectory/context.
18 - Preserve embedding metadata needed to rebuild indexes safely.
19 +- Delayed recall results must survive `LoopData` replacement, stay scoped to the originating memory/profile/project, and be consumed once by the next prompt; do not persist live task objects into chat JSON.
20 - `memory_load` accepts numeric `threshold` and `limit` values as native numbers or numeric strings and coerces them before vector search.
21 +- Grouped tool-prompt declarations must start with `arg` or `args` so native Responses models receive every memory tool.
22 +- Auto-recall embeds each prepared query once and reuses that vector for the memory and solution filters; manual `memory_load` keeps the ordinary text-query path.
23 - Avoid storing transient action-history noise as durable memory.
24
25 ## Work Guidance
plugins/_memory/extensions/python/message_loop_prompts_after/_50_recall_memories.py
+114 -32
@@ -1,18 +1,37 @@
1 import asyncio
2 from helpers.extension import Extension
3 from agent import LoopData
4 -from helpers import dirty_json, errors, log, plugins
4 +from helpers import cache, dirty_json, errors, log, plugins
5
6 # Direct import - this extension lives inside the memory plugin
7 -from plugins._memory.helpers.memory import Memory
7 +from plugins._memory.helpers.memory import Memory, get_agent_memory_subdir
8 from plugins._memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
9
10
11 DATA_NAME_TASK = "_recall_memories_task"
12 DATA_NAME_ITER = "_recall_memories_iter"
13 +DATA_NAME_LOOP = "_recall_memories_loop"
14 +DATA_NAME_RESULT = "_recall_memories_result"
15 +DATA_NAME_RESULT_SCOPE = "_recall_memories_result_scope"
16 SEARCH_TIMEOUT = 30
17
18
19 +def apply_recall_result(loop_data: LoopData, result: dict | None):
20 + extras = loop_data.extras_persistent
21 + extras.pop("memories", None)
22 + extras.pop("solutions", None)
23 + if not isinstance(result, dict):
24 + return
25 + for key in ("memories", "solutions"):
26 + if result.get(key):
27 + extras[key] = result[key]
28 +
29 +
30 +def get_recall_scope(agent):
31 + memory_subdir = get_agent_memory_subdir(agent)
32 + return cache.determine_cache_key(agent, memory_subdir)
33 +
34 +
35 class RecallMemories(Extension):
36
37 # INTERVAL = 3
@@ -35,43 +54,97 @@ class RecallMemories(Extension):
54 if not set["memory_recall_enabled"]:
55 return None
56
57 + recall_scope = get_recall_scope(self.agent)
58 + cached_result = self.agent.get_data(DATA_NAME_RESULT)
59 + if cached_result is not None:
60 + if self.agent.get_data(DATA_NAME_RESULT_SCOPE) == recall_scope:
61 + apply_recall_result(loop_data, cached_result)
62 + else:
63 + cached_result = None
64 + self.agent.set_data(DATA_NAME_RESULT, None)
65 + self.agent.set_data(DATA_NAME_RESULT_SCOPE, None)
66 +
67 # every X iterations (or the first one) recall memories
39 - if loop_data.iteration % set["memory_recall_interval"] == 0:
68 + if loop_data.iteration % set["memory_recall_interval"] != 0:
69 + return
70
41 - # show util message right away
42 - log_item = self.agent.context.log.log(
43 - type="util",
44 - heading="Searching memories...",
45 - )
71 + previous_task = self.agent.get_data(DATA_NAME_TASK)
72 + previous_loop = self.agent.get_data(DATA_NAME_LOOP)
73 + if previous_task and not previous_task.done():
74 + if previous_loop is loop_data:
75 + return
76 + previous_task.cancel()
77
47 - task = asyncio.create_task(
48 - asyncio.wait_for(
49 - self.search_memories(loop_data=loop_data, log_item=log_item, **kwargs),
50 - timeout=SEARCH_TIMEOUT,
51 - )
78 + if cached_result is None:
79 + apply_recall_result(loop_data, {})
80 +
81 + # show util message right away
82 + log_item = self.agent.context.log.log(
83 + type="util",
84 + heading="Searching memories...",
85 + )
86 +
87 + task = asyncio.create_task(
88 + self.search_and_cache(
89 + loop_data=loop_data,
90 + log_item=log_item,
91 + recall_scope=recall_scope,
92 + **kwargs,
93 )
53 - else:
54 - task = None
94 + )
95
96 # set to agent to be able to wait for it
97 self.agent.set_data(DATA_NAME_TASK, task)
98 self.agent.set_data(DATA_NAME_ITER, loop_data.iteration)
99 + self.agent.set_data(DATA_NAME_LOOP, loop_data)
100
60 - async def search_memories(self, log_item: log.LogItem, loop_data: LoopData, **kwargs):
101 + async def search_and_cache(
102 + self, log_item: log.LogItem, loop_data: LoopData, recall_scope, **kwargs
103 + ):
104 if not self.agent:
62 - return
63 -
64 - # cleanup
65 - extras = loop_data.extras_persistent
66 - if "memories" in extras:
67 - del extras["memories"]
68 - if "solutions" in extras:
69 - del extras["solutions"]
70 -
105 + return {}
106 + try:
107 + result = await asyncio.wait_for(
108 + self.search_memories(
109 + loop_data=loop_data,
110 + log_item=log_item,
111 + memory_subdir=recall_scope[-1],
112 + **kwargs,
113 + ),
114 + timeout=SEARCH_TIMEOUT,
115 + )
116 + except TimeoutError:
117 + log_item.update(heading="Memory recall timed out")
118 + result = {}
119 + except Exception as e:
120 + self.agent.context.log.log(
121 + type="warning",
122 + heading="Memory recall error",
123 + content=errors.format_error(e),
124 + )
125 + result = {}
126 + if get_recall_scope(self.agent) == recall_scope:
127 + self.agent.set_data(DATA_NAME_RESULT, result)
128 + self.agent.set_data(DATA_NAME_RESULT_SCOPE, recall_scope)
129 + else:
130 + result = {}
131 + self.agent.set_data(DATA_NAME_RESULT, None)
132 + self.agent.set_data(DATA_NAME_RESULT_SCOPE, None)
133 + return result
134 +
135 + async def search_memories(
136 + self,
137 + log_item: log.LogItem,
138 + loop_data: LoopData,
139 + memory_subdir: str = "",
140 + **kwargs,
141 + ):
142 + if not self.agent:
143 + return {}
144
145 set = plugins.get_plugin_config("_memory", self.agent)
146 if not set:
74 - return None
147 + return {}
148 # try:
149
150 # get system message and chat history for util llm
@@ -113,7 +186,7 @@ class RecallMemories(Extension):
186 log_item.update(
187 heading="Failed to generate memory query",
188 )
116 - return
189 + return {}
190
191 # otherwise use the message and history as query
192 else:
@@ -124,10 +197,16 @@ class RecallMemories(Extension):
197 log_item.update(
198 query="No relevant memory query generated, skipping search",
199 )
127 - return
200 + return {}
201
202 # get memory database
203 + if memory_subdir and get_agent_memory_subdir(self.agent) != memory_subdir:
204 + return {}
205 db = await Memory.get(self.agent)
206 + if memory_subdir and db.memory_subdir != memory_subdir:
207 + return {}
208 +
209 + embedding = await db.embed_query(query)
210
211 # search for general memories and fragments
212 memories = await db.search_similarity_threshold(
@@ -135,6 +214,7 @@ class RecallMemories(Extension):
214 limit=set["memory_recall_memories_max_search"],
215 threshold=set["memory_recall_similarity_threshold"],
216 filter=f"area == '{Memory.Area.MAIN.value}' or area == '{Memory.Area.FRAGMENTS.value}'", # exclude solutions
217 + embedding=embedding,
218 )
219
220 # search for solutions
@@ -143,13 +223,14 @@ class RecallMemories(Extension):
223 limit=set["memory_recall_solutions_max_search"],
224 threshold=set["memory_recall_similarity_threshold"],
225 filter=f"area == '{Memory.Area.SOLUTIONS.value}'", # exclude solutions
226 + embedding=embedding,
227 )
228
229 if not memories and not solutions:
230 log_item.update(
231 heading="No memories or solutions found",
232 )
152 - return
233 + return {}
234
235 # if post filtering is enabled
236 if set["memory_recall_post_filter"]:
@@ -218,12 +299,13 @@ class RecallMemories(Extension):
299 if solutions_txt:
300 log_item.update(solutions=solutions_txt)
301
221 - # place to prompt
302 + result = {}
303 if memories_txt:
223 - extras["memories"] = self.agent.parse_prompt(
304 + result["memories"] = self.agent.parse_prompt(
305 "agent.system.memories.md", memories=memories_txt
306 )
307 if solutions_txt:
227 - extras["solutions"] = self.agent.parse_prompt(
308 + result["solutions"] = self.agent.parse_prompt(
309 "agent.system.solutions.md", solutions=solutions_txt
310 )
311 + return result
plugins/_memory/extensions/python/message_loop_prompts_after/_91_recall_wait.py
+19 -4
@@ -1,6 +1,13 @@
1 from helpers.extension import Extension
2 from agent import LoopData
3 -from plugins._memory.extensions.python.message_loop_prompts_after._50_recall_memories import DATA_NAME_TASK as DATA_NAME_TASK_MEMORIES, DATA_NAME_ITER as DATA_NAME_ITER_MEMORIES
3 +from plugins._memory.extensions.python.message_loop_prompts_after._50_recall_memories import (
4 + DATA_NAME_ITER as DATA_NAME_ITER_MEMORIES,
5 + DATA_NAME_RESULT as DATA_NAME_RESULT_MEMORIES,
6 + DATA_NAME_RESULT_SCOPE as DATA_NAME_RESULT_SCOPE_MEMORIES,
7 + DATA_NAME_TASK as DATA_NAME_TASK_MEMORIES,
8 + apply_recall_result,
9 + get_recall_scope,
10 +)
11 from helpers import plugins
12
13 class RecallWait(Extension):
@@ -16,15 +23,23 @@ class RecallWait(Extension):
23 task = self.agent.get_data(DATA_NAME_TASK_MEMORIES)
24 iter = self.agent.get_data(DATA_NAME_ITER_MEMORIES) or 0
25
19 - if task and not task.done():
26 + if task:
27
28 # if memory recall is set to delayed mode, do not await on the iteration it was called
29 if set["memory_recall_delayed"]:
23 - if iter == loop_data.iteration:
30 + if iter == loop_data.iteration and not task.done():
31 # insert info about delayed memory to extras
32 delay_text = self.agent.read_prompt("memory.recall_delay_msg.md")
33 loop_data.extras_temporary["memory_recall_delayed"] = delay_text
34 return
35
36 # otherwise await the task
30 - await task
37 + result = await task
38 + if self.agent.get_data(DATA_NAME_RESULT_SCOPE_MEMORIES) == get_recall_scope(
39 + self.agent
40 + ):
41 + apply_recall_result(loop_data, result)
42 + else:
43 + apply_recall_result(loop_data, {})
44 + self.agent.set_data(DATA_NAME_RESULT_MEMORIES, None)
45 + self.agent.set_data(DATA_NAME_RESULT_SCOPE_MEMORIES, None)
plugins/_memory/helpers/memory.py
+21 -1
@@ -337,11 +337,31 @@ class Memory:
337 def get_document_by_id(self, id: str) -> Document | None:
338 return self.db.get_by_ids(id)[0]
339
340 + async def embed_query(self, query: str) -> list[float]:
341 + return await self.db.embedding_function.aembed_query(query)
342 +
343 async def search_similarity_threshold(
341 - self, query: str, limit: int, threshold: float, filter: str = ""
344 + self,
345 + query: str,
346 + limit: int,
347 + threshold: float,
348 + filter: str = "",
349 + embedding: list[float] | None = None,
350 ):
351 comparator = Memory._get_comparator(filter) if filter else None
352
353 + if embedding is not None:
354 + docs_and_scores = await self.db.asimilarity_search_with_score_by_vector(
355 + embedding,
356 + k=limit,
357 + filter=comparator,
358 + )
359 + return [
360 + doc
361 + for doc, score in docs_and_scores
362 + if Memory._cosine_normalizer(score) >= threshold
363 + ]
364 +
365 return await self.db.asearch(
366 query,
367 search_type="similarity_score_threshold",
plugins/_memory/prompts/agent.system.tool.memory.md
+4 -5
@@ -1,10 +1,9 @@
1 ## memory tools
2 use when durable recall or storage is useful
3 -- `memory_load`: search stored memories by meaning and metadata
4 -- `memory_save`: store durable information for future recall
5 -- `memory_delete`: delete memories by exact ID
6 -- `memory_forget`: find and remove memories matching a query
7 -args: load uses `query`, optional `threshold`, `limit`, `filter`; save uses `text`, optional `area` and metadata; delete uses comma-separated `ids`; forget uses `query`, optional `threshold`, `filter`
3 +- `memory_load`: args `query`, optional `threshold`, `limit`, `filter`; search by meaning and metadata
4 +- `memory_save`: args `text`, optional `area` and metadata; store durable information; returns a memory ID on success
5 +- `memory_delete`: arg comma-separated `ids`; delete memories by exact ID
6 +- `memory_forget`: args `query`, optional `threshold`, `filter`; find and remove matching memories
7
8 notes:
9 - `threshold` is similarity from `0` to `1`
tests/test_memory_cleanup.py
+56
@@ -36,6 +36,32 @@ class FakeFaiss:
36 return [self.docs[doc_id] for doc_id in ids if doc_id in self.docs]
37
38
39 +class FakeEmbeddings:
40 + def __init__(self):
41 + self.queries: list[str] = []
42 +
43 + async def aembed_query(self, query: str):
44 + self.queries.append(query)
45 + return [0.25, 0.75]
46 +
47 +
48 +class FakeVectorSearch:
49 + def __init__(self):
50 + self.embedding_function = FakeEmbeddings()
51 + self.docs = [
52 + (Document(page_content="main", metadata={"area": "main"}), 0.8),
53 + (Document(page_content="solution", metadata={"area": "solutions"}), 0.7),
54 + (Document(page_content="weak", metadata={"area": "main"}), 0.2),
55 + ]
56 + self.embeddings: list[list[float]] = []
57 +
58 + async def asimilarity_search_with_score_by_vector(
59 + self, embedding, *, k, filter
60 + ):
61 + self.embeddings.append(embedding)
62 + return [(doc, score) for doc, score in self.docs if filter(doc.metadata)][:k]
63 +
64 +
65 def test_memory_forget_removes_exact_matches_and_derived_fragments():
66 main = Document(
67 page_content="User currently prefers memory cleanup token banana-397.",
@@ -91,3 +117,33 @@ def test_memory_delete_cascades_even_when_original_id_is_already_missing():
117 assert [doc.metadata["id"] for doc in removed] == ["replacement-1"]
118 assert fake_db.deleted == ["replacement-1"]
119 assert fake_db.docs == {}
120 +
121 +
122 +def test_memory_reuses_one_query_embedding_across_filtered_searches():
123 + fake_db = FakeVectorSearch()
124 + memory = Memory(fake_db, memory_subdir="test")
125 +
126 + async def search():
127 + embedding = await memory.embed_query("shared recall query")
128 + memories = await memory.search_similarity_threshold(
129 + query="shared recall query",
130 + limit=12,
131 + threshold=0.7,
132 + filter="area == 'main'",
133 + embedding=embedding,
134 + )
135 + solutions = await memory.search_similarity_threshold(
136 + query="shared recall query",
137 + limit=8,
138 + threshold=0.7,
139 + filter="area == 'solutions'",
140 + embedding=embedding,
141 + )
142 + return memories, solutions
143 +
144 + memories, solutions = asyncio.run(search())
145 +
146 + assert fake_db.embedding_function.queries == ["shared recall query"]
147 + assert fake_db.embeddings == [[0.25, 0.75], [0.25, 0.75]]
148 + assert [doc.page_content for doc in memories] == ["main"]
149 + assert [doc.page_content for doc in solutions] == ["solution"]
tests/test_memory_recall.py new
+171
@@ -0,0 +1,171 @@
1 +import asyncio
2 +import sys
3 +from pathlib import Path
4 +from types import SimpleNamespace
5 +
6 +import pytest
7 +
8 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
9 +if str(PROJECT_ROOT) not in sys.path:
10 + sys.path.insert(0, str(PROJECT_ROOT))
11 +
12 +from agent import LoopData
13 +from plugins._memory.extensions.python.message_loop_prompts_after import (
14 + _50_recall_memories as recall_module,
15 + _91_recall_wait as wait_module,
16 +)
17 +
18 +
19 +class _LogItem:
20 + def update(self, **_kwargs):
21 + pass
22 +
23 +
24 +class _Agent:
25 + def __init__(self):
26 + self.data = {}
27 + self.project = "project-a"
28 + self.config = SimpleNamespace(profile="agent0")
29 + self.context = SimpleNamespace(
30 + log=SimpleNamespace(log=lambda **_kwargs: _LogItem()),
31 + get_data=lambda *_args, **_kwargs: self.project,
32 + )
33 +
34 + def get_data(self, key):
35 + return self.data.get(key)
36 +
37 + def set_data(self, key, value):
38 + self.data[key] = value
39 +
40 + def read_prompt(self, _name):
41 + return "Recall is running in the background."
42 +
43 +
44 +def _settings():
45 + return {
46 + "memory_recall_enabled": True,
47 + "memory_recall_delayed": True,
48 + "memory_recall_interval": 1,
49 + }
50 +
51 +
52 +@pytest.mark.asyncio
53 +async def test_delayed_recall_result_reaches_the_next_monologue(monkeypatch):
54 + agent = _Agent()
55 + recall = recall_module.RecallMemories(agent=agent)
56 + monkeypatch.setattr(
57 + recall_module.plugins, "get_plugin_config", lambda *_args: _settings()
58 + )
59 +
60 + async def search_memories(**_kwargs):
61 + return {"memories": "recalled context"}
62 +
63 + monkeypatch.setattr(recall, "search_memories", search_memories)
64 +
65 + first_loop = LoopData()
66 + first_loop.iteration = 0
67 + await recall.execute(loop_data=first_loop)
68 + await agent.get_data(recall_module.DATA_NAME_TASK)
69 +
70 + next_loop = LoopData()
71 + next_loop.iteration = 0
72 + await recall.execute(loop_data=next_loop)
73 + await agent.get_data(recall_module.DATA_NAME_TASK)
74 +
75 + assert next_loop.extras_persistent["memories"] == "recalled context"
76 +
77 +
78 +@pytest.mark.asyncio
79 +async def test_delayed_recall_task_survives_the_next_internal_iteration(monkeypatch):
80 + agent = _Agent()
81 + recall = recall_module.RecallMemories(agent=agent)
82 + wait = wait_module.RecallWait(agent=agent)
83 + settings = _settings()
84 + monkeypatch.setattr(
85 + recall_module.plugins, "get_plugin_config", lambda *_args: settings
86 + )
87 +
88 + release = asyncio.Event()
89 +
90 + async def search_memories(**_kwargs):
91 + await release.wait()
92 + return {"solutions": "recalled solution"}
93 +
94 + monkeypatch.setattr(recall, "search_memories", search_memories)
95 +
96 + loop_data = LoopData()
97 + loop_data.iteration = 0
98 + await recall.execute(loop_data=loop_data)
99 + first_task = agent.get_data(recall_module.DATA_NAME_TASK)
100 + await wait.execute(loop_data=loop_data)
101 + assert "memory_recall_delayed" in loop_data.extras_temporary
102 +
103 + loop_data.iteration = 1
104 + await recall.execute(loop_data=loop_data)
105 + next_task = agent.get_data(recall_module.DATA_NAME_TASK)
106 + release.set()
107 + await first_task
108 + if next_task is not first_task:
109 + await next_task
110 +
111 + assert next_task is first_task
112 + await wait.execute(loop_data=loop_data)
113 + assert loop_data.extras_persistent["solutions"] == "recalled solution"
114 +
115 +
116 +@pytest.mark.asyncio
117 +async def test_completed_blocking_recall_is_applied(monkeypatch):
118 + agent = _Agent()
119 + recall = recall_module.RecallMemories(agent=agent)
120 + wait = wait_module.RecallWait(agent=agent)
121 + settings = {**_settings(), "memory_recall_delayed": False}
122 + monkeypatch.setattr(
123 + recall_module.plugins, "get_plugin_config", lambda *_args: settings
124 + )
125 +
126 + async def search_memories(**_kwargs):
127 + return {"memories": "ready before wait"}
128 +
129 + monkeypatch.setattr(recall, "search_memories", search_memories)
130 +
131 + loop_data = LoopData()
132 + loop_data.iteration = 0
133 + await recall.execute(loop_data=loop_data)
134 + await agent.get_data(recall_module.DATA_NAME_TASK)
135 + await wait.execute(loop_data=loop_data)
136 +
137 + assert loop_data.extras_persistent["memories"] == "ready before wait"
138 +
139 +
140 +@pytest.mark.asyncio
141 +async def test_delayed_recall_result_does_not_cross_profile_or_project(monkeypatch):
142 + agent = _Agent()
143 + recall = recall_module.RecallMemories(agent=agent)
144 + monkeypatch.setattr(
145 + recall_module.plugins, "get_plugin_config", lambda *_args: _settings()
146 + )
147 +
148 + release = asyncio.Event()
149 +
150 + async def search_memories(**_kwargs):
151 + await release.wait()
152 + return {"memories": "project-a memory"}
153 +
154 + monkeypatch.setattr(recall, "search_memories", search_memories)
155 +
156 + first_loop = LoopData()
157 + first_loop.iteration = 0
158 + await recall.execute(loop_data=first_loop)
159 + first_task = agent.get_data(recall_module.DATA_NAME_TASK)
160 +
161 + agent.project = "project-b"
162 + agent.config.profile = "developer"
163 + release.set()
164 + await first_task
165 +
166 + next_loop = LoopData()
167 + next_loop.iteration = 0
168 + await recall.execute(loop_data=next_loop)
169 +
170 + assert "memories" not in next_loop.extras_persistent
171 + await agent.get_data(recall_module.DATA_NAME_TASK)
tests/test_responses_tools.py
+13
@@ -292,3 +292,16 @@ durable memory operations
292 assert responses_tools._tool_names_from_prompt(
293 prompt, fallback="memory"
294 ) == ["memory_load"]
295 +
296 +
297 +def test_bundled_memory_prompt_exposes_every_memory_tool():
298 + prompt = (
299 + PROJECT_ROOT / "plugins/_memory/prompts/agent.system.tool.memory.md"
300 + ).read_text(encoding="utf-8")
301 +
302 + assert responses_tools._tool_names_from_prompt(prompt, fallback="memory") == [
303 + "memory_load",
304 + "memory_save",
305 + "memory_delete",
306 + "memory_forget",
307 + ]