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

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:
ALI YESILKAYA 2026-06-22 22:51:11 +02:00 committed by GitHub
parent dc7a2093ae
commit 3ea4c3c706
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 163 additions and 28 deletions

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,
),
}