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:
@@ -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 ""
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user