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
+ ]