Skip to content

Commit a7f6bfc

Browse files
ozandclaude
andauthored
fix(memory): keep loop rules resident and trim whole entries (#1443) (#1446)
* wip(memory): resident rules block for the loop memory index (#1443) Work-in-progress snapshot committed by the operator after the agent's session was interrupted by a gateway 429. Not reviewed, not test-run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(memory): keep loop rules resident while trimming whole entries * test(memory): expose resident index fit metrics * chore: restore unrelated loguru regression guard * fix(memory): report when a resident rule label stops matching The resident block is selected by matching label text in memory/index.md -- an artifact the INSTANCE owns and can rename. A rename returns that rule to the droppable remainder, which is precisely the failure this policy exists to prevent, and it would happen silently: an unmatched label reads exactly like a matched one. last_index_fit now carries resident_matched, resident_expected and resident_missing, and a mismatch logs a warning naming the missing labels. Reported, never asserted -- the memory reader must not fail a cycle over index text it does not own. Tests pin both directions: a renamed "[DO NOT touch]" entry reports 4 of 5 with the missing label named, and an intact index reports 5 of 5 so the counter cannot pass vacuously. Verified against the live 8,680-byte index: 5/5 matched, kept_chars 3985, 28 entries dropped -- agreeing with the PR's accounting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3574b58 commit a7f6bfc

3 files changed

Lines changed: 184 additions & 7 deletions

File tree

nanobot/agent/context.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def build_system_prompt(
131131
sections.append(("memory", f"# Memory\n\n{memory}" if memory else ""))
132132
return self._fit_system_prompt(
133133
sections, strict=strict, degrade_on_overflow=degrade_on_overflow,
134+
memory_fit=self.memory.last_index_fit,
134135
)
135136

136137
SECTION_SEPARATOR = "\n\n---\n\n"
@@ -359,6 +360,7 @@ def _fit_system_prompt(
359360
sections: list[tuple[str, str]],
360361
strict: bool = False,
361362
degrade_on_overflow: bool = False,
363+
memory_fit: dict[str, Any] | None = None,
362364
) -> str:
363365
"""Fit sections under the cap and record the outcome in :attr:`last_fit`.
364366
@@ -382,6 +384,14 @@ def _fit_system_prompt(
382384
# max(0, non_empty_sections - 1) == chars.
383385
section_names = [name for name, _ in sections]
384386
fit: dict[str, Any] = {"cap": cap, "strict": strict, "dropped": []}
387+
if memory_fit:
388+
fit["memory_index"] = {
389+
key: memory_fit[key]
390+
for key in (
391+
"source_chars", "resident_chars", "remainder_source_chars",
392+
"remainder_kept_chars", "dropped_entries", "dropped_chars",
393+
) if key in memory_fit
394+
}
385395
joined = self._record_fit(fit, section_names, sections)
386396
if len(joined) <= cap:
387397
occupancy = len(joined) / cap if cap else 1.0

nanobot/agent/memory.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def __init__(self, workspace: Path):
8282
self.memory_file = self.memory_dir / "MEMORY.md"
8383
self.history_file = self.memory_dir / "HISTORY.md"
8484
self._consecutive_failures = 0
85+
self.last_index_fit: dict[str, Any] | None = None
8586

8687
def read_long_term(self) -> str:
8788
if self.memory_file.exists():
@@ -99,10 +100,87 @@ def get_memory_context(self, *, loop: bool = False, max_chars: int = 4000) -> st
99100
if loop:
100101
index = self.memory_dir / "index.md"
101102
text = index.read_text(encoding="utf-8") if index.is_file() else ""
102-
return f"## Long-term Memory\n{text[-max_chars:]}" if text else ""
103+
return self._format_loop_index(text, max_chars=max_chars) if text else ""
103104
long_term = self.read_long_term()
104105
return f"## Long-term Memory\n{long_term}" if long_term else ""
105106

107+
def _format_loop_index(self, text: str, *, max_chars: int) -> str:
108+
"""Keep policy entries resident and trim only complete index entries.
109+
110+
The resident block is made from the index preamble, the ``Facts``
111+
heading, and the five operational entries that define identity,
112+
write-target, prohibitions, rules, and host paths. The remainder is
113+
selected as whole lines from newest to oldest, then restored to source
114+
order. No character slice can cut a token or remove the resident block.
115+
116+
The labels are matched against index text the INSTANCE owns and can
117+
rename. A rename would silently return that rule to the droppable
118+
remainder — the failure this function exists to prevent. So the match
119+
count is recorded in ``last_index_fit`` as ``resident_matched`` and
120+
``resident_missing``: a guard keyed on something that moves must at
121+
least say when it stopped matching, or it reads exactly like a guard
122+
that is working.
123+
"""
124+
lines = text.splitlines(keepends=True)
125+
resident_labels: tuple[str, ...] = (
126+
"[Identity]", "[Write target:", "[DO NOT touch]", "[Rules]", "[Key paths",
127+
)
128+
matched_labels: set[str] = set()
129+
resident: list[str] = []
130+
remainder: list[str] = []
131+
facts_heading_seen = False
132+
for line in lines:
133+
stripped = line.strip()
134+
if not facts_heading_seen:
135+
resident.append(line)
136+
if stripped.startswith("## Facts"):
137+
facts_heading_seen = True
138+
continue
139+
hit = [label for label in resident_labels if label in line]
140+
if hit:
141+
matched_labels.update(hit)
142+
resident.append(line)
143+
else:
144+
remainder.append(line)
145+
146+
resident_text = "".join(resident)
147+
available = max(0, max_chars - len(resident_text))
148+
kept_reversed: list[str] = []
149+
used = 0
150+
dropped = 0
151+
dropped_chars = 0
152+
for entry in reversed(remainder):
153+
if used + len(entry) <= available:
154+
kept_reversed.append(entry)
155+
used += len(entry)
156+
else:
157+
dropped += 1
158+
dropped_chars += len(entry)
159+
kept = "".join(reversed(kept_reversed))
160+
self.last_index_fit = {
161+
"source_chars": len(text),
162+
"resident_chars": len(resident_text),
163+
"remainder_source_chars": len(text) - len(resident_text),
164+
"remainder_kept_chars": used,
165+
"kept_chars": len(resident_text) + used,
166+
"dropped_entries": dropped,
167+
"dropped_chars": dropped_chars,
168+
"max_chars": max_chars,
169+
# #1443: a resident label that stopped matching means the instance
170+
# renamed that entry and the rule is droppable again. Reported, not
171+
# asserted — the reader must never fail the cycle over index text.
172+
"resident_matched": len(matched_labels),
173+
"resident_expected": len(resident_labels),
174+
"resident_missing": sorted(set(resident_labels) - matched_labels),
175+
}
176+
if matched_labels != set(resident_labels):
177+
logger.warning(
178+
"memory index: {} of {} resident rule entries matched; missing={}",
179+
len(matched_labels), len(resident_labels),
180+
",".join(sorted(set(resident_labels) - matched_labels)) or "none",
181+
)
182+
return resident_text + kept
183+
106184
@staticmethod
107185
def _format_messages(messages: list[dict]) -> str:
108186
lines = []

tests/test_memory_v2.py

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,23 @@ def test_interactive_context_keeps_legacy_memory(tmp_path: Path):
2222
assert "FULL LEGACY BODY" in prompt
2323

2424

25-
def test_loop_memory_context_reads_tail_and_shows_freshly_added_fact(tmp_path: Path):
26-
"""Issue #1041 Part 1: when index.md exceeds max_chars, newest facts at the tail are visible."""
25+
def test_loop_memory_context_keeps_resident_rules_and_drops_whole_entries(tmp_path: Path):
26+
"""#1443: small caps cannot remove the rules block or split an entry."""
2727
from nanobot.agent.memory import MemoryStore
2828

2929
mem_dir = tmp_path / "memory"
3030
mem_dir.mkdir()
3131
index_file = mem_dir / "index.md"
3232

33-
# Fill index.md with older facts totaling > 4000 chars
34-
lines = [f"- [Old Fact {i}](facts/old_{i}.md) — Older context fact {i}" for i in range(100)]
33+
lines = [
34+
"# Memory index", "", "## Facts (memory/facts/)", "",
35+
"* [Identity](facts/identity.md)",
36+
"* [Write target: workspace](facts/write-target.md)",
37+
"* [DO NOT touch](facts/do-not-touch.md)",
38+
"* [Rules](facts/rules.md)",
39+
"* [Key paths on host](facts/key-paths.md)",
40+
]
41+
lines.extend(f"- [Old Fact {i}](facts/old_{i}.md) — Older context fact {i}" for i in range(100))
3542
lines.append("- [Brand New Fact](facts/fresh.md) — Crucial latest discovered fact")
3643
content = "\n".join(lines) + "\n"
3744
index_file.write_text(content, encoding="utf-8")
@@ -40,8 +47,90 @@ def test_loop_memory_context_reads_tail_and_shows_freshly_added_fact(tmp_path: P
4047
store = MemoryStore(tmp_path)
4148
ctx = store.get_memory_context(loop=True, max_chars=4000)
4249

43-
# Tail should be included, so the fresh fact is visible
50+
assert "[Identity]" in ctx
51+
assert "[Write target: workspace]" in ctx
52+
assert "[DO NOT touch]" in ctx
53+
assert "[Rules]" in ctx
54+
assert "[Key paths on host]" in ctx
4455
assert "Crucial latest discovered fact" in ctx
4556
assert "Brand New Fact" in ctx
46-
# Old head facts should be truncated out
4757
assert "Old Fact 0" not in ctx
58+
assert "[trimmed" not in ctx
59+
assert store.last_index_fit["dropped_entries"] > 0
60+
assert store.last_index_fit["resident_chars"] <= len(ctx)
61+
62+
63+
def test_context_fit_records_memory_index_drop_details(tmp_path: Path):
64+
mem_dir = tmp_path / "memory"
65+
mem_dir.mkdir()
66+
index = [
67+
"# Memory index", "", "## Facts (memory/facts/)", "",
68+
"* [Identity](facts/identity.md)", "* [Write target: workspace](facts/write-target.md)",
69+
"* [DO NOT touch](facts/do-not-touch.md)", "* [Rules](facts/rules.md)",
70+
"* [Key paths on host](facts/key-paths.md)",
71+
] + [f"- [Filler {i}](facts/filler_{i}.md) — {'x' * 40}" for i in range(100)]
72+
(mem_dir / "index.md").write_text("\n".join(index) + "\n", encoding="utf-8")
73+
builder = ContextBuilder(tmp_path)
74+
builder.skills.get_always_skills = lambda: []
75+
builder.skills.load_skills_for_context = lambda names: ""
76+
builder.skills.build_skills_summary = lambda excluded_names=None: ""
77+
prompt = builder.build_system_prompt(loop_profile=True)
78+
fit = builder.last_fit
79+
assert "[Identity]" in prompt and "[Rules]" in prompt
80+
assert fit["memory_index"]["dropped_entries"] > 0
81+
assert fit["memory_index"]["resident_chars"] > 0
82+
83+
84+
def test_renamed_rule_entry_is_reported_not_silently_droppable(tmp_path: Path):
85+
"""#1443: the instance owns memory/ and can rename its own facts.
86+
87+
The resident block is matched on index label text, so a rename returns
88+
that rule to the droppable remainder -- the exact failure this policy
89+
exists to prevent. That must be visible: a guard keyed on something that
90+
moves reads identically to a working guard once it stops matching.
91+
"""
92+
from nanobot.agent.memory import MemoryStore
93+
94+
mem_dir = tmp_path / "memory"
95+
mem_dir.mkdir()
96+
lines = [
97+
"# Memory index", "", "## Facts (memory/facts/)", "",
98+
"* [Identity](facts/identity.md)",
99+
"* [Write target: workspace](facts/write-target.md)",
100+
# renamed by the instance -- no longer matches "[DO NOT touch]"
101+
"* [Do not modify these paths](facts/do-not-touch.md)",
102+
"* [Rules](facts/rules.md)",
103+
"* [Key paths on host](facts/key-paths.md)",
104+
]
105+
lines.extend(f"- [Old Fact {i}](facts/old_{i}.md) — filler {i}" for i in range(100))
106+
(mem_dir / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
107+
108+
store = MemoryStore(tmp_path)
109+
store.get_memory_context(loop=True, max_chars=4000)
110+
fit = store.last_index_fit
111+
112+
assert fit["resident_matched"] == 4
113+
assert fit["resident_expected"] == 5
114+
assert fit["resident_missing"] == ["[DO NOT touch]"]
115+
116+
117+
def test_intact_index_reports_every_resident_label_matched(tmp_path: Path):
118+
"""The counter must be non-vacuous: a healthy index reports 5 of 5."""
119+
from nanobot.agent.memory import MemoryStore
120+
121+
mem_dir = tmp_path / "memory"
122+
mem_dir.mkdir()
123+
lines = [
124+
"# Memory index", "", "## Facts (memory/facts/)", "",
125+
"* [Identity](facts/identity.md)",
126+
"* [Write target: workspace](facts/write-target.md)",
127+
"* [DO NOT touch](facts/do-not-touch.md)",
128+
"* [Rules](facts/rules.md)",
129+
"* [Key paths on host](facts/key-paths.md)",
130+
]
131+
(mem_dir / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
132+
133+
store = MemoryStore(tmp_path)
134+
store.get_memory_context(loop=True, max_chars=4000)
135+
assert store.last_index_fit["resident_matched"] == 5
136+
assert store.last_index_fit["resident_missing"] == []

0 commit comments

Comments
 (0)