mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-09 01:04:43 +02:00
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>
178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
"""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
|