feat(agent): add learn action flow and grounding guards

This commit is contained in:
Dom
2026-06-02 16:24:10 +02:00
parent 86b3c8f7e7
commit d38f0b0f2f
39 changed files with 5901 additions and 212 deletions

View File

@@ -9,6 +9,7 @@ Tourne dans son propre thread daemon pour ne pas bloquer pystray.
import logging
import os
import math
import threading
import time
from datetime import datetime
@@ -121,7 +122,7 @@ def _tpl_done(payload: Dict[str, Any]) -> tuple:
def _tpl_need_confirm(payload: Dict[str, Any]) -> tuple:
action = payload.get("action") or {}
desc = action.get("description") if isinstance(action, dict) else None
title = desc or "Validation requise"
title = desc or "J'attends ton accord avant de continuer"
return ("?", ACTION_ICON_RUN, str(title))
@@ -867,11 +868,19 @@ class ChatWindow:
pass
except Exception:
logger.debug("force-show chat_window silenced", exc_info=True)
# UX fix mai 2026 : repartir d'un chat vide pour focaliser
# l'attention sur la question (clear visuel uniquement,
# self._messages reste intact pour la traçabilité debug).
self._clear_chat_history()
self._render_paused_bubble(payload)
try:
# UX fix mai 2026 : repartir d'un chat vide pour focaliser
# l'attention sur la question (clear visuel uniquement,
# self._messages reste intact pour la traçabilité debug).
self._clear_chat_history()
self._render_paused_bubble(payload)
except Exception:
logger.exception("render paused bubble failed; using fallback")
try:
self._clear_chat_history()
self._render_paused_fallback_bubble(payload)
except Exception:
logger.debug("render paused fallback silenced", exc_info=True)
self._root.after(0, _show_and_render)
@@ -895,7 +904,11 @@ class ChatWindow:
logger.debug("clear chat history silenced", exc_info=True)
@staticmethod
def _compute_paused_bubble_height(reason_str: str) -> tuple:
def _compute_paused_bubble_height(
reason_str: str,
chars_per_line: int = 52,
max_rows: int = 14,
) -> tuple:
"""Calcule la hauteur du Text (en lignes) + si une scrollbar est
nécessaire pour le message d'une bulle paused.
@@ -910,11 +923,11 @@ class ChatWindow:
if not reason_str:
return 2, False
text = str(reason_str)
# Estimation : ~60 chars/ligne effectifs avec wraplength.
wrapped_lines = (len(text) // 60) + 1
explicit_lines = text.count("\n") + 1
estimated = max(wrapped_lines, explicit_lines)
cap = 12
chars_per_line = max(24, int(chars_per_line or 52))
estimated = 0
for raw_line in text.splitlines() or [""]:
estimated += max(1, math.ceil(len(raw_line) / chars_per_line))
cap = max(2, int(max_rows or 14))
height = max(2, min(cap, estimated))
# Scrollbar dès que le cap est atteint OU contenu long (filet
# textuel : ≥ 200 chars implique souvent un débordement visuel
@@ -922,6 +935,46 @@ class ChatWindow:
needs_scroll = (estimated >= cap) or (len(text) > 200)
return height, needs_scroll
def _paused_text_layout(self) -> tuple:
"""Retourne ``(wrap_px, chars_per_line, max_rows)`` pour la bulle pause.
La fenêtre Léa est souvent redimensionnée à ~380px de large sur le
poste Windows. Les anciennes estimations fixes calculaient trop peu
de lignes et tronquaient le message. On part donc des dimensions
réelles du canvas et de la métrique de la police Tk.
"""
canvas_w = 0
canvas_h = 0
try:
canvas_w = int(self._canvas.winfo_width()) if self._canvas is not None else 0
canvas_h = int(self._canvas.winfo_height()) if self._canvas is not None else 0
except Exception:
canvas_w = canvas_h = 0
# Marges: container + padding inner + petite marge droite. La bulle
# de pause est une alerte critique, elle utilise donc presque toute
# la largeur disponible sur les fenêtres étroites.
wrap_px = max(220, canvas_w - (2 * MARGIN) - 52) if canvas_w else 360
avg_char = 8
line_px = 22
try:
from tkinter import font as tkfont
font = tkfont.Font(font=FONT_MSG)
avg_char = max(6, font.measure("n"))
line_px = max(18, font.metrics("linespace"))
except Exception:
pass
chars_per_line = max(24, int(wrap_px / avg_char))
# Réserver titre, metadata, boutons, feedback et padding. Même sur
# une petite fenêtre, on garde assez de lignes pour ne pas couper un
# message d'erreur standard.
max_rows = 14
if canvas_h:
max_rows = max(5, min(18, int((canvas_h - 145) / line_px)))
return wrap_px, chars_per_line, max_rows
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
tk = self._tk
if getattr(self, "_msg_frame", None) is None:
@@ -941,7 +994,7 @@ class ChatWindow:
container, bg=PAUSED_BG, padx=14, pady=12,
highlightbackground=PAUSED_BORDER, highlightthickness=2,
)
inner.pack(anchor=tk.W, padx=(0, 50), fill=tk.X)
inner.pack(anchor=tk.W, padx=(0, 12), fill=tk.X)
tk.Label(
inner, text=f"⏸ Pause supervisée • {now}",
@@ -949,31 +1002,44 @@ class ChatWindow:
font=("Segoe UI", 12, "bold"), anchor="w",
).pack(fill=tk.X, anchor=tk.W)
# Message scrollable pour les longs reasons (ex: 200+ chars depuis le serveur).
# On utilise un Text en mode read-only avec hauteur calculée selon la longueur.
# Patch 22 mai 2026 : prendre en compte les \n explicites (titres
# fenêtre / patterns) et activer la scrollbar dès que le cap de
# hauteur est atteint — sinon les bulles de pause étaient
# tronquées visuellement sans aucun ascenseur visible.
# Message borné et scrollable : sur une fenêtre Léa étroite, une
# bulle trop haute fait disparaître le début du diagnostic hors du
# viewport. On garde donc la bulle compacte et on scrolle le texte.
reason_str = str(reason)
height_lines, needs_scroll = self._compute_paused_bubble_height(reason_str)
msg_frame = tk.Frame(inner, bg=PAUSED_BG)
msg_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
reason_text = tk.Text(
msg_frame, bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_MSG, wrap=tk.WORD, bd=0, height=height_lines,
highlightthickness=0, relief=tk.FLAT, cursor="arrow",
_wrap_px, chars_per_line, max_rows = self._paused_text_layout()
text_rows, needs_text_scroll = self._compute_paused_bubble_height(
reason_str,
chars_per_line=chars_per_line,
max_rows=max_rows,
)
reason_text.insert("1.0", reason_str)
reason_text.configure(state="disabled")
reason_text.pack(side=tk.LEFT, fill=tk.X, expand=True)
if needs_scroll:
reason_scroll = tk.Scrollbar(
msg_frame, orient=tk.VERTICAL,
command=reason_text.yview, width=8,
text_frame = tk.Frame(inner, bg=PAUSED_BG)
text_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
reason_msg = tk.Text(
text_frame,
height=text_rows,
wrap=tk.WORD,
bg=PAUSED_BG,
fg=PAUSED_FG,
font=FONT_MSG,
bd=0,
highlightthickness=0,
relief=tk.FLAT,
padx=0,
pady=0,
cursor="arrow",
)
reason_msg.insert("1.0", reason_str)
reason_msg.configure(state="disabled")
reason_msg.pack(side=tk.LEFT, fill=tk.X, expand=True)
if needs_text_scroll:
scrollbar = tk.Scrollbar(
text_frame,
orient=tk.VERTICAL,
command=reason_msg.yview,
width=12,
)
reason_text.configure(yscrollcommand=reason_scroll.set)
reason_scroll.pack(side=tk.RIGHT, fill=tk.Y)
reason_msg.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y, padx=(6, 0))
tk.Label(
inner, text=f"{workflow} — étape {completed}/{total}",
@@ -1018,6 +1084,89 @@ class ChatWindow:
# Scroll automatique vers la nouvelle bulle (visible immédiatement)
self._scroll_to_bottom()
def _render_paused_fallback_bubble(self, payload: Dict[str, Any]) -> None:
"""Rendu minimal de secours si la bulle riche echoue."""
tk = self._tk
if getattr(self, "_msg_frame", None) is None:
return
replay_id = str(payload.get("replay_id", "") or "")
workflow = payload.get("workflow", "?")
reason = str(
payload.get("reason")
or "Action incertaine - j'ai besoin de votre validation."
)
completed = payload.get("completed", 0)
total = payload.get("total", "?")
now = datetime.now().strftime("%H:%M")
container = tk.Frame(self._msg_frame, bg=BG_COLOR)
container.pack(fill=tk.X, padx=MARGIN, pady=6)
inner = tk.Frame(
container, bg=PAUSED_BG, padx=14, pady=12,
highlightbackground=PAUSED_BORDER, highlightthickness=2,
)
inner.pack(anchor=tk.W, padx=(0, 12), fill=tk.X)
tk.Label(
inner, text=f"Pause supervisee - {now}",
bg=PAUSED_BG, fg=PAUSED_FG,
font=("Segoe UI", 12, "bold"), anchor="w",
).pack(fill=tk.X, anchor=tk.W)
wrap_px = 360
try:
if self._canvas is not None:
wrap_px = max(220, int(self._canvas.winfo_width()) - 80)
except Exception:
pass
tk.Label(
inner, text=reason, bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_MSG, wraplength=wrap_px, justify=tk.LEFT,
anchor=tk.W,
).pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
tk.Label(
inner, text=f"{workflow} - etape {completed}/{total}",
bg=PAUSED_BG, fg=TIMESTAMP_FG, font=FONT_TIMESTAMP, anchor="w",
).pack(fill=tk.X, anchor=tk.W, pady=(4, 8))
btn_frame = tk.Frame(inner, bg=PAUSED_BG)
btn_frame.pack(fill=tk.X, anchor=tk.W)
btn_resume = tk.Button(
btn_frame, text="Continuer",
bg=PAUSED_BTN_RESUME_BG, fg="white", font=FONT_QUICK_BTN,
padx=14, pady=4, bd=0, cursor="hand2",
activebackground=PAUSED_BTN_RESUME_HOVER, activeforeground="white",
command=lambda: self._on_paused_resume(replay_id),
)
btn_resume.pack(side=tk.LEFT, padx=(0, 8))
btn_abort = tk.Button(
btn_frame, text="Annuler",
bg=PAUSED_BTN_ABORT_BG, fg="white", font=FONT_QUICK_BTN,
padx=14, pady=4, bd=0, cursor="hand2",
activebackground=PAUSED_BTN_ABORT_HOVER, activeforeground="white",
command=lambda: self._on_paused_abort(replay_id),
)
btn_abort.pack(side=tk.LEFT)
feedback_label = tk.Label(
inner, text="", bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_TIMESTAMP, anchor="w",
)
feedback_label.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
self._active_paused_bubble = {
"container": container, "inner": inner,
"btn_resume": btn_resume, "btn_abort": btn_abort,
"feedback_label": feedback_label,
"replay_id": replay_id,
}
self._scroll_to_bottom()
def _close_active_paused_bubble(self, reason: str) -> None:
if self._active_paused_bubble is None or self._root is None:
return
@@ -1524,8 +1673,19 @@ class ChatWindow:
self._add_lea_message(
f"C'est parti ! Montrez-moi comment faire \u00ab {name} \u00bb."
)
# --- P1-LEA-SHADOW : d\u00e9clencher d'abord l'orchestrateur L\u00e9a Linux ---
# On contacte agent-chat AVANT la capture locale : si la session
# serveur d\u00e9marre, on r\u00e9cup\u00e8re un session_id + un message d'accueil
# de L\u00e9a qu'on affiche dans le chat. Si \u00e9chec : mode d\u00e9grad\u00e9
# (capture locale uniquement, sans assistance conversationnelle).
self._start_lea_orchestrator_session(name)
# --- Comportement historique pr\u00e9serv\u00e9 : capture locale ---
# Le pipeline streaming (frames/\u00e9v\u00e9nements) reste pilot\u00e9 par
# agent_v1 local. L'orchestrateur Linux ne touche PAS \u00e0 la
# capture, il pilote uniquement le dialogue de fin de session.
try:
# Utiliser l'etat partage si disponible (synchronise le systray)
if self._shared_state is not None:
self._shared_state.start_recording(name)
elif self._on_start_callback is not None:
@@ -1533,6 +1693,60 @@ class ChatWindow:
except Exception as e:
self._add_lea_message(f"Oups, un probl\u00e8me : {e}")
def _start_lea_orchestrator_session(self, session_name: str) -> None:
"""Appelle POST /api/learn/start c\u00f4t\u00e9 agent-chat Linux (P1-LEA-SHADOW).
Fail-safe : toute erreur (config absente, httpx manquant, timeout,
500 serveur...) bascule en mode d\u00e9grad\u00e9 sans bloquer la capture
locale. Un message clair est affich\u00e9 dans le chat.
"""
try:
from ..config import AGENT_CHAT_URL, API_TOKEN, MACHINE_ID
from ..network.lea_orchestrator_client import (
LeaOrchestratorError,
start_learning_session,
)
except Exception as exc: # pragma: no cover (import-time)
logger.error("Impossible de charger le client orchestrateur L\u00e9a : %s", exc)
self._add_lea_message(
"\u26a0 Impossible de joindre L\u00e9a serveur. "
"L'apprentissage continue localement, mais sans assistance "
"conversationnelle."
)
return
try:
resp = start_learning_session(
AGENT_CHAT_URL,
machine_id=MACHINE_ID,
session_name=session_name,
api_token=API_TOKEN,
trigger_source="windows_button",
)
except LeaOrchestratorError as exc:
logger.error("Orchestrateur L\u00e9a injoignable : %s", exc)
self._add_lea_message(
"\u26a0 Impossible de joindre L\u00e9a serveur. "
"L'apprentissage continue localement, mais sans assistance "
"conversationnelle."
)
return
except Exception as exc: # noqa: BLE001 \u2014 d\u00e9fensif
logger.exception("Erreur inattendue orchestrateur L\u00e9a")
self._add_lea_message(
f"\u26a0 Erreur orchestrateur L\u00e9a : {exc}. "
"L'apprentissage continue localement."
)
return
# Affichage du message d'accueil renvoy\u00e9 par L\u00e9a (si pr\u00e9sent)
if resp.message:
self._add_lea_message(resp.message)
logger.info(
"Session orchestrateur L\u00e9a OK : id=%s state=%s",
resp.session_id, resp.state,
)
def _on_quick_tasks(self) -> None:
"""Bouton Lancer — demande ce que L\u00e9a sait faire."""
self._add_user_message("Qu'est-ce que vous savez faire ?")