M5.2: agent memory
remember(name, description, fact) and forget(name, why): control tools for the driver (before the go-ahead too) and conversations, writing only the agent's own vault folder, one note per fact with its provenance. No one agrees first; each change is a line in the thread. The harness keeps the index and puts it into the transcript once per item, with a hash. At an item's close, completed or abandoned, a bounded memory pass (the driver's model, its own transcript and view) records what was decided and learned; it never holds the close up (tuning.memory.max_turns). With the operator: corrections may be paraphrased (L6 changed), the model forgets on its own, and nothing limits what may prompt a note. The walk remembers and forgets, and checks nothing lands outside the agent's folder. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
1 parent
2a35475eec
commit
2b26f0cff5
16 files changed
+604
-16
No files matched your search
@@ -0,0 +1,160 @@
|
||||
"""Agent memory in the loop (L6.4, M5.2): the model remembers and forgets
|
||||
without asking, each said in the thread; the index is shown once per item;
|
||||
a bounded pass at an item's close records what will matter, and never holds
|
||||
the close up."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from test_loop_milestones import DON, RESTATE, call, loop_agent, say
|
||||
from test_loop_milestones import item as the_item
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
from test_loop_notes import Spy
|
||||
|
||||
from nisse_dev.canonical import AssistantText, Fact
|
||||
from nisse_dev.llm import RoleMap
|
||||
from nisse_dev.memory import Memory
|
||||
from nisse_dev.vault import Vault
|
||||
|
||||
FOLDER = "memory/nisse-dev"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory(tmp_path: Path) -> Memory:
|
||||
return Memory(Vault(tmp_path / "v", {FOLDER: "rw"}), FOLDER)
|
||||
|
||||
|
||||
def facts(world: dict[str, Any]) -> list[str]:
|
||||
events = world["store"].transcript(the_item(world).id, "orchestrator").read()
|
||||
return [e.event.text for e in events if isinstance(e.event, Fact)]
|
||||
|
||||
|
||||
def said(transport: Any) -> list[str]:
|
||||
return [m.text for m in transport.outbound]
|
||||
|
||||
|
||||
def agent(world: dict[str, Any], model: Spy, memory: Memory, **more: Any) -> Any:
|
||||
return loop_agent(
|
||||
world,
|
||||
model,
|
||||
vault=memory._vault,
|
||||
memory=memory,
|
||||
roles=RoleMap(driver="m", helper="m", expert="m").pinned(),
|
||||
**more,
|
||||
)
|
||||
|
||||
|
||||
def test_the_model_remembers_and_forgets_without_asking(
|
||||
world: dict[str, Any], memory: Memory
|
||||
) -> None:
|
||||
model = Spy(
|
||||
[
|
||||
call(
|
||||
"remember",
|
||||
name="uses-split",
|
||||
description="Count words with split",
|
||||
fact="The operator wants str.split, no regex.",
|
||||
),
|
||||
call(
|
||||
"remember",
|
||||
name="old-style",
|
||||
description="Old style",
|
||||
fact="Tabs.",
|
||||
),
|
||||
call("forget", name="old-style", why="the operator changed it"),
|
||||
],
|
||||
RESTATE,
|
||||
)
|
||||
loop, transport = agent(world, model, memory)
|
||||
loop.intake()
|
||||
assert {"remember", "forget"} <= model.offered[0]
|
||||
assert "Remembered: Count words with split" in said(transport)
|
||||
assert "Forgot: old-style (the operator changed it)" in said(transport)
|
||||
index = memory.snapshot().text
|
||||
assert "[[uses-split]]" in index and "old-style" not in index
|
||||
|
||||
|
||||
def test_the_index_is_shown_once_per_item(
|
||||
world: dict[str, Any], memory: Memory
|
||||
) -> None:
|
||||
memory.remember("uses-split", "Count words with split", "f", provenance={})
|
||||
model = Spy(RESTATE, [call("ask_human", question="tabs or spaces?")])
|
||||
loop, transport = agent(world, model, memory)
|
||||
loop.intake()
|
||||
say(transport, world, "go")
|
||||
shown = [f for f in facts(world) if f.startswith("your memory's index")]
|
||||
assert len(shown) == 1
|
||||
assert f"sha {memory.snapshot().sha}" in shown[0]
|
||||
assert "[[uses-split]] — Count words with split" in shown[0]
|
||||
|
||||
|
||||
def test_a_pass_at_the_close_records_what_will_matter(
|
||||
world: dict[str, Any], memory: Memory
|
||||
) -> None:
|
||||
model = Spy(
|
||||
RESTATE,
|
||||
[
|
||||
call(
|
||||
"remember",
|
||||
name="no-count-words",
|
||||
description="The operator dropped count_words",
|
||||
fact="Abandoned: not needed after all.",
|
||||
),
|
||||
call("memory_done", summary="one note"),
|
||||
],
|
||||
)
|
||||
loop, transport = agent(world, model, memory)
|
||||
loop.intake()
|
||||
say(transport, world, "abandon")
|
||||
assert the_item(world).status == "closed"
|
||||
assert "memory pass: recording what this item decided and learned" in facts(world)
|
||||
assert "[[no-count-words]]" in memory.snapshot().text
|
||||
offered = model.offered[-1]
|
||||
assert offered == {
|
||||
"read_note",
|
||||
"list_notes",
|
||||
"search_notes",
|
||||
"remember",
|
||||
"forget",
|
||||
"memory_done",
|
||||
}
|
||||
assert "which abandoned" in model.systems[-1]
|
||||
# Its own transcript: the driver's never holds it.
|
||||
log = world["store"].transcript(the_item(world).id, "driver").read()
|
||||
remembered = {e.actor for e in log if getattr(e.event, "tool", "") == "remember"}
|
||||
assert remembered == {"memory"}
|
||||
|
||||
|
||||
def test_a_pass_that_doesnt_finish_never_holds_up_the_close(
|
||||
world: dict[str, Any], memory: Memory
|
||||
) -> None:
|
||||
prose = [AssistantText(text="hmm", model="m")]
|
||||
model = Spy(RESTATE, prose, prose) # prose twice: the run fails
|
||||
loop, transport = agent(world, model, memory)
|
||||
loop.intake()
|
||||
say(transport, world, "abandon")
|
||||
assert the_item(world).status == "closed"
|
||||
assert any(f.startswith("memory pass didn't finish") for f in facts(world))
|
||||
|
||||
|
||||
def test_the_pass_is_bounded(world: dict[str, Any], memory: Memory) -> None:
|
||||
looking = [call("list_notes", folder="")]
|
||||
model = Spy(RESTATE, *[looking] * 3)
|
||||
loop, transport = agent(world, model, memory, memory_turns=3)
|
||||
loop.intake()
|
||||
say(transport, world, "abandon")
|
||||
assert any("hit the 3-turn cap" in f for f in facts(world))
|
||||
assert not model.turns
|
||||
|
||||
|
||||
def test_a_conversation_can_remember(world: dict[str, Any], memory: Memory) -> None:
|
||||
world["forge"].issues.clear()
|
||||
model = Spy(
|
||||
[call("remember", name="has-4090", description="Has a 4090", fact="f")],
|
||||
[call("reply", text="Noted.")],
|
||||
)
|
||||
_, transport = agent(world, model, memory)
|
||||
transport.receive(DON, "I have a 4090 for local models.")
|
||||
assert "[[has-4090]]" in memory.snapshot().text
|
||||
assert "Remembered: Has a 4090" in said(transport)
|
||||
@@ -53,9 +53,11 @@ from nisse_dev.loop.items import (
|
||||
WAITING,
|
||||
loop_of,
|
||||
)
|
||||
from nisse_dev.memory import Memory
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.tools import Workspace
|
||||
from nisse_dev.transport import InboundMessage, InMemoryTransport
|
||||
from nisse_dev.vault import Vault
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
# How many walks, and from which seed: all 48 by default; CI runs fewer on a
|
||||
@@ -95,6 +97,20 @@ class PolicyModel:
|
||||
"the design has merged" in t for t in facts
|
||||
)
|
||||
path = f"docs/d{self.n}.md" if design else f"f{self.n}.py"
|
||||
if "memory_done" in offered:
|
||||
# The memory pass at a close (M5.2): a note, a forgetting, done.
|
||||
roll = self.rng.random()
|
||||
if roll < 0.3:
|
||||
return [
|
||||
call("remember", name=f"n{self.n % 4}", description="d", fact="f")
|
||||
]
|
||||
if roll < 0.55:
|
||||
return [call("forget", name=f"n{self.n % 4}", why="superseded")]
|
||||
return [call("memory_done", summary="done")]
|
||||
if "remember" in offered and self.rng.random() < 0.06:
|
||||
if self.rng.random() < 0.4:
|
||||
return [call("forget", name=f"n{self.n % 4}", why="superseded")]
|
||||
return [call("remember", name=f"n{self.n % 4}", description="d", fact="f")]
|
||||
if "toolchain_ready" in offered:
|
||||
# A preflight (M4.1): usually reported, sometimes badly or not.
|
||||
if self.rng.random() < 0.15:
|
||||
@@ -326,6 +342,15 @@ class Walk:
|
||||
self.drop_in = 0
|
||||
self.while_down: list[str] = []
|
||||
self.checking = False # the walk's own checks see the real VM
|
||||
# M5.2: the agent's memory, in a vault that also holds the
|
||||
# operator's notes; nothing may land outside its own folder.
|
||||
vault_root = Path(self.world["repo"].root).parent / "vault"
|
||||
(vault_root / "notes").mkdir(parents=True, exist_ok=True)
|
||||
(vault_root / "notes" / "Ops.md").write_text("the operator's\n")
|
||||
self.memory = Memory(
|
||||
Vault(vault_root, {"memory/nisse-dev": "rw", "notes": "ro"}),
|
||||
"memory/nisse-dev",
|
||||
)
|
||||
real = SSHHost.execute
|
||||
walk = self
|
||||
|
||||
@@ -420,6 +445,9 @@ class Walk:
|
||||
preflight=True,
|
||||
preflight_turns=3,
|
||||
markers=str(self.markers),
|
||||
vault=self.memory._vault,
|
||||
memory=self.memory,
|
||||
memory_turns=3,
|
||||
)
|
||||
transport.on_message(agent.on_message)
|
||||
return agent, transport
|
||||
@@ -677,6 +705,17 @@ class Walk:
|
||||
where = f"seed {self.seed}, steps {self.steps}"
|
||||
# M4.1: the driver never works on a project whose VM has no marker.
|
||||
assert not self.unready, f"ready_before_work: {self.unready}: {where}"
|
||||
# M5.2: the agent writes only its own memory folder.
|
||||
root = self.memory._vault.root
|
||||
strays = [
|
||||
str(p.relative_to(root))
|
||||
for p in root.rglob("*")
|
||||
if p.is_file()
|
||||
and not p.is_relative_to(root / "memory" / "nisse-dev")
|
||||
and p != root / "notes" / "Ops.md"
|
||||
]
|
||||
assert not strays, f"memory_stays_home: {strays}: {where}"
|
||||
assert (root / "notes" / "Ops.md").read_text() == "the operator's\n", where
|
||||
# #137: no model works on a project whose build VM is out of reach.
|
||||
assert not self.while_down, f"no_model_on_a_lost_vm: {self.while_down}: {where}"
|
||||
# M4.3: nothing runs for a project whose build VM is blocked.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Agent memory (L6.4, M5.2): one note per fact in the agent's folder, an
|
||||
index the harness keeps, and a snapshot of it for the transcript."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.memory import Memory
|
||||
from nisse_dev.vault import Vault, VaultError, split_frontmatter
|
||||
|
||||
FOLDER = "memory/nisse-dev"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory(tmp_path: Path) -> Memory:
|
||||
return Memory(Vault(tmp_path / "v", {FOLDER: "rw", "notes": "ro"}), FOLDER)
|
||||
|
||||
|
||||
def note(memory: Memory, name: str) -> tuple[dict, str]:
|
||||
path = memory._vault.root / FOLDER / f"{name}.md"
|
||||
return split_frontmatter(path.read_text())
|
||||
|
||||
|
||||
def test_a_fact_is_a_note_with_its_description_and_provenance(memory: Memory) -> None:
|
||||
replaced = memory.remember(
|
||||
"prefers-small-prs",
|
||||
"The operator prefers small PRs",
|
||||
"Don said PRs over 400 lines are hard to review.\n",
|
||||
provenance={"source": "work item x"},
|
||||
)
|
||||
assert not replaced
|
||||
front, body = note(memory, "prefers-small-prs")
|
||||
assert front["description"] == "The operator prefers small PRs"
|
||||
assert front["provenance"]["source"] == "work item x"
|
||||
assert body == "Don said PRs over 400 lines are hard to review.\n"
|
||||
|
||||
|
||||
def test_the_same_name_replaces_and_the_index_follows(memory: Memory) -> None:
|
||||
memory.remember("b-fact", "Bee", "b", provenance={})
|
||||
memory.remember("a-fact", "Ay", "a", provenance={})
|
||||
assert memory.remember("a-fact", "Ay, again", "a2", provenance={})
|
||||
index = memory.snapshot().text
|
||||
assert index.endswith("- [[a-fact]] — Ay, again\n- [[b-fact]] — Bee")
|
||||
memory.forget("a-fact")
|
||||
assert "a-fact" not in memory.snapshot().text
|
||||
with pytest.raises(VaultError, match="no such note"):
|
||||
memory.forget("a-fact")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["index", "Caps", "../x", "a/b", "", "-x"])
|
||||
def test_names_are_plain(memory: Memory, name: str) -> None:
|
||||
with pytest.raises(VaultError, match="a note's name"):
|
||||
memory.remember(name, "d", "f", provenance={})
|
||||
|
||||
|
||||
def test_a_note_needs_a_description_and_a_fact(memory: Memory) -> None:
|
||||
with pytest.raises(VaultError, match="description and the fact"):
|
||||
memory.remember("x", " ", "f", provenance={})
|
||||
|
||||
|
||||
def test_the_snapshot_is_hashed_and_made_if_missing(memory: Memory) -> None:
|
||||
first = memory.snapshot()
|
||||
assert first.path == f"{FOLDER}/index.md"
|
||||
assert "Nothing yet." in first.text
|
||||
assert memory.snapshot().sha == first.sha # same index, same hash
|
||||
memory.remember("x", "Ex", "f", provenance={})
|
||||
assert memory.snapshot().sha != first.sha
|
||||
Reference in new issue
Block a user