Files
nisse/nisse-dev/tests/test_loop_memory.py
dongarandClaude Opus 5.5 e351d0a74e
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m37s
CI / e2e (pull_request) Successful in 4m5s
nisse-core: group modules into packages by architecture section
A pure move; behaviour unchanged.

- models (L9, L7): llm, catalog, probe, render, render_anthropic, context
- log (L7, L6): canonical, views
- state (L8): store (was state), reconcile, worker, cancel, dump
- knowledge (L10, L6.4): vault, memory
- conversation (L5): transport, matrix
- execution (L13): ssh (was execution), testing
- authority (P3, P4, L4): actions
- gitea, oplog, envfile stay at the top until each gains a second module.

Every import, monkeypatch target, script and doc reference rewritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-13 21:16:09 +00:00

236 lines
8.0 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_core.knowledge.memory import Memory
from nisse_core.knowledge.vault import Vault
from nisse_core.log.canonical import AssistantText, Fact
from nisse_core.models.llm import RoleMap
FOLDER = "memory/nisse-dev"
@pytest.fixture
def memory(tmp_path: Path) -> Memory:
return Memory(Vault(tmp_path / "v", {FOLDER: "rw"}, agent="nisse-dev"), 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)