feat(stt): Asa agentique — boucle d'outils (function calling, Phase 1)

Asa ne répondait qu'à partir d'un contexte figé (RAG doc ou blocs live
pré-câblés, à présélectionner) → les questions à état live (« gpu-01 tourne
bien ? ») tombaient sur « la doc ne le précise pas ». Bascule vers une boucle
de function-calling : le modèle décide quels outils appeler puis répond à
partir de leurs résultats.

- Nouveau contexte `asa` (STT_DEFAULT_CONTEXT=asa, défaut prod) + tools.py
  (registre schémas OpenAI + exécuteurs) + brain.ask_with_tools (boucle bornée
  STT_TOOL_MAX_ITERS=4, réponse forcée au-delà ; _post factorisé + retry).
- Outils Phase 1, LECTURE SEULE : search_docs (RAG funk-docs), host_health
  (gpu-01|storage-01 : up/charge/RAM + llama-server), cluster_status,
  prometheus_query (PromQL arbitraire). Réutilisent sources.py / knowledge.py.
- Trace des outils renvoyée dans `context` → visualiseur HUD (un bloc par appel).
- 100 % local (Qwen3-8B) : tool-calling natif llama.cpp validé, survit au
  /no_think. LiteLLM transmet bien les tools. PromQL validés contre le vrai
  Prometheus in-cluster. 3 tests unitaires de la boucle (hors-ligne).

Web search (SearXNG in-cluster) = Phase 2 ; actions admin pilotées par le LLM
(hermes-exec comme outil) = Phase 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-22 21:45:44 +02:00
parent de1cd6c407
commit 19773a837a
11 changed files with 566 additions and 42 deletions

View file

@ -7,6 +7,9 @@ selon l'alias `hermes-default` / `hermes-switch`. L'intégration des outils Herm
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
import httpx
from stt_server.config import settings
@ -29,6 +32,47 @@ async def aclose() -> None:
_client = None
async def _post(payload: dict) -> dict:
"""POST vers LiteLLM avec retry sur blip transitoire ; renvoie le message du 1er choix.
Résilience : la chaîne LiteLLMllama-server (ROCm) se fige par à-coups. Une 2 tentative
récupère un blip transitoire (ex. connexion keep-alive périmée) au lieu de renvoyer 502.
Timeout PAR tentative (`request_timeout`) on ne dépasse pas ~2× ce budget au pire.
"""
headers = {"Authorization": f"Bearer {settings.litellm_key}"}
last_exc: Exception | None = None
for _ in range(2):
try:
r = await _get_client().post(
settings.litellm_url, json=payload, headers=headers,
timeout=settings.request_timeout,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]
except httpx.HTTPError as e:
last_exc = e
await aclose() # repart sur une connexion neuve (pool potentiellement vicié)
raise last_exc # type: ignore[misc]
def _content_of(msg: dict) -> str:
"""content du message ; repli sur reasoning_content (modèle thinking → content vide)."""
content = (msg.get("content") or "").strip()
if not content:
content = (msg.get("reasoning_content") or "").strip()
return content
def _with_no_think(system: str) -> str:
if settings.disable_thinking:
# Qwen3 est un modèle « thinking » : sans ça il dépense tout le budget max_tokens
# en raisonnement (`reasoning_content`) → `content` vide, ou part en réflexion longue
# → timeout (502). Le token `/no_think` désactive le raisonnement (le tool-calling
# survit — vérifié). Inoffensif pour les modèles non-Qwen (simple texte ignoré).
return system + "\n/no_think"
return system
async def ask(
text: str,
system: str,
@ -40,46 +84,87 @@ async def ask(
Le choix du contexte, l'injection des sources live et du RAG sont faits en amont
(app.v1_ask + contexts) ; ici on ne fait que le réglage thinking + l'appel LLM.
"""
if settings.disable_thinking:
# Qwen3 est un modèle « thinking » : sans ça il dépense tout le budget max_tokens
# en raisonnement (`reasoning_content`) → `content` vide, ou part en réflexion longue
# → timeout (502). Le token de contrôle `/no_think` désactive le mode raisonnement.
# Inoffensif pour les modèles non-Qwen (simple texte ignoré).
system += "\n/no_think"
messages = [{"role": "system", "content": system}]
messages = [{"role": "system", "content": _with_no_think(system)}]
if history:
messages += history
messages.append({"role": "user", "content": text})
payload = {
msg = await _post({
"model": model or settings.model,
"messages": messages,
"max_tokens": settings.max_tokens,
"temperature": settings.temperature,
}
headers = {"Authorization": f"Bearer {settings.litellm_key}"}
# Résilience : la chaîne LiteLLM→llama-server (ROCm) se fige par à-coups. Une 2ᵉ tentative
# récupère un blip transitoire (ex. connexion keep-alive périmée) au lieu de renvoyer 502.
# Timeout PAR tentative (`request_timeout`) → on ne dépasse pas ~2× ce budget au pire.
last_exc: Exception | None = None
for attempt in range(2):
try:
r = await _get_client().post(
settings.litellm_url,
json=payload,
headers=headers,
timeout=settings.request_timeout,
)
r.raise_for_status()
break
except httpx.HTTPError as e:
last_exc = e
await aclose() # repart sur une connexion neuve (pool potentiellement vicié)
else:
raise last_exc # type: ignore[misc]
msg = r.json()["choices"][0]["message"]
# Filet de sécurité : si un modèle « thinking » renvoie un content vide (tout parti
# en reasoning_content), on récupère le raisonnement plutôt que de renvoyer "".
content = (msg.get("content") or "").strip()
if not content:
content = (msg.get("reasoning_content") or "").strip()
return content
})
return _content_of(msg)
# dispatch(name, args) -> texte du résultat d'outil. Fourni par app.py (closure sur les deps).
Dispatch = Callable[[str, dict], Awaitable[str]]
async def ask_with_tools(
text: str,
system: str,
*,
schemas: list[dict],
dispatch: Dispatch,
model: str | None = None,
history: list[dict] | None = None,
) -> tuple[str, list[dict]]:
"""Boucle de function-calling : le modèle appelle des outils jusqu'à pouvoir répondre.
Renvoie (réponse, trace) `trace` = [{name, args, result}] des outils exécutés
(alimente le visualiseur du HUD). Borné par `tool_max_iters` ; au-delà, un dernier
appel SANS outils force une réponse texte. Si aucun schéma n'est fourni, repli sur `ask`.
"""
if not schemas:
return await ask(text, system, model, history), []
messages: list[dict] = [{"role": "system", "content": _with_no_think(system)}]
if history:
messages += history
messages.append({"role": "user", "content": text})
mdl = model or settings.model
trace: list[dict] = []
for _ in range(settings.tool_max_iters):
msg = await _post({
"model": mdl,
"messages": messages,
"tools": schemas,
"tool_choice": "auto",
"max_tokens": settings.tool_max_tokens,
"temperature": settings.tool_temperature,
})
calls = msg.get("tool_calls") or []
if not calls:
return _content_of(msg), trace
# Réinjecte le message assistant (avec ses tool_calls) avant les résultats d'outils.
messages.append({
"role": "assistant",
"content": msg.get("content") or "",
"tool_calls": calls,
})
for tc in calls:
fn = tc.get("function", {})
name = fn.get("name", "")
try:
args = json.loads(fn.get("arguments") or "{}")
except (ValueError, TypeError):
args = {}
result = await dispatch(name, args)
trace.append({"name": name, "args": args, "result": result})
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"content": result,
})
# Budget d'itérations épuisé : on force une réponse finale sans nouveaux appels d'outils.
msg = await _post({
"model": mdl,
"messages": messages,
"tool_choice": "none",
"max_tokens": settings.max_tokens,
"temperature": settings.temperature,
})
return _content_of(msg), trace