From 7cafc0606995e0a0da05cadd4c066f52e6c382d5 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Mon, 22 Jun 2026 21:52:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt):=20Asa=20agentique=20=E2=80=94=20bouc?= =?UTF-8?q?le=20d'outils=20(function=20calling,=20Phase=201)=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/ia/stt.md | 16 ++- k8s/apps/stt/deployment.yaml | 6 +- progress/2026-06-22.md | 24 +++- stt/server/pyproject.toml | 2 +- stt/server/stt_server/__init__.py | 2 +- stt/server/stt_server/app.py | 65 +++++++++ stt/server/stt_server/brain.py | 157 ++++++++++++++++----- stt/server/stt_server/config.py | 8 ++ stt/server/stt_server/contexts.py | 16 +++ stt/server/stt_server/tools.py | 210 +++++++++++++++++++++++++++++ stt/server/tests/test_tool_loop.py | 102 ++++++++++++++ 11 files changed, 566 insertions(+), 42 deletions(-) create mode 100644 stt/server/stt_server/tools.py create mode 100644 stt/server/tests/test_tool_loop.py diff --git a/admin/ia/stt.md b/admin/ia/stt.md index d03f123..22dd28c 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -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`, diff --git a/k8s/apps/stt/deployment.yaml b/k8s/apps/stt/deployment.yaml index 422a679..b80ac61 100644 --- a/k8s/apps/stt/deployment.yaml +++ b/k8s/apps/stt/deployment.yaml @@ -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 diff --git a/progress/2026-06-22.md b/progress/2026-06-22.md index a484849..9b18c34 100644 --- a/progress/2026-06-22.md +++ b/progress/2026-06-22.md @@ -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. diff --git a/stt/server/pyproject.toml b/stt/server/pyproject.toml index 9c1b21b..c6e333b 100644 --- a/stt/server/pyproject.toml +++ b/stt/server/pyproject.toml @@ -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" diff --git a/stt/server/stt_server/__init__.py b/stt/server/stt_server/__init__.py index 2f40c95..05a3dee 100644 --- a/stt/server/stt_server/__init__.py +++ b/stt/server/stt_server/__init__.py @@ -1,3 +1,3 @@ """STT-server — orchestrateur AI in-cluster pour les clients STT.""" -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/stt/server/stt_server/app.py b/stt/server/stt_server/app.py index 72341a5..931ecb9 100644 --- a/stt/server/stt_server/app.py +++ b/stt/server/stt_server/app.py @@ -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) diff --git a/stt/server/stt_server/brain.py b/stt/server/stt_server/brain.py index 4a2f0a8..1baa891 100644 --- a/stt/server/stt_server/brain.py +++ b/stt/server/stt_server/brain.py @@ -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 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, @@ -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) 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`. + """ + 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 diff --git a/stt/server/stt_server/config.py b/stt/server/stt_server/config.py index 0231bec..c063af1 100644 --- a/stt/server/stt_server/config.py +++ b/stt/server/stt_server/config.py @@ -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" diff --git a/stt/server/stt_server/contexts.py b/stt/server/stt_server/contexts.py index 8ff5127..614f6da 100644 --- a/stt/server/stt_server/contexts.py +++ b/stt/server/stt_server/contexts.py @@ -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.", diff --git a/stt/server/stt_server/tools.py b/stt/server/stt_server/tools.py new file mode 100644 index 0000000..c313742 --- /dev/null +++ b/stt/server/stt_server/tools.py @@ -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}" diff --git a/stt/server/tests/test_tool_loop.py b/stt/server/tests/test_tool_loop.py new file mode 100644 index 0000000..8f55426 --- /dev/null +++ b/stt/server/tests/test_tool_loop.py @@ -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