Files
Loki/backend/app/agent.py
T

275 lines
11 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 json
from typing import AsyncIterator
import httpx
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 l'erreur Ollama renvoyée par un modèle sans function calling."""
message = str(exc).lower()
return "does not support tools" in message or "does not support tool" 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,
) -> AsyncIterator[dict]:
# enabled_tools=None -> tous les outils ; liste vide -> aucun outil.
if enabled_tools is None:
tools = TOOL_DEFINITIONS
elif enabled_tools:
tools = [
t for t in TOOL_DEFINITIONS if t["function"]["name"] in enabled_tools
]
else:
tools = None
collected: list[dict] = []
text_parts: list[str] = []
active_tools = tools
tool_fallback_used = False
tool_repair_attempts = 0
request_options = dict(options or {})
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,
stream=True,
):
msg = chunk.get("message", {})
token = msg.get("content", "")
thinking = msg.get("thinking", "")
if thinking:
thinking_buf += 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"):
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 = ""
convo.append(
{
"role": "system",
"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
raise
assistant_turn: dict = {"role": "assistant", "content": content_buf}
if thinking_buf:
assistant_turn["thinking"] = 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
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, stream=True
):
tok = chunk.get("message", {}).get("content", "")
if tok:
final_chunk += tok
yield {"type": "token", "content": tok}
if chunk.get("done"):
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. Essayez un modèle "
"de chat récent ou désactivez son mode de réflexion avancée."
),
}
return
yield {"type": "final", "content": final_content, "tools": collected}