From a34f9979509f40b1dfd6f689cfcc21edeaa297b0 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 18 Jul 2026 22:08:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(agent):=20t=C3=A2ches=20longues=20=E2=80=94?= =?UTF-8?q?=20=C3=A9lagage=20contexte=20+=20ctx=2016k=20+=20garde=20SEARXN?= =?UTF-8?q?G=5FURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur un long tour, les gros résultats d'outils (MCP 8 Ko, shell 4 Ko) saturaient num_ctx et Ollama tronquait silencieusement le DÉBUT de la conversation : le modèle perdait la consigne et « oubliait » sa tâche. - élagage : les résultats d'outils des itérations passées sont compactés (350 c. + marqueur), le dernier lot reste intact - num_ctx 16384 par défaut (migration v6 : les profils restés sur les anciens défauts 4096/8192 sont montés, les valeurs perso respectées) - serveur MCP avec paramètre requis manquant (SEARXNG_URL) : refus clair au démarrage au lieu d'un échec cryptique à chaque appel Co-Authored-By: Claude Opus 4.8 --- backend/app/agent.py | 34 ++++++++++++++++++ backend/app/agent_config.py | 15 ++++++-- backend/app/mcp_client.py | 8 +++++ backend/tests/test_long_tasks.py | 62 ++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_long_tasks.py diff --git a/backend/app/agent.py b/backend/app/agent.py index 4f8428a..cb41859 100644 --- a/backend/app/agent.py +++ b/backend/app/agent.py @@ -26,6 +26,28 @@ from .tools import TOOL_DEFINITIONS, ToolError, run_tool MAX_ITERATIONS = 6 MAX_TOOL_REPAIR_ATTEMPTS = 2 +# Élagage : au-delà de cette taille, un résultat d'outil des itérations +# passées est tronqué. Les gros payloads (MCP 8 Ko, shell 4 Ko) saturaient le +# contexte en un seul tour long — Ollama tronquait alors silencieusement le +# DÉBUT de la conversation, faisant « oublier » la consigne au modèle. +_PRUNE_KEEP_CHARS = 350 + + +def _prune_old_tool_results(convo: list[dict], before_index: int) -> None: + """Compacte les résultats d'outils déjà consommés par le modèle. + + Seuls les messages ``tool`` antérieurs à ``before_index`` (donc traités + lors d'une itération précédente) sont tronqués ; le dernier lot reste + intact, c'est celui auquel le modèle répond. + """ + for msg in convo[:before_index]: + content = msg.get("content", "") + if msg.get("role") == "tool" and len(content) > _PRUNE_KEEP_CHARS: + msg["content"] = ( + content[:_PRUNE_KEEP_CHARS] + + "… [résultat archivé — déjà traité, ne pas redemander]" + ) + def _tools_not_supported(exc: OllamaError) -> bool: """Détecte un modèle incapable de function calling. @@ -116,6 +138,9 @@ async def run_agent( stats["eval_duration"] += chunk.get("eval_duration") or 0 stats["prompt_eval_count"] += chunk.get("prompt_eval_count") or 0 + # Index du début du dernier lot de résultats d'outils (à préserver). + last_batch_start = 0 + try: for _ in range(MAX_ITERATIONS): content_buf = "" @@ -123,6 +148,11 @@ async def run_agent( thinking_status_sent = False tool_calls: list[dict] = [] + # Compacte les résultats d'outils des itérations antérieures : + # garde le contexte court, la consigne système jamais tronquée. + if last_batch_start: + _prune_old_tool_results(convo, last_batch_start) + # Un modèle peut savoir discuter sans supporter les outils. Ollama # refuse alors la requête entière : on retente une fois en chat simple. while True: @@ -231,6 +261,10 @@ async def run_agent( if not tool_calls: break + # Les résultats du lot qui suit commencent ici : ils restent + # intacts au prochain tour, les précédents seront compactés. + last_batch_start = len(convo) + # Exécution des outils demandés, puis réinjection des résultats. awaiting_confirmation = False for tc in tool_calls: diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py index e5c5127..7e47e3e 100644 --- a/backend/app/agent_config.py +++ b/backend/app/agent_config.py @@ -10,7 +10,7 @@ from . import db CONFIG_KEY = "agent" MODEL_PROFILES_KEY = "model_profiles" PROFILE_STATE_KEY = "model_profiles_state" -PROFILE_VERSION = 5 +PROFILE_VERSION = 6 DEFAULT_SYSTEM_PROMPT = ( "Tu es Loki, un assistant de développement local agentique. Tu disposes " @@ -75,7 +75,11 @@ DEFAULT_GENERATION: dict = { "top_p": 0.9, "top_k": 40, "max_tokens": 2048, - "num_ctx": 4096, + # 16k : les tâches longues (recherche, multi-fichiers) saturaient 4-8k en + # un seul tour et Ollama tronquait la consigne. Le cache KV d'un 16k reste + # raisonnable (~1-3 Go selon modèle) ; réduis num_ctx dans Configuration + # si la VRAM déborde, ou active OLLAMA_KV_CACHE_TYPE=q8_0 côté Ollama. + "num_ctx": 16384, "num_gpu": -1, "num_batch": 256, } @@ -83,7 +87,7 @@ DEFAULT_GENERATION: dict = { RTX_3060_GEMMA4_PROFILE: dict = { **DEFAULT_GENERATION, "max_tokens": 4096, - "num_ctx": 8192, + "num_ctx": 16384, # num_gpu = -1 : laisse Ollama placer le plus de couches possible sur le GPU # (auto-fit, comme `ollama run`). Forcer un nombre de couches qui ne tient pas # en VRAM fait basculer toute l'inférence sur le CPU. @@ -141,6 +145,11 @@ def _migrate_profiles() -> None: for prof in profiles.values(): if prof.get("num_gpu", -1) is not None and prof.get("num_gpu", -1) > 0: prof["num_gpu"] = -1 + # v6 : contexte 16k par défaut. On ne touche qu'aux profils restés sur un + # ancien défaut (4096/8192) — une valeur personnalisée est respectée. + for prof in profiles.values(): + if prof.get("num_ctx") in (4096, 8192): + prof["num_ctx"] = 16384 db.set_config_value(MODEL_PROFILES_KEY, profiles) db.set_config_value(PROFILE_STATE_KEY, {"version": PROFILE_VERSION}) diff --git a/backend/app/mcp_client.py b/backend/app/mcp_client.py index 2389a33..8141986 100644 --- a/backend/app/mcp_client.py +++ b/backend/app/mcp_client.py @@ -127,6 +127,14 @@ class _ServerConn: raise ValueError("commande du serveur personnalisé vide") else: command = list(entry["command"]) + # Paramètres obligatoires (ex. SEARXNG_URL) : refus clair AVANT le + # lancement, plutôt qu'un échec cryptique à chaque appel d'outil. + missing = [k for k in entry["env_params"] if not params.get(k)] + if missing: + raise ValueError( + f"{', '.join(missing)} requis — renseigne ce champ dans la " + "carte du serveur (Configuration → Serveurs MCP)" + ) env = {k: params[k] for k in entry["env_params"] if params.get(k)} server = StdioServerParameters( command=command[0], args=command[1:], env=env or None diff --git a/backend/tests/test_long_tasks.py b/backend/tests/test_long_tasks.py new file mode 100644 index 0000000..f62aca6 --- /dev/null +++ b/backend/tests/test_long_tasks.py @@ -0,0 +1,62 @@ +import os +import tempfile + +os.environ.setdefault("DATA_DIR", tempfile.mkdtemp()) +os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp()) + +import pytest # noqa: E402 + +from app import agent, db, mcp_client # noqa: E402 + +db.init_db() + + +def test_prune_compacte_les_anciens_resultats(): + big = "x" * 5000 + convo = [ + {"role": "system", "content": "consigne"}, + {"role": "user", "content": "tâche"}, + {"role": "assistant", "content": "", "tool_calls": [{}]}, + {"role": "tool", "tool_name": "read_file", "content": big}, + {"role": "assistant", "content": "", "tool_calls": [{}]}, + {"role": "tool", "tool_name": "grep_search", "content": big}, + ] + # Le dernier lot (index 5) commence à 5 : seul l'index 3 est compacté. + agent._prune_old_tool_results(convo, before_index=5) + assert len(convo[3]["content"]) < 500 + assert "archivé" in convo[3]["content"] + assert convo[5]["content"] == big # lot courant intact + assert convo[0]["content"] == "consigne" # système intact + + +def test_prune_ignore_les_petits_resultats(): + convo = [{"role": "tool", "tool_name": "list_dir", "content": "court"}] + agent._prune_old_tool_results(convo, before_index=1) + assert convo[0]["content"] == "court" + + +@pytest.mark.asyncio +async def test_searxng_sans_url_erreur_claire(): + mgr = mcp_client.McpManager() + try: + result = await mgr.test_server("searxng") + assert result["ok"] is False + assert "SEARXNG_URL" in (result["error"] or "") + assert "requis" in (result["error"] or "") + finally: + await mgr.aclose() + + +def test_migration_v6_monte_les_anciens_defauts(): + from app import agent_config + profiles = db.get_config_value(agent_config.MODEL_PROFILES_KEY) or {} + profiles["testmodel:7b"] = {"num_ctx": 8192} + profiles["custom:7b"] = {"num_ctx": 5000} + db.set_config_value(agent_config.MODEL_PROFILES_KEY, profiles) + db.set_config_value(agent_config.PROFILE_STATE_KEY, {"version": 5}) + + agent_config._migrate_profiles() + + migrated = db.get_config_value(agent_config.MODEL_PROFILES_KEY) + assert migrated["testmodel:7b"]["num_ctx"] == 16384 # ancien défaut monté + assert migrated["custom:7b"]["num_ctx"] == 5000 # valeur perso respectée