feat(stt): 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) → 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:
alkatrazz 2026-06-22 21:45:44 +02:00
parent de1cd6c407
commit 19773a837a
11 changed files with 566 additions and 42 deletions

View file

@ -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)