EvoScientist 0.0.1.dev1__py3-none-any.whl → 0.0.1.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- EvoScientist/EvoScientist.py +45 -13
- EvoScientist/cli.py +237 -1363
- EvoScientist/memory.py +715 -0
- EvoScientist/middleware.py +49 -4
- EvoScientist/paths.py +45 -0
- EvoScientist/skills_manager.py +392 -0
- EvoScientist/stream/__init__.py +25 -0
- EvoScientist/stream/display.py +604 -0
- EvoScientist/stream/events.py +415 -0
- EvoScientist/stream/state.py +343 -0
- EvoScientist/stream/utils.py +23 -16
- EvoScientist/tools.py +64 -0
- {evoscientist-0.0.1.dev1.dist-info → evoscientist-0.0.1.dev3.dist-info}/METADATA +106 -7
- {evoscientist-0.0.1.dev1.dist-info → evoscientist-0.0.1.dev3.dist-info}/RECORD +18 -12
- evoscientist-0.0.1.dev3.dist-info/entry_points.txt +5 -0
- evoscientist-0.0.1.dev1.dist-info/entry_points.txt +0 -2
- {evoscientist-0.0.1.dev1.dist-info → evoscientist-0.0.1.dev3.dist-info}/WHEEL +0 -0
- {evoscientist-0.0.1.dev1.dist-info → evoscientist-0.0.1.dev3.dist-info}/licenses/LICENSE +0 -0
- {evoscientist-0.0.1.dev1.dist-info → evoscientist-0.0.1.dev3.dist-info}/top_level.txt +0 -0
EvoScientist/memory.py
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
"""EvoScientist Memory Middleware.
|
|
2
|
+
|
|
3
|
+
Automatically extracts and persists long-term memory (user profile, research
|
|
4
|
+
preferences, experiment conclusions) from conversations.
|
|
5
|
+
|
|
6
|
+
Two mechanisms:
|
|
7
|
+
1. **Injection** (every LLM call): Reads ``/memory/MEMORY.md`` and appends it
|
|
8
|
+
to the system prompt so the agent always has context.
|
|
9
|
+
2. **Extraction** (threshold-triggered): When the conversation exceeds a
|
|
10
|
+
configurable message count, uses an LLM call to pull out structured facts
|
|
11
|
+
and merges them into the appropriate MEMORY.md sections.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from EvoScientist.memory import EvoMemoryMiddleware
|
|
17
|
+
|
|
18
|
+
middleware = EvoMemoryMiddleware(
|
|
19
|
+
backend=my_backend, # or backend factory
|
|
20
|
+
memory_path="/memory/MEMORY.md",
|
|
21
|
+
extraction_model=chat_model,
|
|
22
|
+
trigger=("messages", 20),
|
|
23
|
+
)
|
|
24
|
+
agent = create_deep_agent(middleware=[middleware, ...])
|
|
25
|
+
```
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import re
|
|
32
|
+
from collections.abc import Awaitable, Callable
|
|
33
|
+
from contextvars import ContextVar
|
|
34
|
+
from typing import TYPE_CHECKING, Any, Annotated, NotRequired, cast
|
|
35
|
+
|
|
36
|
+
from langchain.agents.middleware.types import (
|
|
37
|
+
AgentMiddleware,
|
|
38
|
+
AgentState,
|
|
39
|
+
ModelRequest,
|
|
40
|
+
ModelResponse,
|
|
41
|
+
PrivateStateAttr,
|
|
42
|
+
)
|
|
43
|
+
from langchain.tools import ToolRuntime
|
|
44
|
+
from langchain_core.messages import AnyMessage, HumanMessage, AIMessage
|
|
45
|
+
from langchain_core.runnables.config import RunnableConfig
|
|
46
|
+
from langgraph.runtime import Runtime
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from langchain.chat_models import BaseChatModel
|
|
50
|
+
from deepagents.backends.protocol import BACKEND_TYPES, BackendProtocol
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
_CURRENT_MEMORY: ContextVar[str] = ContextVar("evo_memory_current", default="")
|
|
55
|
+
_STATE_MEMORY_KEY = "evo_memory_content"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EvoMemoryState(AgentState):
|
|
59
|
+
"""State schema for EvoMemoryMiddleware."""
|
|
60
|
+
|
|
61
|
+
evo_memory_content: NotRequired[Annotated[str, PrivateStateAttr]]
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Extraction prompt – sent to a (cheap) LLM to pull structured facts
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
EXTRACTION_PROMPT = """\
|
|
68
|
+
You are a memory extraction assistant for a scientific experiment agent called EvoScientist.
|
|
69
|
+
|
|
70
|
+
Analyze the following conversation and extract any NEW information that should be
|
|
71
|
+
remembered long-term. Only extract facts that are **not already present** in the
|
|
72
|
+
current memory shown below.
|
|
73
|
+
|
|
74
|
+
<current_memory>
|
|
75
|
+
{current_memory}
|
|
76
|
+
</current_memory>
|
|
77
|
+
|
|
78
|
+
<conversation>
|
|
79
|
+
{conversation}
|
|
80
|
+
</conversation>
|
|
81
|
+
|
|
82
|
+
Return a JSON object with ONLY the keys that have new information to add.
|
|
83
|
+
Omit keys where there is nothing new. Use `null` for unknown values.
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{{
|
|
87
|
+
"user_profile": {{
|
|
88
|
+
"name": "string or null",
|
|
89
|
+
"role": "string or null",
|
|
90
|
+
"institution": "string or null",
|
|
91
|
+
"language": "string or null"
|
|
92
|
+
}},
|
|
93
|
+
"research_preferences": {{
|
|
94
|
+
"primary_domain": "string or null",
|
|
95
|
+
"sub_fields": "string or null",
|
|
96
|
+
"preferred_frameworks": "string or null",
|
|
97
|
+
"preferred_models": "string or null",
|
|
98
|
+
"hardware": "string or null",
|
|
99
|
+
"constraints": "string or null"
|
|
100
|
+
}},
|
|
101
|
+
"experiment_conclusion": {{
|
|
102
|
+
"title": "string – experiment name",
|
|
103
|
+
"question": "string – research question",
|
|
104
|
+
"method": "string – method summary",
|
|
105
|
+
"key_result": "string – primary metric/outcome",
|
|
106
|
+
"conclusion": "string – one-line conclusion",
|
|
107
|
+
"artifacts": "string – report path if any"
|
|
108
|
+
}},
|
|
109
|
+
"learned_preferences": [
|
|
110
|
+
"string – each new preference or habit observed"
|
|
111
|
+
]
|
|
112
|
+
}}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Rules:
|
|
116
|
+
- Only return keys with genuinely new information.
|
|
117
|
+
- If nothing new was found, return an empty JSON object: `{{}}`
|
|
118
|
+
- Do NOT repeat information already in <current_memory>.
|
|
119
|
+
- For experiment_conclusion, only include if a complete experiment was actually run.
|
|
120
|
+
- Be concise. Each value should be a short phrase, not a paragraph.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# System-prompt snippet injected every turn
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
MEMORY_INJECTION_TEMPLATE = """<evo_memory>
|
|
128
|
+
{memory_content}
|
|
129
|
+
</evo_memory>
|
|
130
|
+
|
|
131
|
+
<memory_instructions>
|
|
132
|
+
The above <evo_memory> contains your long-term memory about the user and past experiments.
|
|
133
|
+
Use this to personalize your responses and avoid re-asking known information.
|
|
134
|
+
|
|
135
|
+
**When to update memory:**
|
|
136
|
+
- User shares their name, role, institution, or language
|
|
137
|
+
- User mentions their research domain, preferred frameworks, models, or hardware
|
|
138
|
+
- User explicitly asks you to remember something
|
|
139
|
+
- An experiment completes with notable conclusions
|
|
140
|
+
|
|
141
|
+
**How to update memory:**
|
|
142
|
+
- If /memory/MEMORY.md does not exist yet, use `write_file` to create it
|
|
143
|
+
- If it already exists, use `edit_file` to update specific sections
|
|
144
|
+
- Use this markdown structure:
|
|
145
|
+
|
|
146
|
+
```markdown
|
|
147
|
+
# EvoScientist Memory
|
|
148
|
+
|
|
149
|
+
## User Profile
|
|
150
|
+
- **Name**: ...
|
|
151
|
+
- **Role**: ...
|
|
152
|
+
- **Institution**: ...
|
|
153
|
+
- **Language**: ...
|
|
154
|
+
|
|
155
|
+
## Research Preferences
|
|
156
|
+
- **Primary Domain**: ...
|
|
157
|
+
- **Sub-fields**: ...
|
|
158
|
+
- **Preferred Frameworks**: ...
|
|
159
|
+
- **Preferred Models**: ...
|
|
160
|
+
- **Hardware**: ...
|
|
161
|
+
- **Constraints**: ...
|
|
162
|
+
|
|
163
|
+
## Experiment History
|
|
164
|
+
### [YYYY-MM-DD] Experiment Title
|
|
165
|
+
- **Question**: ...
|
|
166
|
+
- **Key Result**: ...
|
|
167
|
+
- **Conclusion**: ...
|
|
168
|
+
|
|
169
|
+
## Learned Preferences
|
|
170
|
+
- ...
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Priority:** Update memory IMMEDIATELY when the user provides personal or research
|
|
174
|
+
information — before composing your main response.
|
|
175
|
+
</memory_instructions>"""
|
|
176
|
+
|
|
177
|
+
DEFAULT_MEMORY_TEMPLATE = """# EvoScientist Memory
|
|
178
|
+
|
|
179
|
+
## User Profile
|
|
180
|
+
- **Name**: (unknown)
|
|
181
|
+
- **Role**: (unknown)
|
|
182
|
+
- **Institution**: (unknown)
|
|
183
|
+
- **Language**: (unknown)
|
|
184
|
+
|
|
185
|
+
## Research Preferences
|
|
186
|
+
- **Primary Domain**: (unknown)
|
|
187
|
+
- **Sub-fields**: (unknown)
|
|
188
|
+
- **Preferred Frameworks**: (unknown)
|
|
189
|
+
- **Preferred Models**: (unknown)
|
|
190
|
+
- **Hardware**: (unknown)
|
|
191
|
+
- **Constraints**: (unknown)
|
|
192
|
+
|
|
193
|
+
## Experiment History
|
|
194
|
+
(No experiments yet)
|
|
195
|
+
|
|
196
|
+
## Learned Preferences
|
|
197
|
+
- (none yet)
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _get_thread_id(runtime: Runtime) -> str:
|
|
202
|
+
try:
|
|
203
|
+
config = cast("RunnableConfig", getattr(runtime, "config", {}))
|
|
204
|
+
if isinstance(config, dict):
|
|
205
|
+
thread_id = config.get("configurable", {}).get("thread_id")
|
|
206
|
+
if thread_id is not None:
|
|
207
|
+
return str(thread_id)
|
|
208
|
+
except Exception: # noqa: BLE001
|
|
209
|
+
logger.debug("Failed to resolve thread_id from runtime config")
|
|
210
|
+
return "default"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _ensure_section(content: str, marker: str, body: str) -> str:
|
|
214
|
+
if marker in content:
|
|
215
|
+
return content
|
|
216
|
+
content = content.rstrip()
|
|
217
|
+
if content:
|
|
218
|
+
content += "\n\n"
|
|
219
|
+
return f"{content}{marker}\n{body.rstrip()}\n"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _ensure_memory_template(existing_md: str) -> str:
|
|
223
|
+
if not existing_md.strip():
|
|
224
|
+
return DEFAULT_MEMORY_TEMPLATE
|
|
225
|
+
|
|
226
|
+
result = existing_md
|
|
227
|
+
if "# EvoScientist Memory" not in result:
|
|
228
|
+
result = "# EvoScientist Memory\n\n" + result.lstrip()
|
|
229
|
+
|
|
230
|
+
result = _ensure_section(
|
|
231
|
+
result,
|
|
232
|
+
"## User Profile",
|
|
233
|
+
"\n".join(
|
|
234
|
+
[
|
|
235
|
+
"- **Name**: (unknown)",
|
|
236
|
+
"- **Role**: (unknown)",
|
|
237
|
+
"- **Institution**: (unknown)",
|
|
238
|
+
"- **Language**: (unknown)",
|
|
239
|
+
],
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
result = _ensure_section(
|
|
243
|
+
result,
|
|
244
|
+
"## Research Preferences",
|
|
245
|
+
"\n".join(
|
|
246
|
+
[
|
|
247
|
+
"- **Primary Domain**: (unknown)",
|
|
248
|
+
"- **Sub-fields**: (unknown)",
|
|
249
|
+
"- **Preferred Frameworks**: (unknown)",
|
|
250
|
+
"- **Preferred Models**: (unknown)",
|
|
251
|
+
"- **Hardware**: (unknown)",
|
|
252
|
+
"- **Constraints**: (unknown)",
|
|
253
|
+
],
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
result = _ensure_section(result, "## Experiment History", "(No experiments yet)")
|
|
257
|
+
result = _ensure_section(result, "## Learned Preferences", "- (none yet)")
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _section_bounds(content: str, marker: str) -> tuple[int | None, int | None]:
|
|
262
|
+
idx = content.find(marker)
|
|
263
|
+
if idx == -1:
|
|
264
|
+
return None, None
|
|
265
|
+
start = idx + len(marker)
|
|
266
|
+
next_marker = content.find("\n## ", start)
|
|
267
|
+
if next_marker == -1:
|
|
268
|
+
next_marker = len(content)
|
|
269
|
+
return start, next_marker
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _normalize_item(value: str) -> str:
|
|
273
|
+
return re.sub(r"\s+", " ", value.strip().lower())
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Helper: merge extracted JSON into MEMORY.md markdown
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def _merge_memory(existing_md: str, extracted: dict[str, Any]) -> str:
|
|
281
|
+
"""Merge extracted fields into the existing MEMORY.md content.
|
|
282
|
+
|
|
283
|
+
Performs targeted replacements within the known sections. Unknown
|
|
284
|
+
sections or empty extractions are left untouched.
|
|
285
|
+
"""
|
|
286
|
+
if not extracted:
|
|
287
|
+
return existing_md
|
|
288
|
+
|
|
289
|
+
result = _ensure_memory_template(existing_md)
|
|
290
|
+
|
|
291
|
+
# --- User Profile ---
|
|
292
|
+
profile = extracted.get("user_profile")
|
|
293
|
+
if profile and isinstance(profile, dict):
|
|
294
|
+
field_map = {
|
|
295
|
+
"name": "Name",
|
|
296
|
+
"role": "Role",
|
|
297
|
+
"institution": "Institution",
|
|
298
|
+
"language": "Language",
|
|
299
|
+
}
|
|
300
|
+
for key, label in field_map.items():
|
|
301
|
+
value = profile.get(key)
|
|
302
|
+
if value and value != "null":
|
|
303
|
+
# Replace the line "- **Label**: ..." with new value
|
|
304
|
+
pattern = rf"(- \*\*{label}\*\*: ).*"
|
|
305
|
+
replacement = rf"\g<1>{value}"
|
|
306
|
+
result = re.sub(pattern, replacement, result)
|
|
307
|
+
|
|
308
|
+
# --- Research Preferences ---
|
|
309
|
+
prefs = extracted.get("research_preferences")
|
|
310
|
+
if prefs and isinstance(prefs, dict):
|
|
311
|
+
field_map = {
|
|
312
|
+
"primary_domain": "Primary Domain",
|
|
313
|
+
"sub_fields": "Sub-fields",
|
|
314
|
+
"preferred_frameworks": "Preferred Frameworks",
|
|
315
|
+
"preferred_models": "Preferred Models",
|
|
316
|
+
"hardware": "Hardware",
|
|
317
|
+
"constraints": "Constraints",
|
|
318
|
+
}
|
|
319
|
+
for key, label in field_map.items():
|
|
320
|
+
value = prefs.get(key)
|
|
321
|
+
if value and value != "null":
|
|
322
|
+
pattern = rf"(- \*\*{label}\*\*: ).*"
|
|
323
|
+
replacement = rf"\g<1>{value}"
|
|
324
|
+
result = re.sub(pattern, replacement, result)
|
|
325
|
+
|
|
326
|
+
# --- Experiment History (append) ---
|
|
327
|
+
exp = extracted.get("experiment_conclusion")
|
|
328
|
+
should_add_exp = bool(exp and isinstance(exp, dict) and exp.get("title"))
|
|
329
|
+
if should_add_exp:
|
|
330
|
+
from datetime import datetime
|
|
331
|
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
332
|
+
title = str(exp.get("title", "Untitled")).strip()
|
|
333
|
+
entry = f"\n### [{date_str}] {title}\n"
|
|
334
|
+
entry += f"- **Question**: {exp.get('question', 'N/A')}\n"
|
|
335
|
+
entry += f"- **Method**: {exp.get('method', 'N/A')}\n"
|
|
336
|
+
entry += f"- **Key Result**: {exp.get('key_result', 'N/A')}\n"
|
|
337
|
+
entry += f"- **Conclusion**: {exp.get('conclusion', 'N/A')}\n"
|
|
338
|
+
if exp.get("artifacts"):
|
|
339
|
+
entry += f"- **Artifacts**: {exp['artifacts']}\n"
|
|
340
|
+
|
|
341
|
+
# Remove placeholder if present
|
|
342
|
+
exp_start, exp_end = _section_bounds(result, "## Experiment History")
|
|
343
|
+
if exp_start is not None and exp_end is not None:
|
|
344
|
+
exp_section = result[exp_start:exp_end]
|
|
345
|
+
exp_lines = [
|
|
346
|
+
line for line in exp_section.splitlines() if "(No experiments yet)" not in line
|
|
347
|
+
]
|
|
348
|
+
result = result[:exp_start] + "\n" + "\n".join(exp_lines).strip("\n") + "\n" + result[exp_end:]
|
|
349
|
+
|
|
350
|
+
# De-duplicate by title if already present
|
|
351
|
+
if re.search(rf"### \[[0-9-]+\] {re.escape(title)}\b", result):
|
|
352
|
+
should_add_exp = False
|
|
353
|
+
|
|
354
|
+
if should_add_exp and exp and isinstance(exp, dict) and exp.get("title"):
|
|
355
|
+
# Insert before "## Learned Preferences"
|
|
356
|
+
marker = "## Learned Preferences"
|
|
357
|
+
if marker in result:
|
|
358
|
+
result = result.replace(marker, entry + "\n" + marker)
|
|
359
|
+
else:
|
|
360
|
+
# Fallback: append at end
|
|
361
|
+
result = result.rstrip() + "\n" + entry
|
|
362
|
+
|
|
363
|
+
# --- Learned Preferences (append) ---
|
|
364
|
+
learned = extracted.get("learned_preferences")
|
|
365
|
+
if learned and isinstance(learned, list):
|
|
366
|
+
marker = "## Learned Preferences"
|
|
367
|
+
start, end = _section_bounds(result, marker)
|
|
368
|
+
if start is None or end is None:
|
|
369
|
+
result = _ensure_section(result, marker, "- (none yet)")
|
|
370
|
+
start, end = _section_bounds(result, marker)
|
|
371
|
+
|
|
372
|
+
if start is not None and end is not None:
|
|
373
|
+
section = result[start:end]
|
|
374
|
+
section_lines = [
|
|
375
|
+
line for line in section.splitlines()
|
|
376
|
+
if line.strip() and line.strip() not in {"- (none yet)", "- (none)"}
|
|
377
|
+
]
|
|
378
|
+
existing_items = {
|
|
379
|
+
_normalize_item(line[2:])
|
|
380
|
+
for line in section_lines
|
|
381
|
+
if line.strip().startswith("- ")
|
|
382
|
+
}
|
|
383
|
+
new_lines = []
|
|
384
|
+
for item in learned:
|
|
385
|
+
if not item:
|
|
386
|
+
continue
|
|
387
|
+
normalized = _normalize_item(str(item))
|
|
388
|
+
if normalized in existing_items:
|
|
389
|
+
continue
|
|
390
|
+
existing_items.add(normalized)
|
|
391
|
+
new_lines.append(f"- {item}")
|
|
392
|
+
|
|
393
|
+
if new_lines:
|
|
394
|
+
section_lines.extend(new_lines)
|
|
395
|
+
rebuilt = "\n" + "\n".join(section_lines).strip("\n") + "\n"
|
|
396
|
+
result = result[:start] + rebuilt + result[end:]
|
|
397
|
+
|
|
398
|
+
return result
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# Middleware
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
class EvoMemoryMiddleware(AgentMiddleware):
|
|
406
|
+
"""Middleware that injects and auto-extracts long-term memory.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
backend: Backend instance or factory for reading/writing memory files.
|
|
410
|
+
memory_path: Virtual path to MEMORY.md (default ``/memory/MEMORY.md``).
|
|
411
|
+
extraction_model: Chat model used for extraction (can be a cheap/fast
|
|
412
|
+
model like ``claude-haiku``). If ``None``, automatic extraction is
|
|
413
|
+
disabled and only prompt injection + manual ``edit_file`` works.
|
|
414
|
+
trigger: When to run automatic extraction. Supports
|
|
415
|
+
``("messages", N)`` to trigger every *N* human messages.
|
|
416
|
+
Defaults to ``("messages", 20)``.
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
state_schema = EvoMemoryState
|
|
420
|
+
|
|
421
|
+
def __init__(
|
|
422
|
+
self,
|
|
423
|
+
*,
|
|
424
|
+
backend: BACKEND_TYPES,
|
|
425
|
+
memory_path: str = "/memory/MEMORY.md",
|
|
426
|
+
extraction_model: BaseChatModel | None = None,
|
|
427
|
+
trigger: tuple[str, int] = ("messages", 20),
|
|
428
|
+
) -> None:
|
|
429
|
+
self._backend = backend
|
|
430
|
+
self._memory_path = memory_path
|
|
431
|
+
self._extraction_model = extraction_model
|
|
432
|
+
self._trigger = trigger
|
|
433
|
+
self._last_extraction_at: dict[str, int] = {} # message count per thread
|
|
434
|
+
|
|
435
|
+
# -- backend resolution --------------------------------------------------
|
|
436
|
+
|
|
437
|
+
def _get_backend(
|
|
438
|
+
self,
|
|
439
|
+
state: AgentState[Any],
|
|
440
|
+
runtime: Runtime,
|
|
441
|
+
) -> BackendProtocol:
|
|
442
|
+
if callable(self._backend):
|
|
443
|
+
config = cast("RunnableConfig", getattr(runtime, "config", {}))
|
|
444
|
+
tool_runtime = ToolRuntime(
|
|
445
|
+
state=state,
|
|
446
|
+
context=runtime.context,
|
|
447
|
+
stream_writer=runtime.stream_writer,
|
|
448
|
+
store=runtime.store,
|
|
449
|
+
config=config,
|
|
450
|
+
tool_call_id=None,
|
|
451
|
+
)
|
|
452
|
+
return self._backend(tool_runtime)
|
|
453
|
+
return self._backend
|
|
454
|
+
|
|
455
|
+
# -- agent-level preload -------------------------------------------------
|
|
456
|
+
|
|
457
|
+
def before_agent(
|
|
458
|
+
self,
|
|
459
|
+
state: AgentState[Any],
|
|
460
|
+
runtime: Runtime,
|
|
461
|
+
config: RunnableConfig,
|
|
462
|
+
) -> dict[str, Any] | None:
|
|
463
|
+
if state.get(_STATE_MEMORY_KEY) is not None:
|
|
464
|
+
return None
|
|
465
|
+
backend = self._get_backend(state, runtime)
|
|
466
|
+
memory = self._read_memory(backend)
|
|
467
|
+
_CURRENT_MEMORY.set(memory)
|
|
468
|
+
return {_STATE_MEMORY_KEY: memory}
|
|
469
|
+
|
|
470
|
+
async def abefore_agent(
|
|
471
|
+
self,
|
|
472
|
+
state: AgentState[Any],
|
|
473
|
+
runtime: Runtime,
|
|
474
|
+
config: RunnableConfig,
|
|
475
|
+
) -> dict[str, Any] | None:
|
|
476
|
+
if state.get(_STATE_MEMORY_KEY) is not None:
|
|
477
|
+
return None
|
|
478
|
+
backend = self._get_backend(state, runtime)
|
|
479
|
+
memory = await self._aread_memory(backend)
|
|
480
|
+
_CURRENT_MEMORY.set(memory)
|
|
481
|
+
return {_STATE_MEMORY_KEY: memory}
|
|
482
|
+
|
|
483
|
+
# -- read / write helpers ------------------------------------------------
|
|
484
|
+
|
|
485
|
+
def _read_memory(self, backend: BackendProtocol) -> str:
|
|
486
|
+
"""Read MEMORY.md content (raw bytes → str)."""
|
|
487
|
+
try:
|
|
488
|
+
responses = backend.download_files([self._memory_path])
|
|
489
|
+
if responses and responses[0].content is not None and responses[0].error is None:
|
|
490
|
+
return responses[0].content.decode("utf-8")
|
|
491
|
+
except Exception as e: # noqa: BLE001
|
|
492
|
+
logger.debug("Failed to read memory at %s: %s", self._memory_path, e)
|
|
493
|
+
return ""
|
|
494
|
+
|
|
495
|
+
async def _aread_memory(self, backend: BackendProtocol) -> str:
|
|
496
|
+
try:
|
|
497
|
+
responses = await backend.adownload_files([self._memory_path])
|
|
498
|
+
if responses and responses[0].content is not None and responses[0].error is None:
|
|
499
|
+
return responses[0].content.decode("utf-8")
|
|
500
|
+
except Exception as e: # noqa: BLE001
|
|
501
|
+
logger.debug("Failed to read memory at %s: %s", self._memory_path, e)
|
|
502
|
+
return ""
|
|
503
|
+
|
|
504
|
+
def _write_memory(self, backend: BackendProtocol, old_content: str, new_content: str) -> None:
|
|
505
|
+
"""Write updated MEMORY.md (edit if exists, write if new)."""
|
|
506
|
+
try:
|
|
507
|
+
if old_content:
|
|
508
|
+
result = backend.edit(self._memory_path, old_content, new_content)
|
|
509
|
+
else:
|
|
510
|
+
result = backend.write(self._memory_path, new_content)
|
|
511
|
+
if result and result.error:
|
|
512
|
+
logger.warning("Failed to write memory: %s", result.error)
|
|
513
|
+
except Exception as e: # noqa: BLE001
|
|
514
|
+
logger.warning("Exception writing memory: %s", e)
|
|
515
|
+
|
|
516
|
+
async def _awrite_memory(self, backend: BackendProtocol, old_content: str, new_content: str) -> None:
|
|
517
|
+
try:
|
|
518
|
+
if old_content:
|
|
519
|
+
result = await backend.aedit(self._memory_path, old_content, new_content)
|
|
520
|
+
else:
|
|
521
|
+
result = await backend.awrite(self._memory_path, new_content)
|
|
522
|
+
if result and result.error:
|
|
523
|
+
logger.warning("Failed to write memory: %s", result.error)
|
|
524
|
+
except Exception as e: # noqa: BLE001
|
|
525
|
+
logger.warning("Exception writing memory: %s", e)
|
|
526
|
+
|
|
527
|
+
# -- threshold check -----------------------------------------------------
|
|
528
|
+
|
|
529
|
+
def _should_extract(self, thread_id: str, messages: list[AnyMessage]) -> bool:
|
|
530
|
+
"""Check if we should run automatic extraction."""
|
|
531
|
+
if self._extraction_model is None:
|
|
532
|
+
return False
|
|
533
|
+
|
|
534
|
+
trigger_type, trigger_value = self._trigger
|
|
535
|
+
if trigger_type == "messages":
|
|
536
|
+
human_count = sum(1 for m in messages if isinstance(m, HumanMessage))
|
|
537
|
+
last = self._last_extraction_at.get(thread_id, 0)
|
|
538
|
+
return (human_count - last) >= trigger_value
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
# -- extraction ----------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
def _extract(self, model: BaseChatModel, memory: str, messages: list[AnyMessage]) -> dict[str, Any]:
|
|
544
|
+
"""Run LLM extraction on recent messages."""
|
|
545
|
+
import json
|
|
546
|
+
|
|
547
|
+
# Build conversation string from recent messages (last 30)
|
|
548
|
+
recent = messages[-30:]
|
|
549
|
+
conv_parts = []
|
|
550
|
+
for msg in recent:
|
|
551
|
+
if isinstance(msg, HumanMessage):
|
|
552
|
+
role = "user"
|
|
553
|
+
elif isinstance(msg, AIMessage):
|
|
554
|
+
role = "assistant"
|
|
555
|
+
else:
|
|
556
|
+
continue
|
|
557
|
+
content = msg.content if isinstance(msg.content, str) else str(msg.content)
|
|
558
|
+
conv_parts.append(f"[{role}]: {content}")
|
|
559
|
+
conversation = "\n".join(conv_parts)
|
|
560
|
+
|
|
561
|
+
prompt = EXTRACTION_PROMPT.format(
|
|
562
|
+
current_memory=memory,
|
|
563
|
+
conversation=conversation,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
try:
|
|
567
|
+
response = model.invoke(prompt)
|
|
568
|
+
text = response.content if isinstance(response.content, str) else str(response.content)
|
|
569
|
+
# Extract JSON from response (may be wrapped in ```json ... ```)
|
|
570
|
+
json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
|
571
|
+
if json_match:
|
|
572
|
+
text = json_match.group(1)
|
|
573
|
+
return json.loads(text.strip())
|
|
574
|
+
except Exception as e: # noqa: BLE001
|
|
575
|
+
logger.warning("Memory extraction failed: %s", e)
|
|
576
|
+
return {}
|
|
577
|
+
|
|
578
|
+
async def _aextract(self, model: BaseChatModel, memory: str, messages: list[AnyMessage]) -> dict[str, Any]:
|
|
579
|
+
import json
|
|
580
|
+
|
|
581
|
+
recent = messages[-30:]
|
|
582
|
+
conv_parts = []
|
|
583
|
+
for msg in recent:
|
|
584
|
+
if isinstance(msg, HumanMessage):
|
|
585
|
+
role = "user"
|
|
586
|
+
elif isinstance(msg, AIMessage):
|
|
587
|
+
role = "assistant"
|
|
588
|
+
else:
|
|
589
|
+
continue
|
|
590
|
+
content = msg.content if isinstance(msg.content, str) else str(msg.content)
|
|
591
|
+
conv_parts.append(f"[{role}]: {content}")
|
|
592
|
+
conversation = "\n".join(conv_parts)
|
|
593
|
+
|
|
594
|
+
prompt = EXTRACTION_PROMPT.format(
|
|
595
|
+
current_memory=memory,
|
|
596
|
+
conversation=conversation,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
try:
|
|
600
|
+
response = await model.ainvoke(prompt)
|
|
601
|
+
text = response.content if isinstance(response.content, str) else str(response.content)
|
|
602
|
+
json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
|
603
|
+
if json_match:
|
|
604
|
+
text = json_match.group(1)
|
|
605
|
+
return json.loads(text.strip())
|
|
606
|
+
except Exception as e: # noqa: BLE001
|
|
607
|
+
logger.warning("Memory extraction failed: %s", e)
|
|
608
|
+
return {}
|
|
609
|
+
|
|
610
|
+
# -- middleware hooks -----------------------------------------------------
|
|
611
|
+
|
|
612
|
+
def modify_request(self, request: ModelRequest) -> ModelRequest:
|
|
613
|
+
"""Inject memory content and instructions into the system message.
|
|
614
|
+
|
|
615
|
+
Always injects ``<memory_instructions>`` so the agent knows it can
|
|
616
|
+
save memories, even when MEMORY.md does not exist yet.
|
|
617
|
+
"""
|
|
618
|
+
state = request.state or {}
|
|
619
|
+
memory_content = state.get(_STATE_MEMORY_KEY, "")
|
|
620
|
+
if not memory_content:
|
|
621
|
+
memory_content = _CURRENT_MEMORY.get()
|
|
622
|
+
if not memory_content and request.runtime is not None:
|
|
623
|
+
try:
|
|
624
|
+
backend = self._get_backend(state, request.runtime)
|
|
625
|
+
memory_content = self._read_memory(backend)
|
|
626
|
+
_CURRENT_MEMORY.set(memory_content)
|
|
627
|
+
except Exception as e: # noqa: BLE001
|
|
628
|
+
logger.debug("Failed to load memory during modify_request: %s", e)
|
|
629
|
+
# Use placeholder when memory file doesn't exist yet
|
|
630
|
+
if not memory_content:
|
|
631
|
+
memory_content = "(No memory saved yet. Create /memory/MEMORY.md when you learn important information.)"
|
|
632
|
+
|
|
633
|
+
from deepagents.middleware._utils import append_to_system_message
|
|
634
|
+
injection = MEMORY_INJECTION_TEMPLATE.format(memory_content=memory_content)
|
|
635
|
+
new_system = append_to_system_message(request.system_message, injection)
|
|
636
|
+
return request.override(system_message=new_system)
|
|
637
|
+
|
|
638
|
+
def wrap_model_call(
|
|
639
|
+
self,
|
|
640
|
+
request: ModelRequest,
|
|
641
|
+
handler: Callable[[ModelRequest], ModelResponse],
|
|
642
|
+
) -> ModelResponse:
|
|
643
|
+
"""Inject memory into system prompt before every LLM call."""
|
|
644
|
+
modified = self.modify_request(request)
|
|
645
|
+
return handler(modified)
|
|
646
|
+
|
|
647
|
+
async def awrap_model_call(
|
|
648
|
+
self,
|
|
649
|
+
request: ModelRequest,
|
|
650
|
+
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
651
|
+
) -> ModelResponse:
|
|
652
|
+
modified = self.modify_request(request)
|
|
653
|
+
return await handler(modified)
|
|
654
|
+
|
|
655
|
+
def before_model(
|
|
656
|
+
self,
|
|
657
|
+
state: AgentState[Any],
|
|
658
|
+
runtime: Runtime,
|
|
659
|
+
) -> dict[str, Any] | None:
|
|
660
|
+
"""Read memory and optionally run extraction before each LLM call."""
|
|
661
|
+
backend = self._get_backend(state, runtime)
|
|
662
|
+
messages = state["messages"]
|
|
663
|
+
thread_id = _get_thread_id(runtime)
|
|
664
|
+
|
|
665
|
+
# Always read memory for injection
|
|
666
|
+
memory = self._read_memory(backend)
|
|
667
|
+
_CURRENT_MEMORY.set(memory)
|
|
668
|
+
state_update: dict[str, Any] | None = None
|
|
669
|
+
if state.get(_STATE_MEMORY_KEY) != memory:
|
|
670
|
+
state_update = {_STATE_MEMORY_KEY: memory}
|
|
671
|
+
|
|
672
|
+
# Check extraction threshold
|
|
673
|
+
if self._should_extract(thread_id, messages):
|
|
674
|
+
human_count = sum(1 for m in messages if isinstance(m, HumanMessage))
|
|
675
|
+
extracted = self._extract(self._extraction_model, memory, messages)
|
|
676
|
+
if extracted:
|
|
677
|
+
new_memory = _merge_memory(memory, extracted)
|
|
678
|
+
if new_memory != memory:
|
|
679
|
+
self._write_memory(backend, memory, new_memory)
|
|
680
|
+
_CURRENT_MEMORY.set(new_memory)
|
|
681
|
+
logger.info("Auto-extracted and updated memory")
|
|
682
|
+
state_update = {_STATE_MEMORY_KEY: new_memory}
|
|
683
|
+
self._last_extraction_at[thread_id] = human_count
|
|
684
|
+
|
|
685
|
+
return state_update
|
|
686
|
+
|
|
687
|
+
async def abefore_model(
|
|
688
|
+
self,
|
|
689
|
+
state: AgentState[Any],
|
|
690
|
+
runtime: Runtime,
|
|
691
|
+
) -> dict[str, Any] | None:
|
|
692
|
+
"""Async: Read memory and optionally run extraction."""
|
|
693
|
+
backend = self._get_backend(state, runtime)
|
|
694
|
+
messages = state["messages"]
|
|
695
|
+
thread_id = _get_thread_id(runtime)
|
|
696
|
+
|
|
697
|
+
memory = await self._aread_memory(backend)
|
|
698
|
+
_CURRENT_MEMORY.set(memory)
|
|
699
|
+
state_update: dict[str, Any] | None = None
|
|
700
|
+
if state.get(_STATE_MEMORY_KEY) != memory:
|
|
701
|
+
state_update = {_STATE_MEMORY_KEY: memory}
|
|
702
|
+
|
|
703
|
+
if self._should_extract(thread_id, messages):
|
|
704
|
+
human_count = sum(1 for m in messages if isinstance(m, HumanMessage))
|
|
705
|
+
extracted = await self._aextract(self._extraction_model, memory, messages)
|
|
706
|
+
if extracted:
|
|
707
|
+
new_memory = _merge_memory(memory, extracted)
|
|
708
|
+
if new_memory != memory:
|
|
709
|
+
await self._awrite_memory(backend, memory, new_memory)
|
|
710
|
+
_CURRENT_MEMORY.set(new_memory)
|
|
711
|
+
logger.info("Auto-extracted and updated memory")
|
|
712
|
+
state_update = {_STATE_MEMORY_KEY: new_memory}
|
|
713
|
+
self._last_extraction_at[thread_id] = human_count
|
|
714
|
+
|
|
715
|
+
return state_update
|