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:
@@ -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
|
||||
|
||||
+28
-15
@@ -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.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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é).
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)"""
|
||||
)
|
||||
|
||||
+10
-4
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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}`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.53.0"
|
||||
"version": "7.54.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -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'){
|
||||
|
||||
@@ -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