mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
fix(agent): tâches longues — élagage contexte + ctx 16k + garde SEARXNG_URL
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
71f8ba0d03
commit
a34f997950
4 files changed
+116
-3
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user