84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236 | class MemoryStoreTool(Node):
"""The agent's memory: list, read, write, edit and delete notes that outlive the conversation.
One tool with actions rather than several tools, following ``SkillsTool``. Its backend is a
``MemoryStore`` — deliberately not a ``FileStore``, so memory never shares a path namespace or a
tool with the agent's working files.
"""
group: Literal[NodeGroup.TOOLS] = NodeGroup.TOOLS
action_type: ActionType = ActionType.FILE_OPERATION
# Agent machinery, not an outside-world integration: never swept in by MockPolicy.ALL.
is_mockable: ClassVar[bool] = False
name: str = "memory-store"
description: str = DESCRIPTION
backend: MemoryStore = Field(..., description="Store holding the agent's memories.")
write_enabled: bool = Field(default=True, description="Whether the agent may change memories.")
user_id: str | None = Field(
default=None,
description="End user whose memories these are, bound at construction. An agent rebuilds "
"the tool per run from the run's user_id, so callers only pass it to `agent.run(...)`.",
)
model_config = ConfigDict(arbitrary_types_allowed=True)
input_schema: ClassVar[type[MemoryStoreToolInputSchema]] = MemoryStoreToolInputSchema
@model_validator(mode="after")
def describe_memories(self):
"""Tell the model what each memory holds, straight from the stores themselves.
The backend already knows — a composite composes its routes' descriptions — so there is no
parallel structure to keep in sync and no need to know whether the backend is composite.
"""
listing = render_namespaces(self.backend.describe_namespaces())
if listing and "Your memories:" not in self.description:
self.description = f"{self.description}\n\nYour memories:\n{listing}"
if not self.write_enabled and READ_ONLY_NOTE not in self.description:
self.description += READ_ONLY_NOTE
return self
@property
def to_dict_exclude_params(self):
return super().to_dict_exclude_params | {"backend": True}
def execute(
self, input_data: MemoryStoreToolInputSchema, config: RunnableConfig | None = None, **kwargs
) -> dict[str, Any]:
config = ensure_config(config)
check_cancellation(config)
self.run_on_node_execute_run(config.callbacks, **kwargs)
action = input_data.action
if action in MUTATING_ACTIONS and not self.write_enabled:
raise ToolExecutionException(
f"This memory is read-only; '{action.value}' is not available. You can list and read it.",
recoverable=True,
)
try:
user_id = self.user_id
if action == MemoryStoreAction.LIST:
return self._list(input_data.path or "", user_id)
if action == MemoryStoreAction.READ:
return self._read(input_data.path, user_id)
if action == MemoryStoreAction.WRITE:
return self._write(input_data.path, input_data.content, user_id)
if action == MemoryStoreAction.EDIT:
return self._edit(input_data, user_id)
return self._delete(input_data.path, user_id)
except ToolExecutionException:
raise
except MemoryStoreError as e:
# Includes "not in any memory", which names the valid prefixes: worth relaying verbatim.
raise ToolExecutionException(str(e), recoverable=True) from e
except Exception as e:
logger.error(f"Tool {self.name} - {self.id}: {action.value} failed. Error: {e}")
raise ToolExecutionException(
f"Memory {action.value} failed: {e}. Please analyze the error and take appropriate action.",
recoverable=True,
) from e
def _list(self, prefix: str, user_id: str | None = None) -> dict[str, Any]:
"""List memories, grouped under what each one holds.
The grouping matters: a bare path like ``team/naming.md`` gives the model nothing to judge
relevance by, so it lists and then reads nothing. Naming the memory the path belongs to puts
that judgement where the decision is made, the same way the tool description does.
"""
entries = self.backend.list(prefix, user_id)
if not entries:
where = f" under '{prefix}'" if prefix else ""
return {"content": f"No memories{where} yet."}
namespaces = self.backend.describe_namespaces()
grouped: dict[str, list[str]] = {}
for entry in entries:
owner = max(
(ns for ns in namespaces if ns and entry.path.startswith(ns)),
key=len,
default="",
)
grouped.setdefault(owner, []).append(f" - {entry.path} ({entry.size} chars)")
sections = []
for owner, lines in grouped.items():
heading = f"{owner} — {namespaces[owner]}" if owner and namespaces.get(owner) else (owner or "Memories")
sections.append(f"{heading}\n" + "\n".join(lines))
return {"content": "\n".join(sections), "paths": [entry.path for entry in entries]}
def _read(self, path: str, user_id: str | None = None) -> dict[str, Any]:
try:
return {"content": self.backend.read(path, user_id)}
except MemoryNotFoundError:
raise ToolExecutionException(
f"No memory at '{path}'. Use action 'list' to see what exists.", recoverable=True
) from None
def _write(self, path: str, content: str, user_id: str | None = None) -> dict[str, Any]:
entry = self.backend.write(path, content, user_id)
return {"content": f"Remembered in '{entry.path}'."}
def _edit(self, input_data: MemoryStoreToolInputSchema, user_id: str | None = None) -> dict[str, Any]:
path, find, replace = input_data.path, input_data.find, input_data.replace
try:
current = self.backend.read(path, user_id)
except MemoryNotFoundError:
raise ToolExecutionException(
f"No memory at '{path}' to edit. Use action 'write' to create it.", recoverable=True
) from None
# Candidate positions, not str.count: overlapping matches ("---" in "-----") are what
# make a find string ambiguous, and counting only non-overlapping ones would let an
# ambiguous edit through and persist it.
positions = find_positions(current, find)
if not positions:
raise ToolExecutionException(
f"'{find}' does not appear in '{path}', so nothing was changed.", recoverable=True
)
if len(positions) > 1 and not input_data.replace_all:
raise ToolExecutionException(
f"'{find}' appears {len(positions)} times in '{path}'. Include more surrounding text to "
f"make it unique, or set 'replace_all' to change every occurrence.",
recoverable=True,
)
updated = current.replace(find, replace) if input_data.replace_all else current.replace(find, replace, 1)
self.backend.write(path, updated, user_id)
# How many a replacement actually consumes is str.count's question, not the candidates'.
changed = current.count(find) if input_data.replace_all else 1
return {"content": f"Updated '{path}' ({changed} replacement{'s' if changed > 1 else ''})."}
def _delete(self, path: str, user_id: str | None = None) -> dict[str, Any]:
deleted = self.backend.delete(path, user_id)
return {"content": f"Deleted '{path}'." if deleted else f"There was no memory at '{path}'."}
|