Funk-lab/stt/server/stt_server/brain.py
ALI YESILKAYA 3ea4c3c706
feat(stt): Phase 3 — actions admin pilotées par le LLM (admin_action) (#51)
Asa peut désormais AGIR sur le homelab quand on le demande explicitement, via
un outil de la boucle agentique — mais jamais sans confirmation.

- Outil admin_action(description) (contexte asa) : le LLM PROPOSE une action,
  n'exécute rien. brain.ask_with_tools gagne `confirm_tools` : un tel outil
  arrête la boucle et surface sa réponse (la question de confirmation).
- _handle_agentic : stocke la proposition en pending par session ; au tour
  suivant « confirme » → agent.run_action → hermes-exec (hermes -z --yolo),
  « annule » → oubli. Réutilise le handshake + jeton du contexte agent.
- admin_action n'est exposé que si _actions_available() (STT_ACTIONS_ENABLED
  + jeton) ; sinon retiré des schémas envoyés au modèle.
- Factorisation du ctx_debug du visualiseur. 1 test unitaire (confirm_tools
  arrête la boucle). Serveur 0.9.0 ; doc stt.md + journal.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:51:11 +02:00

178 lines
6.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Orchestration AI : route les requêtes des clients vers LiteLLM (s01).
LiteLLM (:4000) est OpenAI-compatible et route lui-même vers Qwen3 (g01) ou Claude
selon l'alias `hermes-default` / `hermes-switch`. L'intégration des outils Hermes
(« agir sur Funk » via le gateway :8080) est une étape ultérieure.
"""
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
import httpx
from stt_server.config import settings
# Client persistant (pooling + keep-alive) : évite un handshake TCP vers LiteLLM à chaque tour.
_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=settings.request_timeout)
return _client
async def aclose() -> None:
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_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 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.
"""
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,
model: str | None = None,
history: list[dict] | None = None,
) -> str:
"""Interroge le LLM avec un system prompt **déjà assemblé** (voir contexts.assemble).
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.
"""
messages = [{"role": "system", "content": _with_no_think(system)}]
if history:
messages += history
messages.append({"role": "user", "content": text})
msg = await _post({
"model": model or settings.model,
"messages": messages,
"max_tokens": settings.max_tokens,
"temperature": settings.temperature,
})
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,
confirm_tools: tuple[str, ...] = (),
) -> tuple[str, list[dict]]:
"""Boucle de function-calling : le modèle appelle des outils jusqu'à pouvoir répondre.
Renvoie (réponse, trace) où `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`.
`confirm_tools` : outils « à confirmation » (ex. admin_action). Quand le modèle en appelle
un, la boucle s'ARRÊTE et renvoie directement le texte de son dispatch (la question de
confirmation) comme réponse — l'exécution réelle est différée au tour suivant (app.py).
"""
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})
if name in confirm_tools:
# Outil à confirmation : on stoppe la boucle et on surface la question.
return result, trace
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