diff --git a/CHANGELOG.md b/CHANGELOG.md index cd849b7..5a74ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog - FlowDeck +## v7.54.0 (2026-10-06) — Menu + : Mémoire de l'agent (phase 4/8) + +### Added + +- **Mémoire de l'agent** — le moteur n'envoie jamais l'historique des messages + (`AgentEngine.run()` ne construit que système + objectif courant) : chaque run + repartait de zéro. Désormais une **ligne résumé par conversation** + (`app/services/agent_memory.py`, table `agent_memory`, migration 31) est + écrite à la fin de chaque run et **ré-injectée au contexte du run suivant** + quand le toggle est activé. + - `context_for()` : bloc « ## Mémoire de la conversation », **budget 4 000 + caractères** (troncature par la gauche avec marqueur) ; + - `remember()` : 1 note par échange (objectif 180 car. → réponse 700 car.), + **20 notes max**, upsert, et **jamais une erreur ne fait échouer un run** ; + - OFF = ni lecture ni écriture (contexte strict du run courant). +- **Toggle « Mémoire » dans le menu +** — entrée racine à l'état réel + (🧠 activée / 💭 désactivée avec le libellé correspondant), bascule via + `PATCH /api/agent/conversations/{id}` (`memory_enabled` ajouté au whitelist), + état persisté et reflété à l'ouverture/à la création de la conversation. +- **`agent_conversations.memory_enabled`** (migration **31**, défaut `1`) + + config **`AGENT_MEMORY_DEFAULT`** (`settings.agent_memory_default`) appliquée + à la création d'une conversation ; la réponse de création expose la valeur. + +### Tests + +- `tests/test_v754_memory.py` — **8 tests** : colonne + table créées par la + migration, défaut ON à la création, toggle persisté relu en base, + injection ON / absence OFF / persistance à travers un cycle OFF→ON, + budget et nombre de notes bornés, **2 runs réels du moteur** (FakeLLM qui + capture le prompt : mémoire présente ON, absente OFF), câblage front/back. +- `test_v751` + `test_v753` adaptés : **2 sections « bientôt »** restantes + (connecteurs, plugins). +- **Suite complète : 1293 passed / 0 failed** (`-n auto`), `ruff` 0, + `eslint` 0 problème. + + ## v7.53.0 (2026-10-06) — Menu + : Design System – Canevas (phase 3/8) ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 594dc9c..ea9a19c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -284,7 +284,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB --- -## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-3 ✅ 2026-10-06 · phases 4-8 planifiées) +## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-4 ✅ 2026-10-06 · phases 5-8 planifiées) > **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition) > un menu à sections, tel que demandé : @@ -390,17 +390,31 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j. - [x] **Livraison** — `VERSION` + `app/main.py` = **7.53.0**, OpenAPI régénéré (516 chemins), CHANGELOG, ce ROADMAP -### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M +### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M ✅ (livrée 2026-10-06) -- [ ] **Table `agent_memory`** (`workspace_id`, `kind` summary|fact, `content`, - `updated_at`) + colonne `conversations.memory_enabled` -- [ ] **Service `app/services/agent_memory.py`** — extraction en fin de run (résumé + - faits saillants ; LLM sans outils, repli déterministe hors-ligne) -- [ ] **Injection dans `ContextBuilder` si le toggle est ON** ; OFF = contexte strict - de la conversation courante -- [ ] **Toggle « Mémoire »** dans le menu + : état visible, persisté par conversation, - défaut `AGENT_MEMORY_DEFAULT` -- [ ] **Tests** — injection ON / absence OFF / persistance / budget de tokens borné +- [x] **Table `agent_memory`** — **une ligne résumé par conversation** + (`conversation_id` PK, upsert) + colonne `agent_conversations.memory_enabled` + (migration **31**, défaut `1`) ; la variante « mémoire d'espace » prévue au + design est **remise** (YAGNI : rien ne l'écrit — `ponytail:` noté dans le service) +- [x] **Service `app/services/agent_memory.py`** — **extraction déterministe** + (pas d'LLM) : 1 note par échange (objectif 180 car. → réponse 700 car.), + 20 notes max, écrite en fin de run via `agent_memory.remember()` ; toute + erreur est journalisée, **un run ne peut pas échouer à cause de la mémoire** +- [x] **Injection** — `AgentEngine.run()` appelle `agent_memory.context_for()` après + `ContextBuilder.build()` : bloc « ## Mémoire de la conversation », **budget + 4 000 caractères** (troncature par la gauche + marqueur) ; OFF = ni lecture ni + écriture → contexte strict du run courant +- [x] **Toggle « Mémoire »** dans le menu + — entrée racine à l'état réel + (🧠/💭 + libellé), `PATCH /api/agent/conversations/{id}` (`memory_enabled` + ajouté au whitelist), état lu à l'ouverture et à la création, défaut + `AGENT_MEMORY_DEFAULT` (config + réponse de création) +- [x] **Tests** — `tests/test_v754_memory.py` : **8 tests** dont **2 runs réels du + moteur** (FakeLLM capture le prompt : mémoire présente ON / absente OFF), + budget et nombre de notes bornés, toggle relu en base ; `test_v751` + + `test_v753` adaptés (2 sections « bientôt ») ; **suite complète 1293 verts**, + `ruff` 0, `eslint` 0 problème +- [x] **Livraison** — `VERSION` + `app/main.py` = **7.54.0**, OpenAPI régénéré + (516 chemins), CHANGELOG, ce ROADMAP ### Phase 5 — v7.55.0 — Connecteurs : socle · effort L @@ -442,10 +456,9 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j. --- -*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 3 livrées le -2026-10-06 (v7.51.0, v7.52.0, v7.53.0)**, phases 4-8 à valider une par une avant -« go ». Chaque phase se clôt par tests verts, bump de version, CHANGELOG et -ROADMAP à jour.* +*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 4 livrées le +2026-10-06 (v7.51.0 → v7.54.0)**, phases 5-8 à valider une par une avant « go ». +Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.* --- diff --git a/VERSION b/VERSION index b02dd46..ccb032e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.53.0 +7.54.0 diff --git a/app/config.py b/app/config.py index a64158a..bd2bd11 100644 --- a/app/config.py +++ b/app/config.py @@ -126,6 +126,11 @@ class Settings(BaseSettings): agent_max_tokens_budget: int = 500000 agent_run_timeout_seconds: int = 300 + # ── Mémoire de l'agent (v7.54.0) ── + # État initial du toggle « Mémoire » à la création d'une conversation ; + # le basculement se fait ensuite par PATCH /api/agent/conversations/{id}. + agent_memory_default: bool = True + # ── Web tools de l'agent (web_search / fetch_url / search_code) ── # `web_search_provider` : « exa » (recherche sémantique + snippets, clé # requise) ou « duckduckgo » (repli sans compte, parsing HTML — dégradé). diff --git a/app/main.py b/app/main.py index 3d01495..b51a843 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.53.0", + version="7.54.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/migrations.py b/app/migrations.py index 8fbfb38..19a24fd 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -1584,3 +1584,25 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_collections_task " "ON collections(is_task, workspace_id)" ) + + +@register(31, "v7.54.0: agent memory (mémoire de l'agent)") +def _migration_agent_memory(conn: sqlite3.Connection) -> None: + """Toggle par conversation + table des résumés mémorisés. + + Une ligne résumé par conversation (upsert) — voir `app/services/agent_memory.py`. + """ + if "memory_enabled" not in columns(conn, "agent_conversations"): + conn.execute( + "ALTER TABLE agent_conversations " + "ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS agent_memory ( + conversation_id INTEGER PRIMARY KEY + REFERENCES agent_conversations(id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'summary', + content TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )""" + ) diff --git a/app/routers/agent.py b/app/routers/agent.py index 1cc0d9e..76dc90f 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -199,15 +199,17 @@ def create_conversation(request: Request, body: dict = Body(default={})): ws = _workspace_id(request) with get_conn() as conn: agent = _default_agent(conn, user_id) + mem_default = 1 if settings.agent_memory_default else 0 cur = conn.execute( - """INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model) - VALUES (?,?,?,?,?,?)""", + """INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model, memory_enabled) + VALUES (?,?,?,?,?,?,?)""", (agent["id"], user_id, body.get("title", "New conversation"), json.dumps({"workspace_id": ws}), - body.get("provider", ""), body.get("model", "")), + body.get("provider", ""), body.get("model", ""), mem_default), ) conn.commit() - return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"} + return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), + "status": "created", "memory_enabled": mem_default} @router.get("/conversations/{conversation_id}") @@ -249,6 +251,10 @@ def patch_conversation(request: Request, conversation_id: int, body: dict = Body if body.get(col) is not None: sets.append(f"{col}=?") params.append(str(body[col])) + # v7.54.0 — toggle « Mémoire » de la conversation (0/1). + if body.get("memory_enabled") is not None: + sets.append("memory_enabled=?") + params.append(1 if body["memory_enabled"] else 0) params.append(conversation_id) conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params) conn.commit() diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index a80202b..1190dfa 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -18,6 +18,7 @@ import re from app.config import settings from app.db import get_conn +from app.services import agent_memory from app.services.agent_policies import check_tool, get_policy from app.services.context_builder import ContextBuilder from app.services.llm_client import LLMClient @@ -156,6 +157,11 @@ class AgentEngine: skills = [s for s in (self._load_skill(i, scope) for i in ids) if s] system = self._build_system_prompt(agent, skills) context = self.ctx.build(mentions=mentions, files=files) + # v7.54.0 — mémoire de la conversation (toggle) : le moteur n'envoie + # jamais l'historique, sans elle chaque run repart de zéro. + memory = agent_memory.context_for(conversation_id) + if memory: + context = (context + "\n\n" + memory) if context else memory if extra_context and extra_context.strip(): context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip() @@ -304,6 +310,8 @@ class AgentEngine: self._persist_message(conversation_id, "assistant", final_text, model=used_model, tokens=self._tokens) await self._autotitle(conversation_id, objective, final_text) + # v7.54.0 — memorise l'echange (no-op si le toggle memoire est OFF). + agent_memory.remember(conversation_id, objective, final_text) # v6.4.0: emit agent.run.finished (outbound webhooks only). await _fire_agent_webhook("agent.run.finished", { "conversation_id": conversation_id, diff --git a/app/services/agent_memory.py b/app/services/agent_memory.py new file mode 100644 index 0000000..5b7547a --- /dev/null +++ b/app/services/agent_memory.py @@ -0,0 +1,89 @@ +"""Mémoire de l'agent (v7.54.0). + +Une ligne résumé **par conversation**, mise à jour à chaque run et ré-injectée +au contexte du run suivant — le moteur n'envoie jamais l'historique des messages +(`AgentEngine.run()` ne construit que system + objectif courant), donc sans +mémoire chaque run repart de zéro. + +Toggle : colonne `agent_conversations.memory_enabled` — OFF = aucune lecture ni +écriture (contexte strict du run courant). + +ponytail : mémoire par conversation seulement. Si besoin de notes partagées +entre conversations, ajouter des lignes avec ``conversation_id NULL`` + +``workspace_id`` et élargir la lecture dans ``context_for()``. +""" + +from __future__ import annotations + +import logging +import sqlite3 + +from app.db import get_conn + +logger = logging.getLogger(__name__) + +INJECT_BUDGET = 4000 # caractères de mémoire max injectés dans un prompt +NOTE_LINES = 20 # échanges max conservés +NOTE_OBJECTIVE = 180 # troncature de l'objectif +NOTE_ANSWER = 700 # troncature de la réponse + + +def _enabled(conn: sqlite3.Connection, conversation_id: int) -> bool: + row = conn.execute( + "SELECT memory_enabled FROM agent_conversations WHERE id=?", + (conversation_id,), + ).fetchone() + if row is None: + return False + return bool(row["memory_enabled"]) + + +def _one_line(text: str, limit: int) -> str: + return " ".join((text or "").split())[:limit] + + +def context_for(conversation_id: int) -> str: + """Bloc « mémoire » à préfixer au contexte, ou ``''`` (toggle OFF / vide).""" + with get_conn() as conn: + if not _enabled(conn, conversation_id): + return "" + row = conn.execute( + "SELECT content FROM agent_memory WHERE conversation_id=?", + (conversation_id,), + ).fetchone() + content = ((row["content"] if row else "") or "").strip() + if not content: + return "" + if len(content) > INJECT_BUDGET: + content = "…(début de mémoire tronqué)\n" + content[-INJECT_BUDGET:] + return "## Mémoire de la conversation (échanges précédents)\n" + content + + +def remember(conversation_id: int, objective: str, final_text: str) -> None: + """Ajoute un échange à la mémoire — no-op si le toggle est OFF. + + Ne doit **jamais** faire échouer un run : toute erreur est journalisée. + """ + note = f"- **{_one_line(objective, NOTE_OBJECTIVE)}** → {_one_line(final_text, NOTE_ANSWER)}" + try: + with get_conn() as conn: + if not _enabled(conn, conversation_id): + return + row = conn.execute( + "SELECT content FROM agent_memory WHERE conversation_id=?", + (conversation_id,), + ).fetchone() + lines = [ln for ln in ((row["content"] if row else "") or "").split("\n") if ln.strip()] + lines.append(note) + if len(lines) > NOTE_LINES: + lines = lines[-NOTE_LINES:] + conn.execute( + "INSERT INTO agent_memory (conversation_id, content, updated_at) " + "VALUES (?, ?, CURRENT_TIMESTAMP) " + "ON CONFLICT(conversation_id) DO UPDATE SET " + "content=excluded.content, updated_at=CURRENT_TIMESTAMP", + (conversation_id, "\n".join(lines)), + ) + conn.commit() + except Exception: # noqa: BLE001 — la mémoire ne casse jamais un run + logger.exception("Agent memory save failed for conversation #%s", conversation_id) diff --git a/docs/V74_Agent_Plus_Menu.md b/docs/V74_Agent_Plus_Menu.md index 6b091ba..53bd8eb 100644 --- a/docs/V74_Agent_Plus_Menu.md +++ b/docs/V74_Agent_Plus_Menu.md @@ -1,7 +1,9 @@ # V74 — Menu + de l'assistant : hub de contexte (design) -> **Statut** : design — **phases 1 à 3 livrées (v7.51.0 → v7.53.0, 2026-10-06)** ; -> phases 4-8 restent à livrer. Adaptations faites en cours de route : le parcours +> **Statut** : design — **phases 1 à 4 livrées (v7.51.0 → v7.54.0, 2026-10-06)** ; +> phases 5-8 restent à livrer. La **mémoire** a été simplifiée par rapport au +> modèle ici : une seule ligne résumé **par conversation** (pas de lignes +> `workspace_id NULL` — rien ne les écrivait), extraction déterministe sans LLM. Adaptations faites en cours de route : le parcours > utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat > dossier = `icon === 'folder'`), la gestion des skills est une **section du menu > avec formulaire intégré** (pas une modale — `PATCH /api/agent/skills/{id}` diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index d767426..7dca7fc 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.53.0" + "version": "7.54.0" }, "paths": { "/auth/register": { diff --git a/static/js/agent_panel_2.js b/static/js/agent_panel_2.js index 346ac9b..1e09302 100644 --- a/static/js/agent_panel_2.js +++ b/static/js/agent_panel_2.js @@ -46,7 +46,7 @@ {key:'plugins', icon:'🧩', label:'Add plugins', sub:'Modules on/off — bientôt (phase 8)', disabled:true}, {key:'memory', icon:'🧠', label:'Mémoire (on/off)', - sub:'Mémoire persistante — bientôt (phase 4)', disabled:true} + sub:'Résumés des échanges injectés à chaque run', action:'memory'} ]; var FD_PLUS_FILES = [ {key:'f-search', icon:'🔍', label:'Rechercher…', @@ -646,7 +646,8 @@ if(self.llmModel) body.model = self.llmModel; fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)}) .then(function(r){return r.json()}).then(function(d){ - var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()}; + var conv = {id:d.id, title:d.title, memory_enabled:d.memory_enabled, + updated_at:new Date().toISOString()}; self.conversations.unshift(conv); self.currentConv = conv; self.messages = []; self.errorMsg=''; self.notice=''; self.tab='chat'; self.contextChips = []; self.mentionOpen = false; @@ -663,7 +664,8 @@ self._ensuring = fetch('/api/agent/conversations', { method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r){return r.json()}).then(function(d){ - var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()}; + var conv = {id:d.id, title:d.title, memory_enabled:d.memory_enabled, + updated_at:new Date().toISOString()}; self.conversations.unshift(conv); self.currentConv = conv; self.messages = []; self.errorMsg=''; self.notice=''; self.tab='chat'; self.contextChips = []; self.mentionOpen = false; @@ -1140,8 +1142,44 @@ if(s === 'skills-edit') return []; // formulaire, pas de liste if(s === 'canvases') return this._canvasItems(); if(s === 'canvas') return this._canvasActionItems(); + if(s === 'root'){ + // l'entrée Mémoire reflète l'état réel du toggle de la conversation + var on = this.memoryOn; + return FD_PLUS_MENU.map(function(it){ + if(it.key !== 'memory') return it; + return {key:'memory', icon: on ? '🧠' : '💭', label:'Mémoire (on/off)', + sub: on ? 'Activée — résumés des échanges injectés à chaque run' + : 'Désactivée — contexte strict du run courant', + action:'memory'}; + }); + } return FD_PLUS_MENU; }, + // État du toggle mémoire de la conversation courante (défaut : activée). + get memoryOn(){ + if(!this.currentConv) return true; + var v = this.currentConv.memory_enabled; + return v === undefined || v === null ? true : !!Number(v); + }, + // Bascule le toggle mémoire (PATCH /api/agent/conversations/{id}). + toggleMemory(){ + var self = this, conv = this.currentConv; + if(!conv || !conv.id){ + this.toast('Ouvrez une conversation pour activer la mémoire.', true); + return; + } + var next = this.memoryOn ? 0 : 1; + fetch('/api/agent/conversations/' + conv.id, { + method: 'PATCH', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'}, + body: JSON.stringify({memory_enabled: next}) + }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); }) + .then(function(res){ + if(!res.ok){ self.toast((res.d && res.d.detail) || 'Bascule impossible.', true); return; } + conv.memory_enabled = next; + self.toast(next ? 'Mémoire activée — résumés injectés à chaque run.' + : 'Mémoire désactivée — contexte strict du run courant.'); + }).catch(function(){ self.toast('Bascule impossible (réseau).', true); }); + }, // Sous-menu Compétences : builtin (alias affiché) + installés, puis les 2 actions. _skillsItems(){ var out = []; @@ -1319,6 +1357,7 @@ this.plusSection = 'browse'; this.plusCrumb = []; this.plusLoadBrowse(null); return; } // ── Compétences (v7.52.0) ── + if(it.action === 'memory'){ this.toggleMemory(); return; } if(it.action === 'skills'){ this.plusSection = 'skills'; this.plusFocus = 0; return; } if(it.action === 'skill'){ this.closePlus(); this.useSuggestion(it.suggestion); return; } if(it.action === 'skills-manage'){ diff --git a/tests/test_v751_plus_menu.py b/tests/test_v751_plus_menu.py index 7f296dd..acc1a89 100644 --- a/tests/test_v751_plus_menu.py +++ b/tests/test_v751_plus_menu.py @@ -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 diff --git a/tests/test_v753_canvases.py b/tests/test_v753_canvases.py index b5c3566..d4b20e9 100644 --- a/tests/test_v753_canvases.py +++ b/tests/test_v753_canvases.py @@ -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 diff --git a/tests/test_v754_memory.py b/tests/test_v754_memory.py new file mode 100644 index 0000000..37c4b42 --- /dev/null +++ b/tests/test_v754_memory.py @@ -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