fix: contrôle strict des étapes + routage par machine_id

Corrections critiques après test E2E qui montrait des clics au mauvais endroit :

1. Routage par machine_id (api_stream.py)
   Quand 2 machines partagent le même session_id (agent_demo_user),
   les actions d'un replay pour la VM ne doivent PLUS être distribuées
   au PC physique. Vérification que le replay_state appartient bien à
   la machine qui poll avant de consommer la queue.

2. IRBuilder extrait expected_window_before/after (ir_builder.py)
   Pour chaque action click/type/key_combo, stocke le titre de la fenêtre
   au moment du clic (before) et le titre du prochain événement (after).
   Ces champs alimentent le contrôle strict au runtime.

3. ExecutionCompiler crée SuccessCondition title_match (execution_compiler.py)
   Quand expected_window_after est défini, crée une condition de succès
   STRICTE avec method="title_match" et expected_title. Plus de simple
   "l'écran a changé" — on vérifie la fenêtre résultante.

4. Runner propage expected_window_before et success_strict
   Le flag success_strict indique à l'agent que le contrôle post-action
   DOIT être strict (STOP sur mismatch au lieu de warning).

5. UIA strict sur parent_path (executor.py)
   _resolve_via_uia_local REJETTE un match si l'élément trouvé n'est pas
   dans la bonne fenêtre parente (évite ex: "Rechercher" taskbar confondu
   avec "Rechercher" explorateur).

6. Pré/post vérif stricte et bloquante (executor.py)
   - expected_window_before lu en priorité depuis l'action (plan V4)
   - Post-vérif : si success_strict=True et timeout, result.success=False
     → le replay s'arrête au lieu de continuer avec des warnings.

Validé sur la VM :
- Le replay s'arrête proprement quand l'étape 2 aboutit dans "Propriétés de
  Internet" au lieu de "blocnote.txt - Bloc-notes"
- Plus de clics en aveugle / saisie au mauvais endroit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-10 14:05:23 +02:00
parent 56e3cc052a
commit cecdf417b7
7 changed files with 213 additions and 18 deletions

View File

@@ -180,13 +180,30 @@ class ExecutionCompiler:
node.max_retries = default_click_retries
node.recovery_action = "escape"
# Condition de succès basée sur la postcondition
if step.postcondition:
# Condition de succès STRICTE basée sur le titre de fenêtre attendu.
# Si expected_window_after est défini, on fait du title_match (strict).
# Sinon on retombe sur screen_changed (faible).
expected_after = getattr(action, "expected_window_after", "")
if expected_after and expected_after != "unknown_window":
node.success_condition = SuccessCondition(
method="title_match",
expected_title=expected_after,
description=step.postcondition or f"Fenêtre attendue: {expected_after}",
)
elif step.postcondition:
node.success_condition = SuccessCondition(
method="screen_changed",
description=step.postcondition,
)
# Pré-condition stricte : la fenêtre AVANT le clic doit matcher
# Stockée en tant que champ dédié sur le nœud pour l'exécuteur
expected_before = getattr(action, "expected_window_before", "")
if expected_before and expected_before != "unknown_window":
# On l'injecte dans la condition de succès (cas "avant")
# Le nœud portera les deux via des champs séparés
node.expected_window_before = expected_before
elif action.type == "type":
node.text = action.text
node.variable_name = action.text.strip("{}") if action.variable else ""

View File

@@ -133,6 +133,9 @@ class ExecutionNode:
# Vérification
success_condition: Optional[SuccessCondition] = None
# Contrôle strict de fenêtre (pré-condition)
expected_window_before: str = "" # La fenêtre active doit matcher AVANT l'action
# Recovery
recovery_action: str = "escape" # "escape", "undo", "close", "none"
@@ -163,6 +166,8 @@ class ExecutionNode:
d["max_retries"] = self.max_retries
if self.success_condition:
d["success_condition"] = self.success_condition.to_dict()
if self.expected_window_before:
d["expected_window_before"] = self.expected_window_before
d["recovery_action"] = self.recovery_action
if self.is_optional:
d["is_optional"] = True
@@ -187,6 +192,7 @@ class ExecutionNode:
max_retries=d.get("max_retries", 1),
retry_delay_ms=d.get("retry_delay_ms", 2000),
success_condition=success,
expected_window_before=d.get("expected_window_before", ""),
recovery_action=d.get("recovery_action", "escape"),
step_id=d.get("step_id", ""),
is_optional=d.get("is_optional", False),

View File

@@ -103,7 +103,12 @@ class IRBuilder:
)
ir.steps.append(step)
# 5. Détecter les variables
# 5. Contrôle strict : remplir expected_window_before/after pour chaque action
# C'est la clé de la robustesse : chaque action sait dans quelle fenêtre
# elle doit s'exécuter ET dans quelle fenêtre elle doit aboutir.
self._attach_window_expectations(ir, actionable)
# 6. Détecter les variables
ir.variables = self._detect_variables(ir.steps, actionable)
elapsed = time.time() - t_start
@@ -125,6 +130,55 @@ class IRBuilder:
result.append(evt)
return result
def _attach_window_expectations(self, ir: WorkflowIR, events: List[Dict]) -> None:
"""Remplir expected_window_before/after pour chaque action du workflow.
C'est LA clé du contrôle strict : chaque action connaît la fenêtre
dans laquelle elle doit s'exécuter ET celle qui doit apparaître
après. Toute divergence au replay → STOP immédiat.
On reconstruit la séquence d'événements "actionables" (clicks, type,
key_combo) et on aligne chaque Action du workflow sur son événement
source pour récupérer :
- expected_window_before : titre de la fenêtre AU MOMENT du clic
- expected_window_after : titre de la fenêtre du PROCHAIN click
Les fenêtres génériques (unknown_window, vide) sont ignorées.
"""
# Extraire la séquence des événements actionables avec leurs titres
event_sequence: List[Dict[str, Any]] = []
for evt in events:
t = evt.get("type", "")
if t not in ("mouse_click", "text_input", "key_combo", "key_press", "scroll"):
continue
title = evt.get("window", {}).get("title", "") or ""
event_sequence.append({"type": t, "title": title})
# Aligner avec les actions du workflow
# Les actions dans le workflow sont dans le même ordre que les événements
flat_actions: List[tuple] = [] # (step_idx, action_idx, action)
for si, step in enumerate(ir.steps):
for ai, action in enumerate(step.actions):
if action.type in ("click", "type", "key_combo"):
flat_actions.append((si, ai, action))
# Limite : on prend le min entre les 2 listes
n = min(len(flat_actions), len(event_sequence))
for i in range(n):
si, ai, action = flat_actions[i]
title_now = event_sequence[i]["title"]
if title_now and title_now != "unknown_window":
action.expected_window_before = title_now
# Chercher le prochain événement avec un titre valide
for j in range(i + 1, len(event_sequence)):
next_title = event_sequence[j]["title"]
if next_title and next_title != "unknown_window":
# Ne pas mettre "after" si c'est le même titre (pas de transition)
action.expected_window_after = next_title
break
def _detect_applications(self, events: List[Dict]) -> List[str]:
"""Détecter les applications utilisées."""
apps = set()

View File

@@ -75,6 +75,12 @@ class Action:
duration_ms: int = 0 # Durée (pour wait)
variable: bool = False # True si le texte contient une variable {var}
anchor_hint: str = "" # Indice visuel pour aider la résolution
# Contrôle strict des étapes — l'action ne peut s'exécuter que si la fenêtre
# active correspond à `expected_window_before`, et ne peut passer à la
# suivante que si la fenêtre résultante correspond à `expected_window_after`.
# Ces champs sont extraits par l'IRBuilder depuis les événements bruts.
expected_window_before: str = ""
expected_window_after: str = ""
def to_dict(self) -> Dict[str, Any]:
d = {"type": self.type}
@@ -90,6 +96,10 @@ class Action:
d["variable"] = True
if self.anchor_hint:
d["anchor_hint"] = self.anchor_hint
if self.expected_window_before:
d["expected_window_before"] = self.expected_window_before
if self.expected_window_after:
d["expected_window_after"] = self.expected_window_after
return d
@classmethod