mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-13 10:17:58 +02:00
UI : reskin complet vers un theme neo-brutaliste clair (bordures noires 3px, ombres dures, accent orange, polices Press Start 2P + DM Mono). - tailwind.config / index.css / index.html : palette, polices, ombres, scrollbars - TopBar, ActivityBar, LeftPanel, ChatPanel, PreviewPanel, SettingsView, ToolCard, ModelSelector, Icon : restyle complet Chat : panneau « Raisonnement » repliable et redimensionnable affichant le thinking de l'agent en direct (diffuse depuis le backend, persiste dans meta). Robustesse Ollama : les echecs de generation de parseur d'outils (« Unable to generate parser for this template ») declenchent desormais le repli automatique en conversation simple sans outils, au lieu d'un 400. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
323 lines
13 KiB
Python
323 lines
13 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 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 _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] = []
|
|
thinking_parts: list[str] = []
|
|
active_tools = tools
|
|
tool_fallback_used = False
|
|
tool_repair_attempts = 0
|
|
request_options = dict(options or {})
|
|
|
|
# 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,
|
|
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
|
|
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
|
|
|
|
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"):
|
|
_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. Essayez un modèle "
|
|
"de chat récent ou désactivez son mode de réflexion avancée."
|
|
),
|
|
}
|
|
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(),
|
|
}
|