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

@ -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`) 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`, a son **system prompt** et ses **sources live** injectées dans le contexte (`sources.py`,
best-effort, env-config) : 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`) - `ghostfolio` — valeur/positions du portefeuille (API Ghostfolio, `STT_GHOSTFOLIO_TOKEN`)
- `grafana` — métriques clés (Prometheus) - `grafana` — métriques clés (Prometheus)
- `alerting` — alertes actives (Alertmanager, hors Watchdog) - `alerting` — alertes actives (Alertmanager, hors Watchdog)
- `cluster` — état pods/nœuds (Prometheus/kube-state-metrics) + RAG doc - `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 + 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 »). 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`, 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" value: "http://192.168.10.20:1238/v1/embeddings"
- name: STT_EMBED_MODEL - name: STT_EMBED_MODEL
value: "nomic-embed-text" 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 - name: STT_DEFAULT_CONTEXT
value: "funk" value: "asa"
- name: STT_PROMETHEUS_URL - name: STT_PROMETHEUS_URL
value: "http://kube-prometheus-stack-prometheus.monitoring:9090" value: "http://kube-prometheus-stack-prometheus.monitoring:9090"
- name: STT_ALERTMANAGER_URL - 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 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`.) 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 ## 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`. - 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`). - **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] [project]
name = "stt-server" name = "stt-server"
version = "0.3.1" version = "0.7.0"
description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)" description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)"
requires-python = ">=3.11" requires-python = ">=3.11"

View file

@ -1,3 +1,3 @@
"""STT-server — orchestrateur AI in-cluster pour les clients STT.""" """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.brain import ask as brain_ask
from stt_server.config import settings from stt_server.config import settings
from stt_server import agent from stt_server import agent
from stt_server import tools
from stt_server.contexts import CONTEXTS, assemble, get_context from stt_server.contexts import CONTEXTS, assemble, get_context
from stt_server.knowledge import Knowledge from stt_server.knowledge import Knowledge
from stt_server.longterm import LongTermMemory 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})) 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) @app.post("/v1/ask", response_model=AskReply)
async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply: async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
text = req.text.strip() 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. # Contexte « agent » : flux dédié (confirmation 2 temps → hermes-exec), sans LLM.
if ctx.id == "agent": if ctx.id == "agent":
return await _handle_agent(req, text) 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() t0 = time.perf_counter()
history = sessions.history(req.session_id) if req.session_id else None 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) # 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 from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
import httpx import httpx
from stt_server.config import settings from stt_server.config import settings
@ -29,6 +32,47 @@ async def aclose() -> None:
_client = 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( async def ask(
text: str, text: str,
system: 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 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. (app.v1_ask + contexts) ; ici on ne fait que le réglage thinking + l'appel LLM.
""" """
if settings.disable_thinking: messages = [{"role": "system", "content": _with_no_think(system)}]
# 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}]
if history: if history:
messages += history messages += history
messages.append({"role": "user", "content": text}) messages.append({"role": "user", "content": text})
payload = { msg = await _post({
"model": model or settings.model, "model": model or settings.model,
"messages": messages, "messages": messages,
"max_tokens": settings.max_tokens, "max_tokens": settings.max_tokens,
"temperature": settings.temperature, "temperature": settings.temperature,
} })
headers = {"Authorization": f"Bearer {settings.litellm_key}"} return _content_of(msg)
# 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. # dispatch(name, args) -> texte du résultat d'outil. Fourni par app.py (closure sur les deps).
last_exc: Exception | None = None Dispatch = Callable[[str, dict], Awaitable[str]]
for attempt in range(2):
try:
r = await _get_client().post( async def ask_with_tools(
settings.litellm_url, text: str,
json=payload, system: str,
headers=headers, *,
timeout=settings.request_timeout, schemas: list[dict],
) dispatch: Dispatch,
r.raise_for_status() model: str | None = None,
break history: list[dict] | None = None,
except httpx.HTTPError as e: ) -> tuple[str, list[dict]]:
last_exc = e """Boucle de function-calling : le modèle appelle des outils jusqu'à pouvoir répondre.
await aclose() # repart sur une connexion neuve (pool potentiellement vicié)
else: Renvoie (réponse, trace) `trace` = [{name, args, result}] des outils exécutés
raise last_exc # type: ignore[misc] (alimente le visualiseur du HUD). Borné par `tool_max_iters` ; au-delà, un dernier
msg = r.json()["choices"][0]["message"] appel SANS outils force une réponse texte. Si aucun schéma n'est fourni, repli sur `ask`.
# 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 "". if not schemas:
content = (msg.get("content") or "").strip() return await ask(text, system, model, history), []
if not content:
content = (msg.get("reasoning_content") or "").strip() messages: list[dict] = [{"role": "system", "content": _with_no_think(system)}]
return content 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

View file

@ -78,6 +78,14 @@ class Settings:
prometheus_url: str = os.getenv("STT_PROMETHEUS_URL", "http://prometheus.lab.local") prometheus_url: str = os.getenv("STT_PROMETHEUS_URL", "http://prometheus.lab.local")
alertmanager_url: str = os.getenv("STT_ALERTMANAGER_URL", "http://alertmanager.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. # 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. # 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" actions_enabled: bool = os.getenv("STT_ACTIONS_ENABLED", "false").lower() == "true"

View file

@ -29,10 +29,26 @@ class Context:
description: str description: str
system_prompt: str system_prompt: str
sources: tuple[str, ...] = () # "docs","ghostfolio","alerts","cluster","metrics" 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). # Le contexte « funk » garde le prompt grounding-strict historique (env override possible).
CONTEXTS: dict[str, Context] = { 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( "funk": Context(
id="funk", label="Funk · cluster", icon="🛠️", id="funk", label="Funk · cluster", icon="🛠️",
description="Questions sur le homelab depuis la documentation.", 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