- Le moteur n'envoie jamais l'historique (system + objectif courant seulement) : chaque run repartait de zéro. Nouveau service app/services/agent_memory.py — une ligne résumé par conversation (table agent_memory, migration 31), écrite en fin de run et ré-injectée au contexte du run suivant. - context_for() : bloc « ## Mémoire de la conversation », budget 4000 car. (troncature gauche + marqueur). remember() : 1 note/échange (180 → 700 car.), 20 notes max, upsert, erreurs journalisées — un run ne peut pas échouer à cause de la mémoire. Toggle OFF = ni lecture ni écriture. - Toggle « Mémoire » dans le menu + : entrée racine à l'état réel (🧠/💭 + libellé), PATCH /api/agent/conversations/{id} avec memory_enabled ajouté au whitelist, état lu à l'ouverture et à la création, défaut AGENT_MEMORY_DEFAULT (settings.agent_memory_default) + memory_enabled dans la réponse de création. - Décision : mémoire d'espace (lignes workspace_id NULL) remise — YAGNI, rien ne l'écrivait ; noté ponytail: dans le service avec le chemin de montée. - Tests : tests/test_v754_memory.py (8) dont 2 runs réels du moteur (FakeLLM capture le prompt : mémoire présente ON / absente OFF) ; test_v751 et test_v753 adaptés (2 sections « bientôt » restantes). Suite complète 1293 verts (-n auto, 2e run ; 1 échec intermittent sur test_v56_import URL au 1er run, vert isolé + vert au run suivant = flake préexistant), ruff 0, eslint 0 problème. OpenAPI 516 chemins / 7.54.0.
139 lines
5.2 KiB
Python
139 lines
5.2 KiB
Python
"""v7.54.0 — Mémoire de l'agent : toggle par conversation, injection au contexte."""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
from app.db import get_conn
|
|
from app.services import agent_memory
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
|
ENGINE = ROOT / "app" / "services" / "agent_engine.py"
|
|
|
|
|
|
def _conv(client) -> tuple[int, dict]:
|
|
r = client.post("/api/agent/conversations", json={"title": "mémoire"})
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["id"], r.json()
|
|
|
|
|
|
def test_migration_created_column_and_table(client):
|
|
with get_conn() as conn:
|
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_conversations)")}
|
|
assert "memory_enabled" in cols
|
|
tbl = conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='agent_memory'"
|
|
).fetchone()
|
|
assert tbl is not None
|
|
|
|
|
|
def test_create_conversation_defaults_to_memory_on(client):
|
|
_, created = _conv(client)
|
|
assert created["memory_enabled"] == 1 # settings.agent_memory_default
|
|
|
|
|
|
def test_patch_conversation_toggles_memory_persisted(client):
|
|
cid, _ = _conv(client)
|
|
assert client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0}).status_code == 200
|
|
rows = {c["id"]: c for c in client.get("/api/agent/conversations").json()["conversations"]}
|
|
assert rows[cid]["memory_enabled"] == 0
|
|
|
|
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 1})
|
|
rows = {c["id"]: c for c in client.get("/api/agent/conversations").json()["conversations"]}
|
|
assert rows[cid]["memory_enabled"] == 1
|
|
|
|
|
|
def test_context_for_on_off_and_persistence(client):
|
|
cid, _ = _conv(client)
|
|
agent_memory.remember(cid, "Où en est le design system ?", "Les tokens sont dans design-tokens.css.")
|
|
ctx = agent_memory.context_for(cid)
|
|
assert "Mémoire de la conversation" in ctx
|
|
assert "design-tokens.css" in ctx
|
|
|
|
# OFF → plus rien n'est lu ni écrit
|
|
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0})
|
|
assert agent_memory.context_for(cid) == ""
|
|
agent_memory.remember(cid, "Effacé ?", "Ne doit pas apparaître.")
|
|
assert agent_memory.context_for(cid) == ""
|
|
|
|
# ON à nouveau → la mémoire écrite avant le OFF est toujours là (persistée)
|
|
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 1})
|
|
assert "design-tokens.css" in agent_memory.context_for(cid)
|
|
assert "Ne doit pas apparaître" not in agent_memory.context_for(cid)
|
|
|
|
|
|
def test_memory_budget_and_note_lines_bounded(client):
|
|
cid, _ = _conv(client)
|
|
for i in range(30):
|
|
agent_memory.remember(cid, f"question {i} " + "x" * 300, "réponse " + "y" * 900)
|
|
ctx = agent_memory.context_for(cid)
|
|
header = "## Mémoire de la conversation (échanges précédents)"
|
|
assert ctx.startswith(header)
|
|
assert len(ctx) <= agent_memory.INJECT_BUDGET + len(header) + 60
|
|
with get_conn() as conn:
|
|
content = conn.execute(
|
|
"SELECT content FROM agent_memory WHERE conversation_id=?", (cid,)
|
|
).fetchone()["content"]
|
|
assert content.count("\n") + 1 <= agent_memory.NOTE_LINES
|
|
|
|
|
|
async def _drain(engine, cid, objective):
|
|
"""Consomme le générateur SSE de engine.run()."""
|
|
return [ev async for ev in engine.run(cid, objective)]
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self, text=""):
|
|
self.text = text
|
|
self.tool_calls = []
|
|
self.usage = {}
|
|
|
|
|
|
class _RecorderLLM:
|
|
def __init__(self):
|
|
self.last_user = ""
|
|
|
|
async def complete(self, messages, *, model=None, tools=None, stream=False):
|
|
self.last_user = next(m["content"] for m in messages if m["role"] == "user")
|
|
return _Resp("C'est noté.")
|
|
|
|
|
|
def test_engine_run_injects_memory_when_on(client):
|
|
"""Le run réel pousse la mémoire dans le prompt utilisateur (toggle ON)."""
|
|
from app.services.agent_engine import AgentEngine
|
|
|
|
cid, _ = _conv(client)
|
|
agent_memory.remember(cid, "Priorité du sprint", "Livrer le menu + en priorité.")
|
|
fake = _RecorderLLM()
|
|
engine = AgentEngine(1, llm=fake)
|
|
asyncio.run(_drain(engine, cid, "Quelle est la priorité ?"))
|
|
assert "Mémoire de la conversation" in fake.last_user
|
|
assert "menu +" in fake.last_user
|
|
|
|
|
|
def test_engine_run_omits_memory_when_off(client):
|
|
from app.services.agent_engine import AgentEngine
|
|
|
|
cid, _ = _conv(client)
|
|
agent_memory.remember(cid, "Secret", "ne pas injecter")
|
|
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0})
|
|
fake = _RecorderLLM()
|
|
engine = AgentEngine(1, llm=fake)
|
|
asyncio.run(_drain(engine, cid, "Bonjour"))
|
|
assert "Mémoire de la conversation" not in fake.last_user
|
|
assert "ne pas injecter" not in fake.last_user
|
|
|
|
|
|
def test_memory_menu_toggle_wired():
|
|
src = JS_PATH.read_text(encoding="utf-8")
|
|
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
|
assert "action:'memory'" in root
|
|
assert "bientôt (phase 4)" not in root
|
|
# phases 1-4 livrées → connecteurs + plugins restent désactivés
|
|
assert root.count("disabled:true") == 2
|
|
assert "toggleMemory()" in src
|
|
assert "memory_enabled: next" in src
|
|
engine = ENGINE.read_text(encoding="utf-8")
|
|
assert "agent_memory.context_for(" in engine
|
|
assert "agent_memory.remember(" in engine
|