From 3ea4c3c706ad3f4d3e6236c65adc7ca5953cdbe9 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Mon, 22 Jun 2026 22:51:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt):=20Phase=203=20=E2=80=94=20actions=20?= =?UTF-8?q?admin=20pilot=C3=A9es=20par=20le=20LLM=20(admin=5Faction)=20(#5?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/ia/stt.md | 14 ++++-- progress/2026-06-22.md | 20 ++++++-- stt/server/pyproject.toml | 2 +- stt/server/stt_server/__init__.py | 2 +- stt/server/stt_server/app.py | 73 +++++++++++++++++++++++------- stt/server/stt_server/brain.py | 8 ++++ stt/server/stt_server/contexts.py | 8 +++- stt/server/stt_server/tools.py | 30 ++++++++++++ stt/server/tests/test_tool_loop.py | 34 ++++++++++++++ 9 files changed, 163 insertions(+), 28 deletions(-) diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 1824d4e..b989e9a 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -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 »). diff --git a/progress/2026-06-22.md b/progress/2026-06-22.md index 97bde87..62aa7ee 100644 --- a/progress/2026-06-22.md +++ b/progress/2026-06-22.md @@ -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`). diff --git a/stt/server/pyproject.toml b/stt/server/pyproject.toml index 9c1fbb2..12890d0 100644 --- a/stt/server/pyproject.toml +++ b/stt/server/pyproject.toml @@ -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" diff --git a/stt/server/stt_server/__init__.py b/stt/server/stt_server/__init__.py index b1d2771..900e40b 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.8.0" +__version__ = "0.9.0" diff --git a/stt/server/stt_server/app.py b/stt/server/stt_server/app.py index 931ecb9..8b5892a 100644 --- a/stt/server/stt_server/app.py +++ b/stt/server/stt_server/app.py @@ -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) diff --git a/stt/server/stt_server/brain.py b/stt/server/stt_server/brain.py index 1baa891..9204b8d 100644 --- a/stt/server/stt_server/brain.py +++ b/stt/server/stt_server/brain.py @@ -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) 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`. + + `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", ""), diff --git a/stt/server/stt_server/contexts.py b/stt/server/stt_server/contexts.py index 5556568..3d7cb70 100644 --- a/stt/server/stt_server/contexts.py +++ b/stt/server/stt_server/contexts.py @@ -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="🛠️", diff --git a/stt/server/stt_server/tools.py b/stt/server/stt_server/tools.py index c404279..0f8e67d 100644 --- a/stt/server/stt_server/tools.py +++ b/stt/server/stt_server/tools.py @@ -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, + ), } diff --git a/stt/server/tests/test_tool_loop.py b/stt/server/tests/test_tool_loop.py index 8f55426..536a579 100644 --- a/stt/server/tests/test_tool_loop.py +++ b/stt/server/tests/test_tool_loop.py @@ -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] = []