mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 10:54:41 +02:00
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:
parent
de1cd6c407
commit
7cafc06069
11 changed files with 566 additions and 42 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.7.0"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
})
|
||||
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:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
210
stt/server/stt_server/tools.py
Normal file
210
stt/server/stt_server/tools.py
Normal 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}"
|
||||
102
stt/server/tests/test_tool_loop.py
Normal file
102
stt/server/tests/test_tool_loop.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue