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

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:
ALI YESILKAYA 2026-06-22 21:52:28 +02:00 committed by GitHub
parent de1cd6c407
commit 7cafc06069
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 566 additions and 42 deletions

View file

@ -91,12 +91,26 @@ switch global type `hermes-switch` (pas de restart, chaque client choisit le sie
par requête à `/v1/ask` (`GET /v1/contexts` liste les profils). Chaque profil (`contexts.py`)
a son **system prompt** et ses **sources live** injectées dans le contexte (`sources.py`,
best-effort, env-config) :
- `funk` — grounding strict + RAG `funk-docs` (défaut, comportement historique)
- `asa`**boucle d'outils (function calling)**, **défaut prod** (`STT_DEFAULT_CONTEXT=asa`). Voir ci-dessous.
- `funk` — grounding strict + RAG `funk-docs` injecté d'office (comportement historique, sélectionnable)
- `ghostfolio` — valeur/positions du portefeuille (API Ghostfolio, `STT_GHOSTFOLIO_TOKEN`)
- `grafana` — métriques clés (Prometheus)
- `alerting` — alertes actives (Alertmanager, hors Watchdog)
- `cluster` — état pods/nœuds (Prometheus/kube-state-metrics) + RAG doc
**Contexte « asa » — boucle d'outils (function calling, local)** : au lieu d'un appel LLM unique
sur un contexte figé, Asa **décide elle-même** quels outils appeler (`tools.py`) puis répond à
partir de leurs résultats (`brain.ask_with_tools`, boucle bornée par `STT_TOOL_MAX_ITERS=4` ;
au-delà, réponse forcée sans outils). C'est ce qui débloque les questions à **état live** sans
présélection — ex. « gpu-01 tourne bien ? » → l'outil `host_health` est appelé → réponse réelle
(avant : le RAG seul disait « la doc ne le précise pas »). Outils Phase 1, **LECTURE SEULE** :
`search_docs` (RAG `funk-docs`), `host_health(gpu-01|storage-01)` (up/charge/RAM + llama-server,
Prometheus), `cluster_status` (nœuds/pods), `prometheus_query(expr)` (PromQL arbitraire). Modèle
**local** Qwen3-8B (le tool-calling natif de llama.cpp fonctionne, **même avec `/no_think`**
vérifié). Le `trace` des outils appelés est renvoyé dans `context` → visualiseur HUD (un bloc par
appel). L'écriture (agir) reste au contexte `agent` ci-dessous (Phase 3 : la brancher comme outil).
> Web search (SearXNG in-cluster) = Phase 2 ; actions admin pilotées par le LLM = Phase 3.
La réponse `/v1/ask` renvoie le **contexte assemblé** (`context`: system prompt + blocs live +
extraits RAG + mémoire) → alimente le **visualiseur** du HUD (« voir ce qu'on envoie à Asa »).
URLs in-cluster dans `k8s/apps/stt/deployment.yaml` (Prometheus/Alertmanager `monitoring`,

View file

@ -51,9 +51,11 @@ spec:
value: "http://192.168.10.20:1238/v1/embeddings"
- name: STT_EMBED_MODEL
value: "nomic-embed-text"
# Contextes présélectionnables (sources live) — services in-cluster
# Contextes présélectionnables (sources live) — services in-cluster.
# Défaut « asa » : boucle d'outils (function calling local) → Asa sonde l'état
# live du homelab et la doc d'elle-même. « funk » (RAG strict) reste sélectionnable.
- name: STT_DEFAULT_CONTEXT
value: "funk"
value: "asa"
- name: STT_PROMETHEUS_URL
value: "http://kube-prometheus-stack-prometheus.monitoring:9090"
- name: STT_ALERTMANAGER_URL

View file

@ -64,8 +64,30 @@ et le **fix du batch embed** (`llama-embed` ubatch 512 → 2048). Détail : `pro
IaC** : `rocm_rhel_version` 9.4 → 9.7, `llama_server_commit` pinné, pièges documentés dans les
README de rôle. (PR `fix/gpu-apply-amdgpu-repo-and-commit-pin`.)
## 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) → « gpu-01 tourne bien ? » tombait sur « la doc ne le précise pas » (état live
absent de la doc). Bascule vers une **boucle de function-calling** : le modèle décide quels outils
appeler, puis répond à partir des résultats.
- **Validé empiriquement avant de coder** : Qwen3-8B via llama.cpp fait du tool-calling natif
(pas besoin de `--jinja`), LiteLLM transmet bien les `tools`, et le tool-calling **survit au
`/no_think`**. → 100 % local, pas d'escalade Claude (choix utilisateur).
- **Nouveau contexte `asa`** (`STT_DEFAULT_CONTEXT=asa`, défaut prod) + `tools.py` (registre
schémas + exécuteurs) + `brain.ask_with_tools` (boucle bornée `STT_TOOL_MAX_ITERS=4`, réponse
forcée au-delà). Outils **Phase 1, lecture seule** : `search_docs`, `host_health`,
`cluster_status`, `prometheus_query`. Trace des outils → visualiseur HUD.
- **PromQL validés** contre le vrai Prometheus in-cluster (labels `gpu-01-node`/`storage-01`/
`llama-server-gpu` confirmés ; up/load/mem renvoient des données réelles). 3 tests unitaires de
la boucle (hors-ligne). (PR `feat/asa-tool-loop`.)
- **Choix** : web search = **SearXNG in-cluster** (Phase 2) ; modèle = **local uniquement**.
## Prochaines étapes
- **Phase 2** — outil `web_search` (SearXNG self-host in-cluster, namespace `ai`, ArgoCD).
- **Phase 3** — outil `admin_action` (écriture) : brancher `hermes-exec` comme outil piloté par le
LLM, avec le handshake de confirmation vocale existant.
- Enrichir `host_health`/`cluster_status` (GPU via `rocm_scraper`, fs, températures) si utile.
- Si le wake word « asa » génère des faux positifs/négatifs, ajuster `wake_aliases`.
- **1er test d'action vocale** (#43, contexte 🤖 Agent → `hermes-exec`).
- Vérifier qu'un `make apply-gpu` **complet** repasse vert (repo amdgpu sain) à la prochaine occasion.

View file

@ -1,6 +1,6 @@
[project]
name = "stt-server"
version = "0.3.1"
version = "0.7.0"
description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)"
requires-python = ">=3.11"

View file

@ -1,3 +1,3 @@
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
__version__ = "0.6.0"
__version__ = "0.7.0"

View file

@ -20,6 +20,7 @@ from stt_server import brain
from stt_server.brain import ask as brain_ask
from stt_server.config import settings
from stt_server import agent
from stt_server import tools
from stt_server.contexts import CONTEXTS, assemble, get_context
from stt_server.knowledge import Knowledge
from stt_server.longterm import LongTermMemory
@ -170,6 +171,67 @@ async def _handle_agent(req: AskRequest, text: str) -> AskReply:
context=_agent_context_debug({"pending": text}))
async def _handle_agentic(
req: "AskRequest", text: str, ctx, model: str, background: BackgroundTasks
) -> "AskReply":
"""Contexte à outils : recall mémoire (perso) + boucle de function-calling (état live).
La doc n'est plus injectée d'office (elle est devenue l'outil `search_docs`) ; on garde
le recall long-terme pour la personnalisation. Le trace des outils alimente le visualiseur.
"""
t0 = time.perf_counter()
history = sessions.history(req.session_id) if req.session_id else None
memories, qvec = await longterm.recall(text) if longterm else ([], None)
system = ctx.system_prompt
if memories:
souvenirs = "\n".join(f"- {m}" for m in memories)
system += (
"\n\nÉléments de mémoire long-terme (peuvent aider, ignore si hors-sujet) :\n"
+ souvenirs
)
schemas = tools.schemas_for(ctx.tools)
t_recall = time.perf_counter()
async with httpx.AsyncClient() as client:
deps = tools.ToolDeps(client=client, knowledge=knowledge, qvec=qvec)
async def _dispatch(name: str, args: dict) -> str:
return await tools.run(name, args, deps)
try:
reply, tool_trace = await brain.ask_with_tools(
text, system, schemas=schemas, dispatch=_dispatch,
model=model, history=history,
)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
t_gen = time.perf_counter()
if req.session_id:
sessions.add(req.session_id, "user", text)
sessions.add(req.session_id, "assistant", reply)
if longterm:
background.add_task(longterm.store, req.session_id or "anon", text, qvec)
log.info(
"ask ctx=%s model=%s recall=%.0fms loop=%.0fms total=%.0fms mem=%d tools=%d",
ctx.id, model, (t_recall - t0) * 1000, (t_gen - t_recall) * 1000,
(t_gen - t0) * 1000, len(memories), len(tool_trace),
)
# Visualiseur HUD : on présente chaque appel d'outil comme un bloc (réutilise l'UI existante).
ctx_debug = {
"id": ctx.id, "label": ctx.label, "icon": ctx.icon,
"system_prompt": ctx.system_prompt,
"blocks": [
{
"source": f"tool:{t['name']}",
"title": f"🔧 {t['name']}({', '.join(f'{k}={v}' for k, v in t['args'].items())})",
"text": t["result"],
}
for t in tool_trace
],
"docs": [], "memories": memories,
}
return AskReply(reply=reply, model=model, context_id=ctx.id, context=ctx_debug)
@app.post("/v1/ask", response_model=AskReply)
async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
text = req.text.strip()
@ -185,6 +247,9 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
# Contexte « agent » : flux dédié (confirmation 2 temps → hermes-exec), sans LLM.
if ctx.id == "agent":
return await _handle_agent(req, text)
# Contexte à outils (« asa ») : boucle de function-calling (le modèle sonde l'état live).
if ctx.tools:
return await _handle_agentic(req, text, ctx, model, background)
t0 = time.perf_counter()
history = sessions.history(req.session_id) if req.session_id else None
# recall : timeout serré, dégrade vite ; renvoie aussi le vecteur (réutilisé ci-dessous)

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):
})
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:
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
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

View file

@ -78,6 +78,14 @@ class Settings:
prometheus_url: str = os.getenv("STT_PROMETHEUS_URL", "http://prometheus.lab.local")
alertmanager_url: str = os.getenv("STT_ALERTMANAGER_URL", "http://alertmanager.lab.local")
# Boucle d'outils (contexte « asa ») — function calling local (Qwen3-8B). Le modèle
# appelle des outils de lecture (doc, santé hôtes, état cluster, PromQL) jusqu'à pouvoir
# répondre, borné par tool_max_iters (puis réponse forcée sans outils).
tool_max_iters: int = int(os.getenv("STT_TOOL_MAX_ITERS", "4"))
tool_max_tokens: int = int(os.getenv("STT_TOOL_MAX_TOKENS", "512"))
# Température basse pour le tool-use (sélection d'outil + arguments déterministes).
tool_temperature: float = float(os.getenv("STT_TOOL_TEMPERATURE", "0.2"))
# Actions via Hermes (contexte « agent ») — OPT-IN, désactivé par défaut.
# Le contexte « agent » n'est exposé que si actions_enabled ET un jeton est présent.
actions_enabled: bool = os.getenv("STT_ACTIONS_ENABLED", "false").lower() == "true"

View file

@ -29,10 +29,26 @@ class Context:
description: str
system_prompt: str
sources: tuple[str, ...] = () # "docs","ghostfolio","alerts","cluster","metrics"
tools: tuple[str, ...] = () # outils function-calling (cf. tools.py) → boucle agentique
# Le contexte « funk » garde le prompt grounding-strict historique (env override possible).
CONTEXTS: dict[str, Context] = {
# Contexte « asa » : boucle d'outils (function calling). Asa décide elle-même quand
# sonder l'état live du homelab ou chercher dans la doc, puis répond. C'est le contexte
# par défaut côté production (STT_DEFAULT_CONTEXT=asa) — « gpu-01 tourne bien ? » marche
# sans présélection. Les outils sont en LECTURE SEULE (volet actions = contexte « agent »).
"asa": Context(
id="asa", label="Asa · agent", icon="",
description="Asa répond en s'appuyant sur des outils (doc + état live du homelab).",
system_prompt=_BASE + " Tu disposes d'outils pour obtenir l'état RÉEL et LIVE du "
"homelab (santé des machines, état du cluster, métriques Prometheus) et pour chercher "
"dans la documentation Funk. Dès qu'une question porte sur l'état courant, un fait "
"précis ou la config du homelab, APPELLE l'outil adéquat puis réponds à partir de son "
"résultat — n'invente jamais. Si les outils ne donnent pas l'information, dis-le "
"simplement. Pour le bavardage général, réponds directement sans outil.",
tools=("search_docs", "host_health", "cluster_status", "prometheus_query"),
),
"funk": Context(
id="funk", label="Funk · cluster", icon="🛠️",
description="Questions sur le homelab depuis la documentation.",

View file

@ -0,0 +1,210 @@
"""Outils appelables par Asa (function calling) — Phase 1 : LECTURE SEULE.
Chaque outil = un schéma OpenAI (exposé au LLM) + un exécuteur async qui renvoie un
**texte court** réinjecté dans la boucle (`brain.ask_with_tools`). Tout est best-effort :
un exécuteur n'élève jamais d'exception fatale il renvoie une note d'erreur lisible que
le modèle peut relayer à l'utilisateur.
Les outils réutilisent l'infra existante (Prometheus via `sources`, RAG via `knowledge`).
Aucune action d'écriture ici : le volet « agir sur le homelab » passe par `agent.py`
(hermes-exec, avec confirmation) et sera branché comme outil en Phase 3.
"""
from __future__ import annotations
from dataclasses import dataclass
import httpx
from stt_server import sources
from stt_server.config import settings
# job Prometheus du node_exporter par hôte hors-cluster (cf. sources.cluster_block).
_HOST_JOBS = {"gpu-01": "gpu-01-node", "storage-01": "storage-01"}
@dataclass
class ToolDeps:
"""Dépendances injectées aux exécuteurs (client HTTP partagé, RAG, vecteur de requête)."""
client: httpx.AsyncClient
knowledge: object | None = None # stt_server.knowledge.Knowledge | None
qvec: list[float] | None = None # embedding de la requête (réutilisé par search_docs)
# --- Exécuteurs -----------------------------------------------------------------
async def _search_docs(args: dict, deps: ToolDeps) -> str:
query = (args.get("query") or "").strip()
if not query:
return "Aucune requête fournie."
if deps.knowledge is None:
return "La recherche documentaire est désactivée."
# Réutilise le vecteur déjà calculé si la requête de l'outil == requête utilisateur.
docs = await deps.knowledge.search(query, deps.qvec)
if not docs:
return "Aucun passage pertinent dans la documentation Funk."
return "\n\n".join(docs[:4])
def _fmt_series(rows: list[tuple[dict, float]], label_key: str | None = None, limit: int = 10) -> str:
out = []
for metric, val in rows[:limit]:
v = int(val) if float(val).is_integer() else round(val, 3)
if label_key and metric.get(label_key):
out.append(f"{metric[label_key]}={v}")
elif metric:
ident = metric.get("__name__") or next(iter(metric.values()), "")
out.append(f"{ident}={v}")
else:
out.append(str(v))
return ", ".join(out)
async def _host_health(args: dict, deps: ToolDeps) -> str:
host = (args.get("host") or "").strip()
job = _HOST_JOBS.get(host)
if not job:
return f"Hôte inconnu : {host!r} (attendu : {', '.join(_HOST_JOBS)})."
try:
up = await sources._prom_query(deps.client, f'up{{job="{job}"}}')
load = await sources._prom_query(deps.client, f'node_load1{{job="{job}"}}')
mem = await sources._prom_query(
deps.client,
f'100*node_memory_MemAvailable_bytes{{job="{job}"}}'
f'/node_memory_MemTotal_bytes{{job="{job}"}}',
)
except httpx.HTTPError:
return "Données indisponibles : Prometheus injoignable."
if not up:
return f"{host} : aucune métrique (cible Prometheus absente ou hôte éteint)."
online = up[0][1] == 1
parts = [f"{host} : {'en ligne' if online else 'HORS LIGNE'}"]
if load:
parts.append(f"charge 1 min {round(load[0][1], 2)}")
if mem:
parts.append(f"RAM libre {round(mem[0][1])}%")
if host == "gpu-01":
try:
llama = await sources._prom_query(deps.client, 'up{job="llama-server-gpu"}')
if llama:
parts.append("llama-server " + ("up" if llama[0][1] == 1 else "DOWN"))
except httpx.HTTPError:
pass
return ", ".join(parts) + "."
async def _cluster_status(args: dict, deps: ToolDeps) -> str:
# Réutilise le résumé déjà éprouvé (nœuds joignables, pods prêts / non prêts).
return await sources.cluster_block(deps.client)
async def _prometheus_query(args: dict, deps: ToolDeps) -> str:
expr = (args.get("expr") or "").strip()
if not expr:
return "Aucune expression PromQL fournie."
try:
rows = await sources._prom_query(deps.client, expr)
except httpx.HTTPError:
return "Données indisponibles : Prometheus injoignable."
except Exception as e: # noqa: BLE001 — PromQL invalide, etc.
return f"Requête refusée par Prometheus : {e}"
if not rows:
return "La requête ne renvoie aucune série (résultat vide)."
return _fmt_series(rows)
# --- Registre : nom → (schéma OpenAI, exécuteur) --------------------------------
_TOOLS: dict[str, tuple[dict, object]] = {
"search_docs": (
{
"type": "function",
"function": {
"name": "search_docs",
"description": "Recherche dans la documentation du homelab Funk "
"(machines, services, configuration, procédures). À utiliser pour toute "
"question sur la conception ou la config du homelab.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Question ou mots-clés"}
},
"required": ["query"],
},
},
},
_search_docs,
),
"host_health": (
{
"type": "function",
"function": {
"name": "host_health",
"description": "État de santé LIVE d'une machine hors-cluster "
"(en ligne ?, charge CPU, RAM libre ; pour gpu-01 aussi l'état de llama-server). "
"À utiliser pour « est-ce que gpu-01 / storage-01 tourne bien ? ».",
"parameters": {
"type": "object",
"properties": {
"host": {
"type": "string",
"enum": list(_HOST_JOBS),
"description": "Nom de la machine",
}
},
"required": ["host"],
},
},
},
_host_health,
),
"cluster_status": (
{
"type": "function",
"function": {
"name": "cluster_status",
"description": "État LIVE du cluster Kubernetes Funk : hôtes joignables, "
"nombre de pods prêts et liste des pods non prêts. À utiliser pour "
"« est-ce que le cluster va bien ? ».",
"parameters": {"type": "object", "properties": {}},
},
},
_cluster_status,
),
"prometheus_query": (
{
"type": "function",
"function": {
"name": "prometheus_query",
"description": "Exécute une requête PromQL arbitraire (lecture seule) contre "
"Prometheus et renvoie les séries. À utiliser pour une métrique précise non "
"couverte par les autres outils.",
"parameters": {
"type": "object",
"properties": {
"expr": {"type": "string", "description": "Expression PromQL"}
},
"required": ["expr"],
},
},
},
_prometheus_query,
),
}
def schemas_for(names: tuple[str, ...]) -> list[dict]:
"""Schémas OpenAI des outils demandés (ignore les inconnus)."""
return [_TOOLS[n][0] for n in names if n in _TOOLS]
async def run(name: str, args: dict, deps: ToolDeps) -> str:
"""Exécute un outil ; ne lève jamais — renvoie une note d'erreur lisible."""
entry = _TOOLS.get(name)
if entry is None:
return f"Outil inconnu : {name}."
try:
return await entry[1](args or {}, deps)
except Exception as e: # noqa: BLE001 — robustesse : la boucle ne doit jamais casser
return f"Erreur de l'outil {name} : {e}"

View file

@ -0,0 +1,102 @@
"""Tests de la boucle de function-calling (`brain.ask_with_tools`).
Hors-ligne : on monkeypatche `brain._post` (l'appel LiteLLM) pour simuler les réponses
du modèle, et on fournit un `dispatch` factice. Lançable avec `pytest` (pas de plugin
async requis : chaque test pilote sa propre boucle via `asyncio.run`).
"""
from __future__ import annotations
import asyncio
from stt_server import brain
def test_loop_calls_tool_then_answers(monkeypatch):
calls: list[dict] = []
async def fake_post(payload):
calls.append(payload)
if len(calls) == 1: # 1er tour : le modèle demande un outil
return {
"role": "assistant", "content": "",
"tool_calls": [{
"id": "t1", "type": "function",
"function": {"name": "host_health", "arguments": '{"host": "gpu-01"}'},
}],
}
return {"role": "assistant", "content": "gpu-01 est en ligne et se porte bien."}
monkeypatch.setattr(brain, "_post", fake_post)
executed: list[tuple[str, dict]] = []
async def dispatch(name, args):
executed.append((name, args))
return "gpu-01 : en ligne, charge 0.16, RAM libre 48%."
async def go():
schemas = [{"type": "function", "function": {"name": "host_health", "parameters": {}}}]
return await brain.ask_with_tools(
"gpu-01 tourne bien ?", "Tu es Asa.",
schemas=schemas, dispatch=dispatch,
)
reply, trace = asyncio.run(go())
assert executed == [("host_health", {"host": "gpu-01"})]
assert "en ligne" in reply
assert len(trace) == 1 and trace[0]["name"] == "host_health"
# Le 2e appel doit réinjecter l'assistant (avec tool_calls) puis le résultat d'outil.
assert [m["role"] for m in calls[1]["messages"]] == ["system", "user", "assistant", "tool"]
def test_no_schemas_falls_back_to_plain_ask(monkeypatch):
async def fake_post(payload):
assert "tools" not in payload # repli sur ask() : pas d'outils dans le payload
return {"role": "assistant", "content": "réponse simple"}
monkeypatch.setattr(brain, "_post", fake_post)
async def dispatch(name, args): # ne doit jamais être appelé
raise AssertionError("dispatch ne doit pas être invoqué sans schémas")
async def go():
return await brain.ask_with_tools(
"salut", "sys", schemas=[], dispatch=dispatch,
)
reply, trace = asyncio.run(go())
assert reply == "réponse simple" and trace == []
def test_loop_forces_answer_after_max_iters(monkeypatch):
"""Si le modèle boucle indéfiniment sur des outils, un dernier appel force une réponse."""
calls: list[dict] = []
async def fake_post(payload):
calls.append(payload)
if payload.get("tool_choice") == "none": # appel final forcé
return {"role": "assistant", "content": "Réponse finale forcée."}
return { # tour normal : redemande toujours un outil
"role": "assistant", "content": "",
"tool_calls": [{
"id": f"t{len(calls)}", "type": "function",
"function": {"name": "cluster_status", "arguments": "{}"},
}],
}
monkeypatch.setattr(brain, "_post", fake_post)
async def dispatch(name, args):
return "ok"
async def go():
schemas = [{"type": "function", "function": {"name": "cluster_status", "parameters": {}}}]
return await brain.ask_with_tools(
"état ?", "sys", schemas=schemas, dispatch=dispatch,
)
reply, trace = asyncio.run(go())
assert reply == "Réponse finale forcée."
# tool_max_iters tours d'outils + 1 appel final forcé.
assert calls[-1]["tool_choice"] == "none"
assert len(trace) == brain.settings.tool_max_iters