Skip to content

Commit aba4e29

Browse files
authored
Merge pull request #160 from vstorm-co/fix/agent-memory-injection-and-subagent-collision
fix: memory injection recency + subagent read_memory collision
2 parents 1c6f994 + 1c60465 commit aba4e29

9 files changed

Lines changed: 234 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.3.33] - 2026-06-26
11+
12+
### Fixed
13+
14+
- **Agent memory injection now keeps the most recent lines, not the oldest** ([#157](https://github.com/vstorm-co/pydantic-deepagents/issues/157)) (`pydantic_deep/toolsets/memory.py`). `format_memory_prompt` truncated an over-budget `MEMORY.md` by keeping the first `max_lines`, but `write_memory` appends new content to the end of the file, so the newest observations were the first to drop out. Truncation now keeps the recency tail (the dropped-line marker moves above the kept tail). Two additions from the same report: authors can pin a foundational head with a `<!-- deep:pin-end -->` marker (`DEFAULT_PIN_END_MARKER`), which is always injected in full so it survives truncation; and injection can be budgeted in approximate tokens via a new `max_tokens` that takes precedence over `max_lines` (reusing the `NUM_CHARS_PER_TOKEN` heuristic). `AgentMemoryToolset` and `MemoryCapability` gain `max_tokens` / `pin_marker`; subagents accept `extra.memory_max_tokens` / `extra.memory_pin_marker`.
15+
- **Subagent delegation no longer fails with a `read_memory` tool name collision** ([#155](https://github.com/vstorm-co/pydantic-deepagents/issues/155)) (`pydantic_deep/agent.py`). With `include_memory=True` and `include_subagents=True` (both default), the default subagent factory passed `include_memory=True` into each subagent's own `create_deep_agent`, which registered a second `AgentMemoryToolset` ('deep-memory', under the wrong "main" namespace) on top of the one `_inject_subagent_memory_toolset` already injects — a regression since 0.3.30 that made every delegation fail with `AgentMemoryToolset 'deep-memory' defines a tool whose name conflicts ...: 'read_memory'`. The factory no longer creates its own memory toolset; the injected one, correctly namespaced to the subagent, is the single source.
16+
1017
## [0.3.32] - 2026-06-26
1118

1219
### Changed

pydantic_deep/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ class Analysis(BaseModel):
218218
DEFAULT_MAX_MEMORY_LINES,
219219
DEFAULT_MEMORY_DIR,
220220
DEFAULT_MEMORY_FILENAME,
221+
DEFAULT_PIN_END_MARKER,
221222
AgentMemoryToolset,
222223
MemoryAccessError,
223224
MemoryFile,
@@ -412,6 +413,7 @@ class Analysis(BaseModel):
412413
"DEFAULT_MEMORY_DIR",
413414
"DEFAULT_MEMORY_FILENAME",
414415
"DEFAULT_MAX_MEMORY_LINES",
416+
"DEFAULT_PIN_END_MARKER",
415417
# Eviction
416418
"EvictionCapability",
417419
"EvictionProcessor",

pydantic_deep/agent.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,6 @@ def _make_default_deep_agent_factory(
229229
edit_format: Any,
230230
context_files: Any,
231231
context_discovery: Any,
232-
include_memory: bool,
233232
memory_dir: Any,
234233
web_search: bool,
235234
web_fetch: bool,
@@ -266,7 +265,7 @@ def _factory(cfg: dict[str, Any]) -> Any:
266265
include_builtin_subagents=False,
267266
context_manager=False,
268267
cost_tracking=False,
269-
include_memory=include_memory,
268+
include_memory=False,
270269
memory_dir=memory_dir,
271270
context_files=context_files,
272271
context_discovery=context_discovery,
@@ -305,14 +304,16 @@ def _inject_subagent_memory_toolset(sa_config: SubAgentConfig, memory_dir: str |
305304
from pydantic_deep.toolsets.memory import (
306305
DEFAULT_MAX_MEMORY_LINES,
307306
DEFAULT_MEMORY_DIR,
307+
DEFAULT_PIN_END_MARKER,
308308
AgentMemoryToolset,
309309
)
310310

311-
max_lines = extra.get("memory_max_lines", DEFAULT_MAX_MEMORY_LINES)
312311
mem = AgentMemoryToolset(
313312
agent_name=sa_config["name"],
314313
memory_dir=memory_dir or DEFAULT_MEMORY_DIR,
315-
max_lines=max_lines,
314+
max_lines=extra.get("memory_max_lines", DEFAULT_MAX_MEMORY_LINES),
315+
max_tokens=extra.get("memory_max_tokens"),
316+
pin_marker=extra.get("memory_pin_marker", DEFAULT_PIN_END_MARKER),
316317
)
317318
existing = list(sa_config.get("toolsets", []))
318319
existing.append(mem)
@@ -940,7 +941,6 @@ def _set_toolset_retries(toolset: AbstractToolset[DeepAgentDeps], max_retries: i
940941
edit_format=edit_format,
941942
context_files=context_files,
942943
context_discovery=context_discovery,
943-
include_memory=include_memory,
944944
memory_dir=memory_dir,
945945
web_search=web_search,
946946
web_fetch=web_fetch,

pydantic_deep/capabilities/memory.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
from pydantic_ai.capabilities import AbstractCapability
1010
from pydantic_ai.toolsets import AbstractToolset
1111

12-
from pydantic_deep.toolsets.memory import DEFAULT_MEMORY_DIR, AgentMemoryToolset
12+
from pydantic_deep.toolsets.memory import (
13+
DEFAULT_MEMORY_DIR,
14+
DEFAULT_PIN_END_MARKER,
15+
AgentMemoryToolset,
16+
)
1317

1418

1519
@dataclass
@@ -31,13 +35,17 @@ class MemoryCapability(AbstractCapability[Any]):
3135
agent_name: str = "main"
3236
memory_dir: str = DEFAULT_MEMORY_DIR
3337
max_lines: int = 200
38+
max_tokens: int | None = None
39+
pin_marker: str = DEFAULT_PIN_END_MARKER
3440
_toolset: AgentMemoryToolset | None = field(default=None, init=False, repr=False)
3541

3642
def __post_init__(self) -> None:
3743
self._toolset = AgentMemoryToolset(
3844
agent_name=self.agent_name,
3945
memory_dir=self.memory_dir,
4046
max_lines=self.max_lines,
47+
max_tokens=self.max_tokens,
48+
pin_marker=self.pin_marker,
4149
)
4250

4351
def get_toolset(self) -> AbstractToolset[Any] | None:

pydantic_deep/toolsets/memory.py

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from pydantic_ai.toolsets import FunctionToolset
2020
from pydantic_ai_backends import AsyncBackendProtocol
2121

22+
from pydantic_deep.processors.eviction import NUM_CHARS_PER_TOKEN
23+
2224

2325
class MemoryAccessError(Exception):
2426
"""The backend denied access to the memory path.
@@ -39,6 +41,15 @@ class MemoryAccessError(Exception):
3941
DEFAULT_MAX_MEMORY_LINES: int = 200
4042
"""Default max lines to inject into system prompt."""
4143

44+
DEFAULT_PIN_END_MARKER: str = "<!-- deep:pin-end -->"
45+
"""Marker delimiting the pinned memory head from the truncatable body.
46+
47+
Everything above the first occurrence is always injected verbatim, so
48+
foundational notes survive truncation; everything below it is the
49+
recency-truncated body. The default is an HTML comment, invisible in rendered
50+
markdown.
51+
"""
52+
4253
# Tool description constants
4354

4455
READ_MEMORY_DESCRIPTION = """\
@@ -131,26 +142,72 @@ async def load_memory(
131142
return None
132143

133144

134-
def format_memory_prompt(memory: MemoryFile, max_lines: int) -> str:
145+
def _select_recent_lines(lines: list[str], max_lines: int, max_tokens: int | None) -> int:
146+
"""Return how many trailing lines fit the budget (>=1 when `lines` is non-empty).
147+
148+
The *most recent* lines are the ones kept, since `write_memory` appends new
149+
content to the end of the file. `max_tokens` (approximate, via
150+
`NUM_CHARS_PER_TOKEN`) takes precedence over `max_lines` when provided.
151+
"""
152+
if not lines:
153+
return 0
154+
if max_tokens is None:
155+
return min(len(lines), max_lines)
156+
157+
char_budget = max_tokens * NUM_CHARS_PER_TOKEN
158+
used = 0
159+
kept = 0
160+
for line in reversed(lines):
161+
used += len(line) + 1 # +1 approximates the joining newline
162+
if used > char_budget and kept >= 1:
163+
break
164+
kept += 1
165+
return kept
166+
167+
168+
def format_memory_prompt(
169+
memory: MemoryFile,
170+
max_lines: int,
171+
*,
172+
max_tokens: int | None = None,
173+
pin_marker: str = DEFAULT_PIN_END_MARKER,
174+
) -> str:
135175
"""Format memory content for system prompt injection.
136176
137-
Only the first `max_lines` lines are included to stay within
138-
token budget. If truncated, a marker is added.
177+
`write_memory` appends new content, so the newest observations live at the
178+
end of the file. When memory exceeds the budget, the *most recent* lines are
179+
kept (the tail), not the oldest. Content above the first `pin_marker` is a
180+
pinned head that is always injected in full, so foundational notes survive
181+
truncation. If the body is truncated, a marker noting the dropped lines is
182+
inserted above the kept tail.
139183
140184
Args:
141185
memory: Loaded memory file.
142-
max_lines: Maximum number of lines to include.
186+
max_lines: Maximum number of body lines to include.
187+
max_tokens: Optional approximate token budget for the body. When set it
188+
takes precedence over `max_lines`, using the `NUM_CHARS_PER_TOKEN`
189+
heuristic used elsewhere in the library.
190+
pin_marker: Marker whose first occurrence ends the always-injected head.
143191
144192
Returns:
145193
Formatted system prompt section.
146194
"""
147-
lines = memory.content.splitlines()
148-
if len(lines) > max_lines:
149-
truncated_count = len(lines) - max_lines
150-
content = "\n".join(lines[:max_lines])
151-
content += f"\n\n... [{truncated_count} more lines in memory] ..."
152-
else:
153-
content = memory.content
195+
pinned = ""
196+
body = memory.content
197+
marker_idx = memory.content.find(pin_marker)
198+
if marker_idx != -1:
199+
pinned = memory.content[:marker_idx].rstrip("\n")
200+
body = memory.content[marker_idx + len(pin_marker) :].lstrip("\n")
201+
202+
body_lines = body.splitlines()
203+
keep = _select_recent_lines(body_lines, max_lines, max_tokens)
204+
if keep < len(body_lines):
205+
dropped = len(body_lines) - keep
206+
body = f"... [{dropped} more lines in memory] ..."
207+
if keep:
208+
body += "\n\n" + "\n".join(body_lines[-keep:])
209+
210+
content = f"{pinned}\n\n{body}" if pinned and body else pinned or body
154211

155212
return f"## Agent Memory ({memory.agent_name})\n\n{content}"
156213

@@ -176,14 +233,21 @@ def __init__(
176233
agent_name: str = "main",
177234
memory_dir: str = DEFAULT_MEMORY_DIR,
178235
max_lines: int = DEFAULT_MAX_MEMORY_LINES,
236+
max_tokens: int | None = None,
237+
pin_marker: str = DEFAULT_PIN_END_MARKER,
179238
descriptions: dict[str, str] | None = None,
180239
) -> None:
181240
"""Initialize the memory toolset.
182241
183242
Args:
184243
agent_name: Name of the agent (used for path and prompt label).
185244
memory_dir: Base directory for memory files in the backend.
186-
max_lines: Max lines to inject into system prompt.
245+
max_lines: Max body lines to inject into the system prompt. The most
246+
recent lines are kept (`write_memory` appends to the end).
247+
max_tokens: Optional approximate token budget for injection. When set
248+
it takes precedence over `max_lines`.
249+
pin_marker: Marker whose first occurrence ends the always-injected
250+
pinned head, so foundational notes survive truncation.
187251
descriptions: Optional mapping of tool name to custom description.
188252
Supported keys: `read_memory`, `write_memory`, `update_memory`.
189253
Any key not present falls back to the built-in description constant.
@@ -192,6 +256,8 @@ def __init__(
192256
self._agent_name = agent_name
193257
self._memory_dir = memory_dir
194258
self._max_lines = max_lines
259+
self._max_tokens = max_tokens
260+
self._pin_marker = pin_marker
195261
self._descs = descriptions or {}
196262
self._path = get_memory_path(memory_dir, agent_name)
197263

@@ -285,5 +351,10 @@ async def get_instructions(self, ctx: RunContext[Any]) -> list[InstructionPart]
285351
return None
286352
if mem is None:
287353
return None
288-
result = format_memory_prompt(mem, self._max_lines)
354+
result = format_memory_prompt(
355+
mem,
356+
self._max_lines,
357+
max_tokens=self._max_tokens,
358+
pin_marker=self._pin_marker,
359+
)
289360
return [InstructionPart(content=result, dynamic=True)] if result else None

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pydantic-deep"
3-
version = "0.3.32"
3+
version = "0.3.33"
44
description = "Batteries-included agent harness for Python — tool-calling, sandboxed execution, multi-agent teams, and unlimited context on Pydantic AI"
55
readme = "README.md"
66
keywords = [

tests/test_agent.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ def _default_factory(**parent_kwargs: object) -> Any:
8181
edit_format=None,
8282
context_files=None,
8383
context_discovery=False,
84-
include_memory=False,
8584
memory_dir=None,
8685
web_search=False,
8786
web_fetch=False,
@@ -147,6 +146,46 @@ def test_subagent_configs_not_mutated_and_no_toolset_doubling(self):
147146
assert "agent_factory" not in cfg
148147
assert cfg["toolsets"] == []
149148

149+
def test_subagent_factory_single_memory_toolset(self):
150+
"""Regression for #155: the default subagent factory must not register a
151+
second AgentMemoryToolset.
152+
153+
`_inject_subagent_memory_toolset` adds one memory toolset under the
154+
subagent's own name; the factory previously also passed
155+
`include_memory=True`, so `create_deep_agent` added a second 'deep-memory'
156+
toolset (under the wrong "main" name), causing a `read_memory` collision.
157+
"""
158+
from pydantic_deep.agent import _inject_subagent_memory_toolset
159+
from pydantic_deep.toolsets.memory import AgentMemoryToolset
160+
161+
cfg: SubAgentConfig = SubAgentConfig(
162+
name="researcher", description="explores", instructions="explore", toolsets=[]
163+
)
164+
_inject_subagent_memory_toolset(cfg, None)
165+
166+
sub_agent = self._default_factory()(cfg)
167+
168+
seen: set[int] = set()
169+
found: list[Any] = []
170+
171+
def _walk(toolsets: Any) -> None:
172+
for ts in toolsets:
173+
if id(ts) in seen:
174+
continue
175+
seen.add(id(ts))
176+
found.append(ts)
177+
for attr in ("toolsets", "_toolsets", "wrapped"):
178+
inner = getattr(ts, attr, None)
179+
if isinstance(inner, (list, tuple)):
180+
_walk(inner)
181+
elif inner is not None and inner is not ts:
182+
_walk([inner])
183+
184+
_walk(list(getattr(sub_agent, "toolsets", []) or []))
185+
memory_toolsets = [t for t in found if isinstance(t, AgentMemoryToolset)]
186+
assert len(memory_toolsets) == 1
187+
assert memory_toolsets[0]._agent_name == "researcher"
188+
150189
def test_create_with_interrupt_on(self):
151190
"""Test creating an agent with interrupt_on config."""
152191
agent = create_deep_agent(

tests/test_capabilities.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ def test_custom_name(self):
8787
cap = MemoryCapability(agent_name="worker")
8888
assert cap.agent_name == "worker"
8989

90+
def test_max_tokens_passed_to_toolset(self):
91+
"""`max_tokens` is forwarded to the underlying AgentMemoryToolset (#157)."""
92+
cap = MemoryCapability(max_tokens=1500)
93+
assert cap._toolset is not None
94+
assert cap._toolset._max_tokens == 1500
95+
9096
def test_get_toolset(self):
9197
cap = MemoryCapability()
9298
assert cap.get_toolset() is not None

0 commit comments

Comments
 (0)