Files
Loki/backend/app/agent.py
T
MichaelandClaude Opus 4.8 d19624b46b refactor(agent): vérification JS via _verify_written (déjà auto après écriture)
L'auto-check séparé dans la boucle agent doublonnait _verify_written et
signalait à tort les fichiers écrits par morceaux (mode append).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:04:47 +02:00

394 lines
16 KiB
Python

"""Boucle agentique : tool-calling itératif au-dessus d'Ollama.
L'agent dialogue avec le modèle ; quand celui-ci demande un outil, on l'exécute,
on réinjecte le résultat, et on reboucle jusqu'à une réponse finale (ou la
limite d'itérations). La fonction est un générateur asynchrone d'événements
relayés tels quels au client via SSE.
Événements émis :
token {content} — fragment de texte de l'agent
tool_call {name, args} — début d'exécution d'un outil
tool_result {name, args, summary, status}
final {content, tools} — réponse complète + récap des outils
"""
from __future__ import annotations
import asyncio
import json
from typing import AsyncIterator
import httpx
from . import coder
from .ollama_client import OllamaError, ollama
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
MAX_ITERATIONS = 6
MAX_TOOL_REPAIR_ATTEMPTS = 2
def _tools_not_supported(exc: OllamaError) -> bool:
"""Détecte un modèle incapable de function calling.
Deux cas :
- Ollama refuse explicitement (« does not support tools ») ;
- Ollama n'arrive pas à dériver un parseur d'appels d'outils du template
du modèle (« Unable to generate parser for this template ») — fréquent
sur des modèles exotiques dont le template Jinja lève une exception.
Dans les deux cas, on retombe sur une conversation simple, sans outils.
"""
message = str(exc).lower()
return (
"does not support tools" in message
or "does not support tool" in message
or "unable to generate parser for this template" in message
or "automatic parser generation failed" in message
)
def _thinking_not_supported(exc: OllamaError) -> bool:
"""Le modèle (ou son template) refuse le paramètre ``think``."""
message = str(exc).lower()
return "does not support thinking" in message or "thinking is not supported" in message
def _invalid_tool_arguments(exc: OllamaError) -> bool:
message = str(exc).lower()
return (
"invalid tool call arguments" in message
or "unexpected end of json" in message
or "failed to parse tool" in message
)
def _parse_args(raw) -> dict:
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
return {}
async def run_agent(
model: str,
convo: list[dict],
*,
options: dict | None = None,
enabled_tools: list[str] | None = None,
confirm_shell: bool = True,
think: bool = True,
keep_alive: str | None = None,
mcp_tools: list[dict] | None = None,
) -> AsyncIterator[dict]:
# enabled_tools=None -> tous les outils ; liste vide -> aucun outil.
if enabled_tools is None:
tools = list(TOOL_DEFINITIONS)
elif enabled_tools:
tools = [
t for t in TOOL_DEFINITIONS if t["function"]["name"] in enabled_tools
]
else:
tools = []
# Outils MCP des serveurs activés (déjà résolus par l'appelant).
tools.extend(mcp_tools or [])
if not tools:
tools = None
collected: list[dict] = []
text_parts: list[str] = []
thinking_parts: list[str] = []
active_tools = tools
tool_fallback_used = False
tool_repair_attempts = 0
request_options = dict(options or {})
# On n'envoie ``think`` que pour le DÉSACTIVER (False) ; laissé à None, le
# modèle garde son comportement par défaut. Repli si le modèle le refuse.
request_think: bool | None = None if think else False
# Métriques cumulées sur tous les appels Ollama du tour agentique : Ollama
# les renvoie dans le chunk final (done=true) de chaque génération.
stats = {"eval_count": 0, "eval_duration": 0, "prompt_eval_count": 0}
def _accumulate(chunk: dict) -> None:
stats["eval_count"] += chunk.get("eval_count") or 0
stats["eval_duration"] += chunk.get("eval_duration") or 0
stats["prompt_eval_count"] += chunk.get("prompt_eval_count") or 0
try:
for _ in range(MAX_ITERATIONS):
content_buf = ""
thinking_buf = ""
thinking_status_sent = False
tool_calls: list[dict] = []
# 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:
try:
async for chunk in ollama.chat(
model,
convo,
tools=active_tools,
options=request_options,
think=request_think,
keep_alive=keep_alive,
stream=True,
):
msg = chunk.get("message", {})
token = msg.get("content", "")
thinking = msg.get("thinking", "")
if thinking:
thinking_buf += thinking
# Diffuse le raisonnement en direct pour l'afficher
# dans le panneau repliable du chat.
yield {"type": "thinking", "content": thinking}
if not thinking_status_sent:
thinking_status_sent = True
yield {"type": "status", "message": "Réflexion…"}
if token:
content_buf += token
yield {"type": "token", "content": token}
if msg.get("tool_calls"):
tool_calls.extend(msg["tool_calls"])
if chunk.get("done"):
_accumulate(chunk)
break
break
except OllamaError as exc:
if (
active_tools
and tool_repair_attempts < MAX_TOOL_REPAIR_ATTEMPTS
and not content_buf
and not tool_calls
and _invalid_tool_arguments(exc)
):
tool_repair_attempts += 1
request_options["num_predict"] = max(
int(request_options.get("num_predict", 0)), 4096
)
thinking_buf = ""
# NB : on réinjecte ce rappel en `user`, pas en `system`.
# Beaucoup de templates (Gemma, Mistral…) lèvent
# « System message must be at the beginning » dès qu'un
# message system apparaît ailleurs qu'en tête, ce qui
# ferait échouer toute la requête en 400.
convo.append(
{
"role": "user",
"content": (
"L'appel d'outil précédent contenait un JSON "
"tronqué. Réessaie immédiatement avec des arguments "
"JSON valides. Pour un fichier long, utilise write_file "
"en plusieurs appels : overwrite puis append, avec des "
"morceaux courts et complets."
),
}
)
yield {
"type": "notice",
"message": (
"Appel d'outil tronqué : nouvelle tentative "
f"{tool_repair_attempts}/{MAX_TOOL_REPAIR_ATTEMPTS}."
),
}
continue
if (
active_tools
and not tool_fallback_used
and not content_buf
and not tool_calls
and _tools_not_supported(exc)
):
active_tools = None
tool_fallback_used = True
yield {
"type": "notice",
"message": (
"Ce modèle ne supporte pas les outils ; "
"réponse en mode conversation simple."
),
}
continue
if request_think is not None and _thinking_not_supported(exc):
# Le modèle n'accepte pas qu'on désactive sa réflexion :
# on retire le paramètre et on relance.
request_think = None
continue
raise
assistant_turn: dict = {"role": "assistant", "content": content_buf}
if thinking_buf:
assistant_turn["thinking"] = thinking_buf
thinking_parts.append(thinking_buf)
if tool_calls:
assistant_turn["tool_calls"] = tool_calls
convo.append(assistant_turn)
if content_buf.strip():
text_parts.append(content_buf.strip())
if not tool_calls:
break
# Exécution des outils demandés, puis réinjection des résultats.
awaiting_confirmation = False
for tc in tool_calls:
fn = tc.get("function", {})
name = fn.get("name", "")
args = _parse_args(fn.get("arguments"))
yield {"type": "tool_call", "name": name, "args": args}
# run_shell est sensible : on demande validation au lieu d'exécuter.
if name == "run_shell" and confirm_shell:
command = args.get("command", "")
record = {
"name": name,
"args": args,
"summary": "validation requise",
"status": "pending",
}
collected.append(record)
yield {"type": "tool_confirm", "name": name, "command": command}
convo.append(
{
"role": "tool",
"tool_name": name,
"content": json.dumps(
{
"ok": False,
"status": "pending",
"message": "Commande en attente de validation "
"de l'utilisateur. N'exécute rien d'autre.",
},
ensure_ascii=False,
),
}
)
awaiting_confirmation = True
continue
# code_task : délégué au moteur code (Aider), long -> thread.
if name == "code_task":
code_model = await coder.pick_code_model(model)
result = await asyncio.to_thread(
coder.run_code_task,
args.get("instruction", ""),
code_model,
args.get("files") or [],
)
summary = result.get("summary", "terminé")
status = "ok" if result.get("ok") else "error"
record = {"name": name, "args": {"instruction": args.get("instruction", "")},
"summary": summary, "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append({
"role": "tool",
"tool_name": name,
"content": json.dumps(
{k: result.get(k) for k in ("ok", "summary", "files", "text")},
ensure_ascii=False,
),
})
continue
# Outils MCP : dispatch asynchrone vers le serveur concerné.
if name.startswith("mcp_"):
from .mcp_client import manager as mcp_manager
mcp_res = await mcp_manager.call_tool(name, args)
status = "ok" if mcp_res["ok"] else "error"
record = {"name": name, "args": args,
"summary": mcp_res["summary"], "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append({
"role": "tool",
"tool_name": name,
"content": json.dumps(
{"ok": mcp_res["ok"], "content": mcp_res["content"]},
ensure_ascii=False,
),
})
continue
try:
result = run_tool(name, args)
summary = result.get("summary", "terminé")
status = result.get("_status", "ok")
except ToolError as exc:
result = {"ok": False, "error": str(exc)}
summary = str(exc)
status = "error"
record = {"name": name, "args": args, "summary": summary, "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append(
{
"role": "tool",
"tool_name": name,
"content": json.dumps(result, ensure_ascii=False),
}
)
# Une commande shell attend une validation : on interrompt la boucle.
if awaiting_confirmation:
# Laisse le modèle conclure son tour (message d'attente).
final_chunk = ""
async for chunk in ollama.chat(
model, convo, options=request_options,
think=request_think, keep_alive=keep_alive, stream=True
):
tok = chunk.get("message", {}).get("content", "")
if tok:
final_chunk += tok
yield {"type": "token", "content": tok}
if chunk.get("done"):
_accumulate(chunk)
break
if final_chunk.strip():
text_parts.append(final_chunk.strip())
break
except OllamaError as exc:
# Échec signalé par Ollama (HTTP ou ligne d'erreur dans le flux) :
# souvent un débordement mémoire / contexte trop grand. On l'expose.
yield {"type": "error", "message": f"Ollama : {exc}"}
return
except (httpx.HTTPError, OSError) as exc:
yield {
"type": "error",
"message": f"Impossible de joindre Ollama ({ollama.host}) : {exc}",
}
return
final_content = "\n\n".join(text_parts).strip()
if not final_content and not collected:
yield {
"type": "error",
"message": (
"Le modèle a terminé sans renvoyer de texte (il n'a produit que "
"du raisonnement). Désactive « Mode réflexion » dans les Réglages, "
"ou essaie un modèle de chat plus récent."
),
}
return
eval_secs = stats["eval_duration"] / 1e9
final_stats = {
"eval_count": stats["eval_count"],
"prompt_eval_count": stats["prompt_eval_count"],
"tokens_per_sec": (
round(stats["eval_count"] / eval_secs, 1) if eval_secs > 0 else None
),
}
yield {
"type": "final",
"content": final_content,
"tools": collected,
"stats": final_stats,
"thinking": "\n\n".join(thinking_parts).strip(),
}