feat: menu + — Mémoire de l'agent — phase 4/8 (v7.54.0)
- 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.
This commit is contained in:
@@ -104,8 +104,8 @@ def test_plus_menu_sections_and_disabled_states():
|
||||
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
|
||||
assert f"key:{key}" in root, key
|
||||
assert "action:'files'" in root and "action:'skills'" in root
|
||||
# phases 1-3 livrées → il reste 3 sections « bientôt » (connecteurs, plugins, mémoire)
|
||||
assert root.count("disabled:true") == 3
|
||||
# phases 1-4 livrées → il reste 2 sections « bientôt » (connecteurs, plugins)
|
||||
assert root.count("disabled:true") == 2
|
||||
|
||||
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'search'" in files and "action:'browse'" in files
|
||||
|
||||
@@ -64,8 +64,8 @@ def test_canvases_menu_wired(client):
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'canvases'" in root
|
||||
# phases 1-3 livrées → 3 sections « bientôt » restantes (connecteurs, plugins, mémoire)
|
||||
assert root.count("disabled:true") == 3
|
||||
# phases 1-4 livrées → 2 sections « bientôt » restantes (connecteurs, plugins)
|
||||
assert root.count("disabled:true") == 2
|
||||
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
|
||||
assert action in src, action
|
||||
# piège /use : la clé builtin part DANS LE BODY, pas en query string
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user