mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 05:34:43 +02:00
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:
parent
dc7a2093ae
commit
3ea4c3c706
9 changed files with 163 additions and 28 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
|
||||
|
||||
__version__ = "0.8.0"
|
||||
__version__ = "0.9.0"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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="🛠️",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue