feat(stt): Phase 3 — actions admin pilotées par le LLM (admin_action)

Asa peut désormais AGIR sur le homelab quand on le demande explicitement, via
un outil de la boucle agentique — mais jamais sans confirmation.

- Outil admin_action(description) (contexte asa) : le LLM PROPOSE une action,
  n'exécute rien. brain.ask_with_tools gagne `confirm_tools` : un tel outil
  arrête la boucle et surface sa réponse (la question de confirmation).
- _handle_agentic : stocke la proposition en pending par session ; au tour
  suivant « confirme » → agent.run_action → hermes-exec (hermes -z --yolo),
  « annule » → oubli. Réutilise le handshake + jeton du contexte agent.
- admin_action n'est exposé que si _actions_available() (STT_ACTIONS_ENABLED
  + jeton) ; sinon retiré des schémas envoyés au modèle.
- Factorisation du ctx_debug du visualiseur. 1 test unitaire (confirm_tools
  arrête la boucle). Serveur 0.9.0 ; doc stt.md + journal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-22 22:26:12 +02:00
parent 55344b55ed
commit 5543edce46
9 changed files with 163 additions and 28 deletions

View file

@ -108,8 +108,16 @@ présélection — ex. « gpu-01 tourne bien ? » → l'outil `host_health` est
Prometheus), `cluster_status` (nœuds/pods), `prometheus_query(expr)` (PromQL arbitraire), et
**`web_search(query)`** (recherche INTERNET via SearXNG, **Phase 2**). 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).
des outils appelés est renvoyé dans `context` → visualiseur HUD (un bloc par appel).
**Outil d'écriture `admin_action` (Phase 3)** : pour une demande explicite d'AGIR, le LLM appelle
`admin_action(description)` qui **n'exécute rien** — la boucle s'arrête (`confirm_tools` dans
`ask_with_tools`), Asa **propose** l'action et stocke un `pending` par session. Le tour suivant,
si l'utilisateur dit « confirme » (mots de `agent.is_confirmation`), `_handle_agentic` exécute via
`agent.run_action`**hermes-exec** (`hermes -z --yolo`) ; « annule » l'oublie. Réutilise le
handshake + le jeton du contexte `agent`. **Exposé seulement si `_actions_available()`** (opt-in
`STT_ACTIONS_ENABLED` + jeton) : sinon `admin_action` est retiré des schémas. La confirmation
protège des erreurs de transcription ; le jeton, des déclenchements non autorisés.
> **`cluster_status`** filtre les pods **terminés** (CronJob Succeeded/Failed) via un join
> `kube_pod_status_phase` → ne signale comme « non prêts » que les pods réellement actifs (sinon
@ -121,7 +129,7 @@ des outils appelés est renvoyé dans `context` → visualiseur HUD (un bloc par
`http://searxng:8080`). Points clés : `use_default_settings: true` (hérite des moteurs),
**`search.formats` inclut `json`** (sinon l'API JSON renvoie 403), `limiter:false`/`image_proxy:false`
(usage interne), image **pinnée**, conf copiée dans un emptyDir via initContainer (évite le mount RO).
Outil = `STT_SEARXNG_URL` (`tools._web_search`). Actions admin pilotées par le LLM = **Phase 3**.
Outil = `STT_SEARXNG_URL` (`tools._web_search`).
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 »).

View file

@ -97,12 +97,24 @@ appeler, puis répond à partir des résultats.
inclure `json`. Image pinnée, conf via initContainer→emptyDir (mount RO contourné), PodSecurity
restricted. (PR `feat/asa-web-search`, serveur 0.8.0.)
### Vérif prod #49 + fix SearXNG + Phase 3
- ✅ **`cluster_status` corrigé vérifié en prod** : « le cluster va bien ? » → *« 38 pods prêts, un
seul non prêt : searxng-… »* → ignore bien les pods cron terminés ET pointe le vrai souci.
- 🐛 **SearXNG crashait** (`'tcp://…:8080' is not a valid integer`) : le Service `searxng` faisait
injecter l'env service-link `SEARXNG_PORT=tcp://…`, lu comme port de bind. Fix `enableServiceLinks:
false` + `SEARXNG_PORT`/`SEARXNG_BIND_ADDRESS` explicites (**PR `fix/searxng-port-collision`**).
Vérif end-to-end `web_search` **après merge** de ce fix.
- ✅ **Phase 3 — actions admin pilotées par le LLM** : outil `admin_action(description)` (contexte
`asa`) qui **ne fait que proposer** — la boucle s'arrête (`confirm_tools`), Asa demande confirmation,
le tour suivant « confirme » → `hermes-exec`. Réutilise le handshake + jeton du contexte `agent` ;
exposé seulement si `STT_ACTIONS_ENABLED` + jeton. 1 test unitaire de plus. (PR `feat/asa-admin-actions`,
serveur 0.9.0.)
## Prochaines étapes
- **Vérifier SearXNG au déploiement** : pod Ready + `web_search` renvoie des résultats réels
(test end-to-end comme pour #48). Ajuster les moteurs SearXNG si bruyant.
- **Phase 3** — outil `admin_action` (écriture) : brancher `hermes-exec` comme outil piloté par le
LLM, avec le handshake de confirmation vocale existant.
- **Vérifier au déploiement** : SearXNG Ready + `web_search` réel ; puis un essai `admin_action`
(« redémarre … » → confirme → exécution Hermes).
- 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`).

View file

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

View file

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

View file

@ -171,6 +171,26 @@ async def _handle_agent(req: AskRequest, text: str) -> AskReply:
context=_agent_context_debug({"pending": text}))
def _tool_blocks(trace: list[dict]) -> list[dict]:
"""Trace d'outils → blocs du visualiseur HUD (un bloc par appel)."""
return [
{
"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 trace
]
def _agentic_ctx_debug(ctx, memories: list[str], trace: list[dict]) -> dict:
return {
"id": ctx.id, "label": ctx.label, "icon": ctx.icon,
"system_prompt": ctx.system_prompt,
"blocks": _tool_blocks(trace), "docs": [], "memories": memories,
}
async def _handle_agentic(
req: "AskRequest", text: str, ctx, model: str, background: BackgroundTasks
) -> "AskReply":
@ -178,8 +198,26 @@ async def _handle_agentic(
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.
Phase 3 : si une action admin a été proposée au tour précédent, ce tour la confirme/annule.
"""
t0 = time.perf_counter()
sid = req.session_id or "anon"
# Confirmation d'une action admin proposée au tour précédent (handshake, hors LLM).
pending = pending_actions.get(sid)
if pending and _actions_available():
if agent.is_confirmation(text):
pending_actions.pop(sid, None)
out = await agent.run_action(pending)
log.info("asa EXEC sid=%s prompt=%r", sid, pending[:120])
trace = [{"name": "admin_action", "args": {"description": pending}, "result": out}]
return AskReply(reply=out, model=model, context_id=ctx.id,
context=_agentic_ctx_debug(ctx, [], trace))
if agent.is_cancellation(text):
pending_actions.pop(sid, None)
return AskReply(reply="C'est annulé.", model=model, context_id=ctx.id,
context=_agentic_ctx_debug(ctx, [], []))
# Ni confirmation ni annulation → l'utilisateur est passé à autre chose : on oublie.
pending_actions.pop(sid, None)
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
@ -189,18 +227,31 @@ async def _handle_agentic(
"\n\nÉléments de mémoire long-terme (peuvent aider, ignore si hors-sujet) :\n"
+ souvenirs
)
schemas = tools.schemas_for(ctx.tools)
# admin_action n'est exposé que si les actions sont activées (opt-in + jeton).
tool_names = tuple(
t for t in ctx.tools if t != "admin_action" or _actions_available()
)
schemas = tools.schemas_for(tool_names)
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:
# admin_action : on n'exécute PAS — on mémorise la proposition et on demande
# confirmation (la boucle s'arrête via confirm_tools). Exécution = tour suivant.
if name == "admin_action":
desc = (args.get("description") or "").strip()
if not desc:
return "Aucune action décrite."
pending_actions[sid] = desc
return (f"Tu veux que j'exécute : « {desc} » ? "
"Dis « confirme » pour lancer, ou « annule ».")
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,
model=model, history=history, confirm_tools=("admin_action",),
)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
@ -215,21 +266,9 @@ async def _handle_agentic(
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)
# Visualiseur HUD : chaque appel d'outil = un bloc (réutilise l'UI existante).
return AskReply(reply=reply, model=model, context_id=ctx.id,
context=_agentic_ctx_debug(ctx, memories, tool_trace))
@app.post("/v1/ask", response_model=AskReply)

View file

@ -109,12 +109,17 @@ async def ask_with_tools(
dispatch: Dispatch,
model: str | None = None,
history: list[dict] | None = None,
confirm_tools: tuple[str, ...] = (),
) -> tuple[str, list[dict]]:
"""Boucle de function-calling : le modèle appelle des outils jusqu'à pouvoir répondre.
Renvoie (réponse, trace) `trace` = [{name, args, result}] des outils exécutés
(alimente le visualiseur du HUD). Borné par `tool_max_iters` ; au-delà, un dernier
appel SANS outils force une réponse texte. Si aucun schéma n'est fourni, repli sur `ask`.
`confirm_tools` : outils « à confirmation » (ex. admin_action). Quand le modèle en appelle
un, la boucle s'ARRÊTE et renvoie directement le texte de son dispatch (la question de
confirmation) comme réponse l'exécution réelle est différée au tour suivant (app.py).
"""
if not schemas:
return await ask(text, system, model, history), []
@ -153,6 +158,9 @@ async def ask_with_tools(
args = {}
result = await dispatch(name, args)
trace.append({"name": name, "args": args, "result": result})
if name in confirm_tools:
# Outil à confirmation : on stoppe la boucle et on surface la question.
return result, trace
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),

View file

@ -47,8 +47,12 @@ CONTEXTS: dict[str, Context] = {
"question porte sur l'état courant, un fait précis du homelab, ou une info externe / "
"récente, 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", "web_search"),
"bavardage général, réponds directement sans outil. Si l'utilisateur demande "
"explicitement d'AGIR (redémarrer, relancer, modifier…), utilise l'outil admin_action "
"(s'il est disponible) — il proposera l'action et demandera confirmation, sans rien "
"exécuter de toi-même.",
tools=("search_docs", "host_health", "cluster_status", "prometheus_query", "web_search",
"admin_action"),
),
"funk": Context(
id="funk", label="Funk · cluster", icon="🛠️",

View file

@ -146,6 +146,12 @@ async def _web_search(args: dict, deps: ToolDeps) -> str:
return "\n".join(lines)
async def _admin_action_placeholder(args: dict, deps: ToolDeps) -> str:
# Jamais atteint : app.py intercepte admin_action dans son dispatch (handshake de
# confirmation). Présent uniquement pour que le schéma soit exposé au LLM via le registre.
return "Action en attente de confirmation."
# --- Registre : nom → (schéma OpenAI, exécuteur) --------------------------------
_TOOLS: dict[str, tuple[dict, object]] = {
@ -243,6 +249,30 @@ _TOOLS: dict[str, tuple[dict, object]] = {
},
_web_search,
),
"admin_action": (
{
"type": "function",
"function": {
"name": "admin_action",
"description": "PROPOSE une action d'administration qui MODIFIE le homelab "
"(redémarrer un service/déploiement, relancer un pod, appliquer un changement…). "
"N'exécute RIEN directement : l'utilisateur devra confirmer vocalement. À utiliser "
"uniquement pour une demande explicite d'AGIR, jamais pour une simple lecture.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "Action à effectuer, en une phrase claire et impérative "
"(ex. « redémarrer le déploiement open-webui »).",
}
},
"required": ["description"],
},
},
},
_admin_action_placeholder,
),
}

View file

@ -68,6 +68,40 @@ def test_no_schemas_falls_back_to_plain_ask(monkeypatch):
assert reply == "réponse simple" and trace == []
def test_confirm_tool_stops_loop(monkeypatch):
"""Un outil de `confirm_tools` (ex. admin_action) arrête la boucle et surface sa réponse."""
calls: list[dict] = []
async def fake_post(payload):
calls.append(payload)
return { # le modèle veut agir → appelle admin_action
"role": "assistant", "content": "",
"tool_calls": [{
"id": "a1", "type": "function",
"function": {"name": "admin_action",
"arguments": '{"description": "redémarrer open-webui"}'},
}],
}
monkeypatch.setattr(brain, "_post", fake_post)
async def dispatch(name, args):
# simule le handshake : renvoie la question de confirmation (pas d'exécution)
return f"Tu veux que j'exécute : « {args['description']} » ? Dis « confirme »."
async def go():
schemas = [{"type": "function", "function": {"name": "admin_action", "parameters": {}}}]
return await brain.ask_with_tools(
"redémarre open-webui", "sys",
schemas=schemas, dispatch=dispatch, confirm_tools=("admin_action",),
)
reply, trace = asyncio.run(go())
assert "confirme" in reply
assert len(calls) == 1 # boucle stoppée après 1 appel LLM (pas de 2e tour)
assert trace[0]["name"] == "admin_action"
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] = []