Files
nisse/nisse-dev/tests/test_loop_memory.py
T
dongarandClaude Opus 5.5 fe4548459f
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m57s
CI / e2e (pull_request) Successful in 3m49s
M5.3: scope memory
A project's notes have their own folder, memory/nisse-dev/projects/<project>/,
with its own index; remember and forget take scope (general, or project:
always the current item's own, so no item writes another project's notes,
and a conversation has none). At an item's start the transcript gets the
general index and its project's, never another's. Index links are pages
from the vault's root, as SilverBullet names them.

The closing pass's memory_done can name repository documents the item left
out of date; the harness says so in the thread and records it. The prompt:
the repository is the authority, and a note that disagrees is corrected.

With the operator: ARCHITECTURE.md isn't put into every item's context (its
size); the infrastructure world model waits for nisse-ops. The walk writes
project notes and checks they come only from that project's items.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-08 23:33:12 +00:00

236 lines
7.9 KiB
Python

"""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 "[[memory/nisse-dev/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 "[[memory/nisse-dev/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 "[[memory/nisse-dev/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 "[[memory/nisse-dev/has-4090]]" in memory.snapshot().text
assert "Remembered: Has a 4090" in said(transport)
def test_a_project_note_goes_with_the_items_project(
world: dict[str, Any], memory: Memory
) -> None:
memory.remember("other-fact", "Other's", "o", provenance={}, project="other")
memory.remember("old", "Sandbox's", "s", provenance={}, project="sandbox")
model = Spy(
[
call(
"remember",
name="deploys-by-tag",
description="Sandbox deploys by tag",
fact="f",
scope="project",
),
call("remember", name="x", description="d", fact="f", scope="team"),
],
RESTATE,
)
loop, _ = agent(world, model, memory)
loop.intake()
note = memory._vault.read(f"{FOLDER}/projects/sandbox/deploys-by-tag.md")
assert "project: sandbox" in note
[shown] = [f for f in facts(world) if f.startswith("your memory's index")]
assert "projects/sandbox/old]]" in shown # this project's notes
assert "other-fact" not in shown # never another's
results = [
e.event.result
for e in world["store"].transcript(the_item(world).id, "driver").read()
if hasattr(e.event, "result")
]
assert "scope is general or project, not 'team'" in results
def test_a_conversation_has_no_project_scope(
world: dict[str, Any], memory: Memory
) -> None:
world["forge"].issues.clear()
model = Spy(
[call("remember", name="x", description="d", fact="f", scope="project")],
[call("reply", text="Noted.")],
)
_, transport = agent(world, model, memory)
transport.receive(DON, "remember this")
assert not (memory._vault.root / FOLDER / "projects").exists()
log = world["store"].all_events()
assert any(
getattr(e.event, "result", "") == "a conversation has no project: use general"
for e in log
)
def test_the_pass_flags_documents_left_out_of_date(
world: dict[str, Any], memory: Memory
) -> None:
model = Spy(
RESTATE,
[
call(
"memory_done",
summary="nothing new",
docs="ARCHITECTURE.md still describes the old text module",
)
],
)
loop, transport = agent(world, model, memory)
loop.intake()
say(transport, world, "abandon")
line = (
"Docs to update in don/sandbox: ARCHITECTURE.md still describes the old "
"text module"
)
assert line in said(transport)
assert line in facts(world)