42 KiB
AXE B2 — Pattern Planner-Actor-Validator & validation sémantique post-action
Date : 2026-05-23 Auteur : agent recherche dispatché (Claude Opus 4.7 1M) Statut : livrable de recherche, lecture seule, AUCUNE modification de code Lien dépendances : AXE_A4 (OCR), AXE_A5 (tokenisation écran — déjà rédigé), AXE_B4 (ORA observe_reason_act)
1. TL;DR + recommandation
Constat. Skyvern (12k stars, SOTA 85.85 % WebVoyager) formalise le Validator comme un agent à part entière, séparé du Planner et de l'Actor. Son rôle : après chaque step, prendre une nouvelle capture, demander à un LLM (avec image + DOM élagué) si l'objectif courant est atteint, sinon renvoyer continue / terminate. C'est exactement ce qui manque à rpa_vision_v3 : VWB = Planner statique, Léa = Actor, et replay_verifier.py est un pixel-diff global qui n'a aucune notion de sémantique (« est-ce que l'onglet Imagerie de l'app Easily est maintenant actif ? »).
Le bug archétype step 10 démo GHT (« Imagerie » cliqué dans le bandeau Edge, REPORT success=True) tient uniquement à cette absence : pHash global voit du mouvement → conclut OK. Un Validator visuel par step le détecterait en 1-3 s.
Recommandation design pour rpa_vision_v3 (justifiée §6, §9) :
- Garder
replay_verifier.verify_action(pixel) comme pré-filtre 10 ms. - Réactiver et étendre
verify_with_criticdéjà câblé (§6) en lui passant unexpected_resulttypé par action. - Ajouter un
Validatorpluggable côté serveur, qui choisit la stratégie de check selonaction_type(matrice §5). Implémentation Python = ~250 LOC. - Pour le bug step 10 précisément :
click_anchordoit déclencher une vérif OCR-ROI autour du point cliqué (rayon 60 px) ET une vérif title-bar (déjà fait parcore/grounding/title_verifier.py). Si la ROI contient le mot Edge / le mot URL / un domaine.com, c'est un faux clic → retry, pas continue. - Latence cible : pixel 10 ms, OCR-ROI 100 ms, LLM-judge 2-3 s. Ne lancer le LLM-judge que si pixel OU OCR-ROI suspect.
Le pattern Skyvern est directement adoptable. Le code Skyvern (Python, AGPL-3.0) montre que le Validator c'est 5 prompts Jinja2 + 1 méthode complete_verify + 1 dataclass CompleteVerifyResult. Pas plus.
2. Skyvern Validator détaillé (code source 23 mai 2026)
2.1. Méthode complete_verify (extraite verbatim de skyvern/forge/agent.py:2609-2730)
Source : https://github.com/Skyvern-AI/skyvern/blob/main/skyvern/forge/agent.py#L2609
Le Validator chez Skyvern n'est pas un sous-processus exotique : c'est une coroutine LLM appelée après l'Actor, à chaque step où il n'y a pas déjà une DecisiveAction (= action terminale émise par l'Actor lui-même).
# skyvern/forge/agent.py (résumé condensé du flux)
async def complete_verify(
self, page: Page, scraped_page: ScrapedPage, task: Task, step: Step
) -> CompleteVerifyResult:
# 1. RE-SCRAPE la page (DOM élagué + screenshots), pas la version utilisée par l'Actor
scraped_page_refreshed = await scraped_page.refresh(draw_boxes=False, scroll=scroll)
# 2. Construit le prompt avec : navigation_goal, payload, complete_criterion,
# action_history, elements parsés, datetime
template_name = "check-user-goal-with-termination" if use_termination_prompt else "check-user-goal"
verification_prompt = load_prompt_with_elements(
element_tree_builder=scraped_page_refreshed,
template_name=template_name,
navigation_goal=task.navigation_goal,
navigation_payload=task.navigation_payload,
complete_criterion=task.complete_criterion,
terminate_criterion=task.terminate_criterion,
action_history=actions_and_results_str,
local_datetime=...,
)
# 3. Appel LLM avec screenshots — un handler LLM dédié possible
# via flag PostHog USE_CHECK_USER_GOAL_HANDLER_FOR_VERIFICATION
verification_result = await llm_api_handler(
prompt=verification_prompt,
step=step,
screenshots=scraped_page_refreshed.screenshots,
prompt_name=prompt_name,
)
# 4. Parse JSON strict → 3 verdicts possibles
result = CompleteVerifyResult.model_validate(verification_result)
if result.is_complete:
verification_status = VerificationStatus.complete
elif result.is_terminate:
verification_status = VerificationStatus.terminate
else:
verification_status = VerificationStatus.continue_step
# 5. Trace OTEL : verification.status, verification.template, verification.reasoning_kind
span.set_attribute("verification.status", verification_status.value)
record_verification_span_attrs(span, result.thoughts)
return result
Trois verdicts uniquement : complete / terminate / continue_step. Pas de success_partial ni de retry_silent. C'est volontaire : la décision est forcée binaire.
Le check_user_goal_complete (lignes 2736+) wrap complete_verify et le convertit en CompleteAction ou TerminateAction pour l'orchestrateur.
2.2. Le prompt check-user-goal.j2 (verbatim, fetch direct du repo)
Your are here to help the user determine if the user has completed their goal on the web{{ " according to the complete criterion" if complete_criterion else "" }}. Use the content of the elements parsed from the page,{{ "" if without_screenshots else " the screenshots of the page," }} the user goal and user details to determine whether the {{ "complete criterion has been met" if complete_criterion else "user goal has been completed" }} or not.
Make sure to ONLY return the JSON object in this format with no additional text before or after it:
{
"page_info": str, // Think step by step. Describe all the useful information in the page related to the user goal.
"thoughts": str, // Think step by step. What information makes you believe whether user goal has completed or not. Use information you see on the site to explain.
"user_goal_achieved": bool // True if the user goal has been completed, false otherwise.
}
User Goal:
{{ navigation_goal }}
User Details:
{{ navigation_payload }}
Action History:
{{ action_history }}
Elements on the page:
{{ elements }}
Current datetime, ISO format:
{{ local_datetime }}
Points clés :
- Sortie JSON stricte, parsée par Pydantic
CompleteVerifyResult.model_validate. - Trois infos données au modèle : (a) screenshots, (b) elements parsés du DOM, (c) action_history textuelle. Multi-modal.
- Le
page_info→thoughts→user_goal_achievedimpose une chain-of-thought structurée. C'est ce qui rend l'erreur diagnosticable.
2.3. Le prompt check-user-goal-with-termination.j2 (expérimental, verbatim)
Ajoute un 3e statut explicite terminate + une classification des échecs en 12 catégories :
"status": str, // Must be one of three values: "complete", "terminate", or "continue".
"failure_categories": array // Only populate when status is "terminate". Classify the root cause.
[{
"category": str, // ANTI_BOT_DETECTION | BROWSER_ERROR | NAVIGATION_FAILURE |
// PAGE_LOAD_TIMEOUT | AUTH_FAILURE | LLM_REASONING_ERROR |
// CREDENTIAL_ERROR | ELEMENT_NOT_FOUND | WRONG_PAGE_STATE |
// DATA_EXTRACTION_FAILURE | INFRASTRUCTURE_ERROR | UNKNOWN
"confidence_float": float,
"reasoning": str
}]
Important: Think carefully about the difference between "terminate" and "continue":
- "terminate" = impossible to achieve, stop trying
- "continue" = not done yet, but achievable with more steps
À retenir : Skyvern est très conservateur sur terminate (« only when CLEAR, EXPLICIT, UNAMBIGUOUS evidence »). C'est aligné avec le feedback feedback_failure_is_learning.md de Dom : échec ≠ stop avec erreur, c'est pause supervisée.
2.4. Quand le Validator se déclenche
Extrait agent.py:1929-1971 :
enable_parallel_verification = False
if (
not has_decisive_action # l'Actor n'a pas déjà émis un COMPLETE
and not task_completes_on_download
and not isinstance(task_block, ActionBlock)
and complete_verification # flag global activable par-task
and (task.navigation_goal or task.complete_criterion)
):
# Géré par feature flag PostHog
disable_user_goal_check = await app.EXPERIMENTATION_PROVIDER.is_feature_enabled_cached(
"DISABLE_USER_GOAL_CHECK",
task.task_id,
...
)
enable_parallel_verification = not disable_user_goal_check
→ Le Validator tourne à CHAQUE step par défaut (« deferred to handle_completed_step »). C'est désactivable par task ou globalement, mais l'état par défaut est ON. Skyvern accepte le coût LLM par step parce qu'un faux succès rend l'agent inutilisable.
2.5. Contrat de données
# skyvern/forge/sdk/schemas/tasks.py (déduit du code agent.py)
class CompleteVerifyResult(BaseModel):
page_info: str
thoughts: str
is_complete: bool
is_terminate: bool = False
status: str | None = None # "complete" | "terminate" | "continue"
failure_categories: list[FailureCategory] = []
2.6. Latence et coût
D'après le post Skyvern 2.0 (https://www.skyvern.com/blog/skyvern-2-0-state-of-the-art-web-navigation-with-85-8-on-webvoyager-eval/) :
- Un step moyen prend 2-10 s.
- Validator = appel LLM séparé (souvent un GPT-4o-mini ou Claude Haiku), 1-3 s.
- ROI = sans Validator, accuracy 68.7 % WebVoyager ; avec Validator, 85.85 %. Le delta de +17 points en accuracy justifie largement la latence.
Source : https://browser-use.com/posts/our-browser-agent-evaluation-system (browser-use rapporte +17 pts également : 45 → 68.7 → 85.85 selon Planner/Validator).
3. Tour d'horizon Validator dans 5 autres frameworks
3.1. OpenAdapt — Evaluation-Driven Feedback
Source : https://github.com/OpenAdaptAI/OpenAdapt/wiki/OpenAdapt-Architecture-(draft), https://github.com/OpenAdaptAI/openadapt-evals.
OpenAdapt formalise le concept au niveau Process Graph (graphe de steps avec arêtes = critères de complétion) :
- Code-based validation : LLM génère du Python qui vérifie une condition d'état (présence d'un message de confirmation, état d'un bouton, etc.). Code stocké, ré-exécuté à chaque replay.
- Model-based validation : LMM (Large Multimodal Model) reçoit le screenshot courant +
completion_criteriaformulés en langage naturel → bool.
Particularité : si la validation échoue, OpenAdapt bascule en mode recording automatiquement → l'utilisateur démontre la suite → la trace devient training data. C'est l'« Evaluation-Driven Feedback ». Le sous-package openadapt-evals expose evaluate_agent_on_benchmark.
3.2. browser-use — agentic judge
Source : https://browser-use.com/posts/our-browser-agent-evaluation-system, https://github.com/browser-use/browser-use.
- LLM judge intégré dans le code agent, tourne après
doneET « can also double as a real-time validation layer during regular use ». - Modèle :
gemini-2.5-flash. Accuracy juge vs labels humains : 87 %. - Sortie JSON stricte :
{
"reasoning": "Analysis covering what worked, failures, trajectory quality, tool usage, output quality",
"verdict": "true|false",
"failure_reason": "Max 5 sentences explanation if failed",
"impossible_task": "true|false",
"reached_captcha": "true|false"
}
- Philosophie : simple prompts and absolute True/False verdicts work best. Complex rubrics → indecisive judging.
3.3. Anthropic Computer Use
Source : https://docs.anthropic.com/en/docs/build-with-claude/computer-use.
Anthropic CU n'a pas de Validator nommé. Boucle minimaliste : screenshot → action → screenshot → ... jusqu'à ce que Claude lui-même décide qu'il a fini. Validation = self-reflection implicite du modèle dans son raisonnement.
→ Acceptable parce que Claude est puissant. Pas applicable à rpa_vision_v3 où l'Actor n'est pas un LLM agentique mais un exécutant déterministe (Léa). Il faut un Validator externe.
3.4. OpenAI Operator / CUA
Source : https://openai.com/index/operator-system-card/.
Idem Anthropic CU : pas de Validator séparé. Le modèle CUA fait perception → reasoning → action en boucle. Selon le system card : « If it encounters challenges or makes mistakes, Operator can leverage its reasoning capabilities to self-correct ». Pas formalisé.
OpenCUA (open-source, https://opencua.xlang.ai/) entraîne avec « reflective Chain-of-Thought reasoning » mais pas de check externe.
3.5. Cradle (BAAI, Kunlun Tech) — Self-Reflection module
Source : https://github.com/BAAI-Agents/Cradle, https://arxiv.org/pdf/2403.03186.
Cradle décompose explicitement en 6 modules dont Self-Reflection :
« Through this module, the agent assesses previous actions to understand their outcomes, evaluate successes or failures, and adjust behavior accordingly. »
Mesure : +20.41 points sur tâches « professional domain » vs baselines. Mais c'est un agent jeu/applications, pas RPA déclaratif → moins directement transposable.
3.6. Tableau récap
| Framework | Validator nommé ? | Modalité | Modèle | Latence | Verdict format |
|---|---|---|---|---|---|
| Skyvern 2.0 | Oui (complete_verify) |
VLM + DOM élagué | GPT-4o ou handler dédié | 1-3 s | JSON is_complete/is_terminate/status |
| OpenAdapt | Oui (Process Graph) | LMM ou Python généré | Configurable | n/a | bool + falls back to recording |
| browser-use | Oui (agentic judge) | VLM + DOM | gemini-2.5-flash | 1-2 s | JSON verdict/failure_reason |
| Anthropic CU | Non (implicite) | Self-reflection | Claude lui-même | inclus | continuation libre |
| OpenAI Operator | Non (implicite) | Self-reflection | CUA | inclus | continuation libre |
| Cradle | Oui (Self-Reflection) | LMM | GPT-4V | 2-5 s | text reasoning |
Convergence forte : les 3 frameworks RPA matures (Skyvern, OpenAdapt, browser-use) ont un Validator explicite, JSON-strict, multi-modal (VLM + structure DOM). Les agents généralistes (CU, Operator) délèguent au LLM agentique. Pour rpa_vision_v3 avec Actor déterministe = camp Skyvern.
4. Taxonomie des approches de validation post-action
| Approche | Coût | Précision | Faux-positifs | Quand l'utiliser |
|---|---|---|---|---|
| A. LLM-as-judge (full VLM) | 1-5 s | Très haute (sémantique) | Faibles | Validation finale de step / cas ambigus |
| B. OCR ROI (texte attendu autour du clic) | 80-200 ms | Haute si texte connu | Sensible OCR errors | Tabs, boutons, libellés |
| C. OCR title-bar (titre fenêtre) | ~120 ms (déjà câblé) | Moyenne | Bruit OCR sur petits crops | Navigation fenêtre / ouverture appli |
| D. Visual diff pHash global | 10 ms | Très basse (juste « ça a bougé ») | Énormes | Pré-filtre nothing-happened |
| E. Visual diff pHash ROI | 20 ms | Moyenne | Moyens | Détection focus tab (changement souligné) |
| F. CLIP features cos-sim | 50-200 ms | Moyenne | Confond visuellement proches | Reconnaissance d'écran connu |
| G. DINOv2 features | 100-300 ms | Haute (self-supervised, plus robuste que CLIP) | Faibles | Comparaison patches précis |
| H. LPIPS | 100 ms | Haute (perceptual) | Moyens | Vérif après animations / transitions |
| I. Window-focus check (win32 API ou OCR titlebar) | <50 ms | Très haute | Quasi nuls | Vérif que la bonne app est devant |
| J. Dialog presence detect | OCR + template | Très haute | Faibles | Détection popups bloquantes |
| K. JSON schema validation (extraction) | <10 ms | Déterministe | nuls | extract_text, t2a_decision |
Source visual diff : https://wopee.io/blog/screenshot-comparison-algorithms-visual-testing/ — pHash est positionné comme « pre-filter, not a comparator ». Les VLM sont positionnés comme « triage layer on top of pixel diffs, not as the comparator itself ». Exactement le design pixel→sémantique déjà câblé dans replay_verifier.verify_with_critic.
Pour DINOv2 / LPIPS / CLIP : sources https://github.com/facebookresearch/dinov2, https://medium.com/aimonks/clip-vs-dinov2-in-image-similarity-6fa5aa7ed8c6. DINOv2 produit des features visuelles plus discriminantes que CLIP pour comparer deux crops d'UI (CLIP est entraîné texte↔image, pas pour le pixel-perfect).
5. Matrice type d'action → check recommandé pour rpa_vision_v3
Aligné avec reference_vwb_action_types.md (memory) et _ALLOWED_ACTION_TYPES de replay_engine.py.
| Action VWB (Léa) | Check primaire | Check secondaire (si primaire ambigu) | Budget latence |
|---|---|---|---|
click_anchor → click |
B. OCR ROI (rayon 60 px) + I. Window focus | A. LLM-as-judge si OCR ne trouve pas le label | 100 ms + 2 s si escalation |
double_click_anchor → click button="double" |
C. OCR title-bar (déjà câblé) + B. OCR ROI | A. LLM-as-judge | 200 ms + 2 s |
right_click_anchor → click button="right" |
J. Dialog presence (menu contextuel attendu) | B. OCR ROI sur menu | 150 ms |
type_text → type |
B. OCR ROI : le texte tapé est-il visible dans la ROI ? | A. LLM-as-judge si texte tronqué | 100 ms |
type_secret |
D. pHash ROI (vérifier qu'un input s'est rempli, pas le contenu) | — | 20 ms |
keyboard_shortcut → key_combo |
C. OCR title-bar OU J. Dialog presence selon raccourci | A. LLM-as-judge en cas de doute | 200 ms |
scroll_to_anchor → scroll |
F. CLIP cos-sim before/after ROI cible visible | D. pHash global change ≠ 0 | 100 ms |
wait_for_anchor → wait |
B. OCR ROI : l'ancre est-elle visible ? | A. LLM-as-judge | 100 ms |
extract_text |
K. JSON schema : type str, longueur > 0, langue fr ratio | A. LLM-as-judge sur le contenu plausibilité | 10 ms + 2 s si plausibilité requise |
extract_text_scroll |
K + A. LLM-as-judge si plusieurs pages | — | 10 ms + 2 s |
extract_table |
K. JSON schema : ≥ 1 row, headers attendus si fournis | A. LLM-as-judge | 10 ms |
screenshot_evidence |
— (action passive) | I. Window focus | <50 ms |
t2a_decision |
K. JSON schema strict (decision ∈ {UHCD, FORFAIT, NA}, JSON parseable) | — | 10 ms |
pause_for_human |
Checklist QW4 (déjà fait, SafetyChecksProvider) |
— | n/a |
db_save_data |
K. Schema row sauvée (SELECT verify) | — | <50 ms |
import_excel, db_read_data |
K. Schema rows | — | <50 ms |
visual_condition |
A. LLM-as-judge sur la condition formulée | — | 2 s |
ai_ocr, ai_summarize, etc. |
K. JSON schema + A. plausibilité | — | 10 ms + 2 s |
Principe directeur : la plupart des actions ont un check pas-cher (OCR ROI, JSON) qui suffit dans 90 % des cas. Le LLM-as-judge (2 s) ne tire qu'en escalation, ou sur les actions à risque élevé (click_anchor sur cibles ambiguës, t2a_decision, visual_condition).
6. Design d'un Validator pluggable — code copy-paste-ready
6.1. Interface
À placer dans agent_v0/server_v1/validator.py (nouveau fichier, complète replay_verifier.py existant) :
# agent_v0/server_v1/validator.py
"""
Validator — vérification sémantique post-action pluggable.
Inspiré de Skyvern (Planner-Actor-Validator). Combine pixel-diff existant
(replay_verifier.py) avec une couche sémantique typée par action_type.
Trois verdicts possibles, calque sur Skyvern :
- COMPLETE → l'action a eu l'effet voulu, passer au step suivant
- CONTINUE → l'effet n'est pas encore visible, re-vérifier après wait
- TERMINATE → l'action a échoué de manière irrécupérable (pause supervisée)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, Optional, Protocol
logger = logging.getLogger(__name__)
class Verdict(str, Enum):
COMPLETE = "complete"
CONTINUE = "continue"
TERMINATE = "terminate"
class FailureCategory(str, Enum):
WRONG_TARGET = "wrong_target" # cliqué ailleurs (ex. bug step 10)
NO_VISUAL_CHANGE = "no_visual_change" # action sans effet
UNEXPECTED_DIALOG = "unexpected_dialog" # popup bloque
WRONG_APPLICATION = "wrong_application" # focus sur mauvaise app (Edge vs Easily)
OCR_TEXT_MISSING = "ocr_text_missing" # texte attendu absent
SCHEMA_INVALID = "schema_invalid" # JSON/extract invalide
UNKNOWN = "unknown"
@dataclass
class ValidationResult:
verdict: Verdict
confidence: float # 0.0-1.0
check_used: str # "ocr_roi" | "llm_judge" | "title_bar" | ...
elapsed_ms: float
reasoning: str = ""
failure_category: Optional[FailureCategory] = None
raw_evidence: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"verdict": self.verdict.value,
"confidence": round(self.confidence, 3),
"check_used": self.check_used,
"elapsed_ms": round(self.elapsed_ms, 1),
"reasoning": self.reasoning,
"failure_category": self.failure_category.value if self.failure_category else None,
"raw_evidence": self.raw_evidence,
}
class ActionChecker(Protocol):
"""Contrat d'un checker spécifique par action_type."""
name: str
budget_ms: float
def check(
self,
action: Dict[str, Any],
result: Dict[str, Any],
screenshot_before: Optional[str],
screenshot_after: Optional[str],
context: Dict[str, Any],
) -> ValidationResult: ...
class Validator:
"""Orchestrateur : route action_type → checker, gère l'escalation."""
def __init__(
self,
checkers: Dict[str, list[ActionChecker]],
default_checker: ActionChecker,
escalation_checker: Optional[ActionChecker] = None,
escalation_threshold: float = 0.5,
):
"""
checkers: mapping action_type → liste de checkers à essayer en ordre.
default_checker: fallback si action_type pas dans le mapping.
escalation_checker: typiquement un LLM-as-judge, lancé si confidence < seuil.
"""
self._checkers = checkers
self._default = default_checker
self._escalation = escalation_checker
self._escalation_threshold = escalation_threshold
def validate(
self,
action: Dict[str, Any],
result: Dict[str, Any],
screenshot_before: Optional[str] = None,
screenshot_after: Optional[str] = None,
context: Optional[Dict[str, Any]] = None,
) -> ValidationResult:
context = context or {}
action_type = action.get("type", "")
candidates = self._checkers.get(action_type, [self._default])
last_result: Optional[ValidationResult] = None
for checker in candidates:
res = checker.check(action, result, screenshot_before, screenshot_after, context)
last_result = res
# Si verdict net + confiance haute → renvoyer
if res.confidence >= self._escalation_threshold and res.verdict != Verdict.CONTINUE:
return res
# Escalation LLM-as-judge si fourni
if self._escalation and last_result and last_result.confidence < self._escalation_threshold:
logger.info(
"Validator escalation LLM-judge (last_conf=%.2f, check=%s)",
last_result.confidence, last_result.check_used,
)
esc = self._escalation.check(action, result, screenshot_before, screenshot_after, context)
# On combine : si LLM contredit, LLM prime (sa confiance est bornée à 0.9)
return esc
return last_result or ValidationResult(
verdict=Verdict.CONTINUE,
confidence=0.3,
check_used="no_checker",
elapsed_ms=0.0,
reasoning="Aucun checker n'a produit de verdict",
)
6.2. Exemple de checker : OcrRoiChecker (pour click_anchor)
# agent_v0/server_v1/checkers/ocr_roi.py
import time
from typing import Any, Dict, Optional
from PIL import Image
from agent_v0.server_v1.validator import (
ActionChecker, ValidationResult, Verdict, FailureCategory,
)
class OcrRoiChecker:
"""Vérifie que le texte attendu apparaît dans la ROI autour du clic.
Spécifiquement conçu pour résoudre le bug step 10 :
si on a cliqué sur 'Imagerie', la ROI 60px doit contenir 'Imagerie'.
Si elle contient 'Edge' ou 'urgence.labs.laurinebazin.design',
on a cliqué dans le bandeau navigateur → failure.
"""
name = "ocr_roi"
budget_ms = 200.0
# Mots suspects = on a cliqué hors-app
SUSPECT_TOKENS = {"edge", "chrome", "firefox", "http", "https", ".com", ".fr",
"favoris", "favorite", "onglet", "tab "}
def __init__(self, ocr_fn, radius_px: int = 60):
self._ocr = ocr_fn # callable(PIL.Image) -> str
self._radius = radius_px
def check(self, action, result, screenshot_before, screenshot_after, context) -> ValidationResult:
t0 = time.time()
expected_text = action.get("by_text") or context.get("expected_text", "")
x_pct = action.get("x_pct")
y_pct = action.get("y_pct")
if not screenshot_after or x_pct is None or y_pct is None:
return ValidationResult(
verdict=Verdict.CONTINUE, confidence=0.2,
check_used=self.name, elapsed_ms=(time.time() - t0) * 1000,
reasoning="ROI indéfinie (pas de coords ou pas de screenshot)",
)
img = self._load_image(screenshot_after)
w, h = img.size
cx, cy = int(x_pct * w), int(y_pct * h)
r = self._radius
roi = img.crop((max(0, cx - r), max(0, cy - r), min(w, cx + r), min(h, cy + r)))
text = (self._ocr(roi) or "").lower()
expected_lower = expected_text.lower().strip()
elapsed_ms = (time.time() - t0) * 1000
# 1) Vérif : un token suspect (navigateur) dans la ROI → faux clic
for suspect in self.SUSPECT_TOKENS:
if suspect in text and suspect not in expected_lower:
return ValidationResult(
verdict=Verdict.TERMINATE, confidence=0.85,
check_used=self.name, elapsed_ms=elapsed_ms,
failure_category=FailureCategory.WRONG_APPLICATION,
reasoning=f"Token navigateur '{suspect}' dans ROI clic — cible probablement hors-app",
raw_evidence={"roi_text": text[:200], "expected": expected_lower},
)
# 2) Vérif : le texte attendu est dans la ROI ?
if expected_lower and expected_lower in text:
return ValidationResult(
verdict=Verdict.COMPLETE, confidence=0.9,
check_used=self.name, elapsed_ms=elapsed_ms,
reasoning=f"Texte '{expected_lower[:40]}' trouvé dans ROI",
raw_evidence={"roi_text": text[:200]},
)
# 3) Pas trouvé mais pas suspect non plus → confiance basse, escalation
return ValidationResult(
verdict=Verdict.CONTINUE, confidence=0.4,
check_used=self.name, elapsed_ms=elapsed_ms,
failure_category=FailureCategory.OCR_TEXT_MISSING,
reasoning=f"Texte '{expected_lower[:40]}' non trouvé dans ROI",
raw_evidence={"roi_text": text[:200]},
)
@staticmethod
def _load_image(source: str) -> Image.Image:
# Délégué à replay_verifier._load_single_image, ou copy-paste équivalent
from agent_v0.server_v1.replay_verifier import ReplayVerifier
return ReplayVerifier()._load_single_image(source)
6.3. Intégration avec replay_verifier.py existant
Le replay_verifier.verify_with_critic couvre déjà 80 % du besoin LLM-as-judge (étape sémantique VLM). Il suffit de :
- Le wrapper dans un
LlmJudgeCheckerqui implémenteActionChecker. - L'utiliser comme
escalation_checkerduValidator.
# agent_v0/server_v1/checkers/llm_judge.py
import time
from agent_v0.server_v1.replay_verifier import ReplayVerifier
from agent_v0.server_v1.validator import (
ActionChecker, ValidationResult, Verdict, FailureCategory,
)
class LlmJudgeChecker:
"""Wrapper autour de ReplayVerifier.verify_with_critic (VLM gemma4)."""
name = "llm_judge"
budget_ms = 3000.0
def __init__(self, verifier: ReplayVerifier):
self._verifier = verifier
def check(self, action, result, screenshot_before, screenshot_after, context) -> ValidationResult:
t0 = time.time()
expected = context.get("expected_result", "")
intention = context.get("action_intention", "")
workflow_ctx = context.get("workflow_context", "")
critic = self._verifier.verify_with_critic(
action=action, result=result,
screenshot_before=screenshot_before,
screenshot_after=screenshot_after,
expected_result=expected,
action_intention=intention,
workflow_context=workflow_ctx,
)
elapsed_ms = (time.time() - t0) * 1000
if critic.semantic_verified is True:
verdict = Verdict.COMPLETE
conf = max(critic.confidence, 0.7)
elif critic.semantic_verified is False:
verdict = Verdict.TERMINATE
conf = 0.8
else:
verdict = Verdict.CONTINUE
conf = 0.4
return ValidationResult(
verdict=verdict, confidence=conf,
check_used=self.name, elapsed_ms=elapsed_ms,
reasoning=critic.semantic_detail or critic.detail,
raw_evidence={"pixel_change_pct": critic.change_area_pct,
"semantic_verified": critic.semantic_verified},
)
6.4. Câblage côté api_stream.py (post-action)
Pseudo-diff (NE PAS appliquer, juste pour montrer le point d'insertion) :
# agent_v0/server_v1/api_stream.py — handler de REPORT
from agent_v0.server_v1.validator import Validator, Verdict
from agent_v0.server_v1.checkers.ocr_roi import OcrRoiChecker
from agent_v0.server_v1.checkers.llm_judge import LlmJudgeChecker
# Init au boot
_validator = Validator(
checkers={
"click": [OcrRoiChecker(ocr_fn=_easyocr_fn)],
"type": [OcrRoiChecker(ocr_fn=_easyocr_fn)],
"key_combo": [TitleBarChecker()], # voir core/grounding/title_verifier.py
# ...
},
default_checker=PixelDiffChecker(), # wrapper ReplayVerifier.verify_action
escalation_checker=LlmJudgeChecker(ReplayVerifier()),
escalation_threshold=0.55,
)
# Dans report_action_result, après le pixel-diff actuel
async def report_action_result(payload):
...
if RPA_VALIDATOR_ENABLED: # kill-switch env var
val = _validator.validate(
action=action, result=result,
screenshot_before=before, screenshot_after=after,
context={"expected_text": action.get("by_text"),
"expected_result": step.get("expected_result", ""),
"action_intention": step.get("label", ""),
"workflow_context": f"step {step_idx}/{total_steps}"},
)
if val.verdict == Verdict.TERMINATE:
# Pause supervisée, pas stop avec error (cf. feedback_failure_is_learning)
_enter_paused_state(reason=val.reasoning, evidence=val.to_dict())
elif val.verdict == Verdict.CONTINUE:
# Re-vérifier après wait, ou retry
_schedule_recheck(action_id, after_ms=1500)
# COMPLETE → continue normalement
7. Application au bug step 10 démo GHT
Rappel du bug (cf. REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md) : step 10 « cliquer onglet Imagerie », OCR-DIRECT renvoie centre de la rangée de tabs → le clic tombe dans la URL bar Edge (au-dessus). pHash global voit du changement → REPORT success=True. Cascade.
Avec le Validator proposé :
- Action
click_anchor(by_text="Imagerie",x_pct=0.23,y_pct=0.28). - Léa rapporte success après mouseclick. Screenshot_after capturé.
Validator.validate(action_type="click", ...)route versOcrRoiChecker.- ROI 60 px autour de (0.23, 0.28) → réellement la URL bar.
- EasyOCR du crop renvoie texte type :
« urgence.labs.laurinebazin.design/aiva-urgence/dossier.html#imagerie » - Token
.comouhttpsdétecté →Verdict.TERMINATEavecFailureCategory.WRONG_APPLICATION. - Reasoning : « Token navigateur 'https' dans ROI clic — cible probablement hors-app ».
api_streamentre en pause supervisée avecevidence={roi_text, expected}. Dom voit dans le dashboard ce qui s'est mal passé. Pas d'enchainement vers step 11.
Latence ajoutée : 100-200 ms (EasyOCR sur 120×120 px). Négligeable vs. les 6 s passés à enchaîner 5 steps faux et à entrer en pause supervisée 33 s plus tard.
Effet secondaire bénéfique : le même mécanisme attrape :
- Clics sur popups Windows (Hello / UAC) → ROI contient « Sécurité Windows » → TERMINATE.
- Clics sur le menu démarrer ou la barre des tâches.
- Tout clic qui tombe dans une zone système non prévue.
8. Budget latence par check — qu'accepter en démo ?
Hypothèse démo GHT (40 steps, 2 min de pipeline cible) :
| Check | Latence | × 40 steps | Acceptable démo ? |
|---|---|---|---|
| Pixel diff global (existant) | 10 ms | 0.4 s | ✅ ON par défaut |
| OCR ROI EasyOCR | 100-200 ms | 4-8 s | ✅ ON sur click, type |
| OCR title-bar (existant) | 120 ms | 4.8 s | ✅ ON sur navigation |
| Schema validation (JSON) | <10 ms | 0.4 s | ✅ ON sur extract_*, t2a_decision |
| LLM-judge gemma4 critic | 2-3 s | 80-120 s | ⚠️ SEULEMENT en escalation |
| LLM-judge cloud (Claude Haiku) | 1-2 s | 40-80 s | ⚠️ SEULEMENT en escalation |
| DINOv2 features ROI | 150 ms | 6 s | ❓ pas nécessaire pour démo |
Recommandation budget :
- Démo : pixel + OCR ROI + title-bar + schema = ~10 s de latence cumulée sur 40 steps. Acceptable.
- LLM-judge escalation déclenché ~5 fois max par démo = 10 s ajoutés. Tolérable si placé sur les steps à risque (clics ambigus sur tabs).
- DINOv2 hors-périmètre démo. À benchmarker post-démo.
Kill-switch obligatoire (cf. QW Suite Mai, conventions Dom) :
RPA_VALIDATOR_ENABLED=true # active la couche entière
RPA_VALIDATOR_LLM_JUDGE_ENABLED=true # active escalation LLM (coûteuse)
RPA_VALIDATOR_OCR_ROI_RADIUS=60 # tunable
RPA_VALIDATOR_ESCALATION_THRESHOLD=0.55
9. Plan d'intégration gradué
9.1. Court terme — 1 jour, faisable avant prochaine démo (P0)
But : éliminer la classe « clic hors-app silencieusement success=True ».
- Créer
agent_v0/server_v1/validator.py(squelette §6.1) — 1 h. - Créer
OcrRoiChecker(§6.2) — 2 h. - Wrapper
LlmJudgeCheckerautour deverify_with_criticexistant (§6.3) — 30 min. - Ajouter hook dans
api_stream.report_action_resultderrièreRPA_VALIDATOR_ENABLED=falsepar défaut — 2 h. - Tests :
- Unit : ROI text matching, suspect tokens, escalation logic — 2 h.
- Integration : rejouer step 10 sur fixture screenshot — 1 h.
- Démo interne avec
RPA_VALIDATOR_ENABLED=truesur Demo_urgence_3_db, mesure latence + faux positifs — 1 h.
Livrable : pas de régression démo si flag off ; quand on, le bug step 10 est attrapé en TERMINATE → pause supervisée.
9.2. Moyen terme — 1-2 semaines (P1)
But : matrice complète action → check (§5).
TitleBarCheckeradapté decore/grounding/title_verifier.pyexistant — 2 h.JsonSchemaCheckerpourextract_text,t2a_decision,extract_table— 4 h.DialogPresenceCheckerréutilisant la cascade de modaux VM (feedback_phash_vs_dialog_in_vm.md) — 4 h.PixelDiffChecker(wrapper de l'existant) avec verdict adapté au contrat Verdict — 2 h.- Câblage de la matrice complète selon §5 — 4 h.
- Dashboard : panneau « Validator stats » par session — pourcentage COMPLETE / CONTINUE / TERMINATE, top failure_categories — 1 j.
9.3. Long terme — post-démo (P2)
- Évaluer DINOv2 vs OCR ROI sur fixtures GHT : meilleur signal pour distinguer « tab activé vs tab survolé » ? Bench 100 steps.
- Migration LLM-judge de gemma4:e4b (local) vers un handler dédié — séparer le « LLM décisionnel T2A » du « LLM judge ». Skyvern expose
USE_CHECK_USER_GOAL_HANDLER_FOR_VERIFICATIONqui sépare déjà. - Apprentissage : enregistrer dans
TargetMemoryStorechaque verdict TERMINATE pour produire du training data (pattern OpenAdapt « success traces become new training data »). - Re-planification : si TERMINATE répété → renvoyer info au Planner pour ajuster le workflow (cf. Skyvern « reporting any errors / tweaks back to the Planner so it can make adjustments in real-time »). Pour rpa_vision_v3 : signaler à VWB que l'ancre est foireuse → suggestion recapture.
10. Sources avec liens
Skyvern (Planner-Actor-Validator)
- Blog Skyvern 2.0 — https://www.skyvern.com/blog/skyvern-2-0-state-of-the-art-web-navigation-with-85-8-on-webvoyager-eval/ (annonce de l'archi Planner-Actor-Validator, score WebVoyager 85.85 %)
- GitHub repo — https://github.com/Skyvern-AI/skyvern
agent.py(méthodecomplete_verify) — https://github.com/Skyvern-AI/skyvern/blob/main/skyvern/forge/agent.py ligne 2609 (au 23 mai 2026)- Prompt
check-user-goal.j2— https://raw.githubusercontent.com/Skyvern-AI/skyvern/main/skyvern/forge/prompts/skyvern/check-user-goal.j2 - Prompt
check-user-goal-with-termination.j2— https://raw.githubusercontent.com/Skyvern-AI/skyvern/main/skyvern/forge/prompts/skyvern/check-user-goal-with-termination.j2 - Prompt
decisive-criterion-validate.j2— https://raw.githubusercontent.com/Skyvern-AI/skyvern/main/skyvern/forge/prompts/skyvern/decisive-criterion-validate.j2 - Hacker News show — https://news.ycombinator.com/item?id=42724616
browser-use (agentic judge)
- Blog « Our browser agent evaluation system » — https://browser-use.com/posts/our-browser-agent-evaluation-system
- AGENTS.md — https://github.com/browser-use/browser-use/blob/main/AGENTS.md
OpenAdapt (Evaluation-Driven Feedback)
- GitHub OpenAdapt — https://github.com/OpenAdaptAI/OpenAdapt
- GitHub openadapt-evals — https://github.com/OpenAdaptAI/openadapt-evals
- Wiki architecture — https://github.com/OpenAdaptAI/OpenAdapt/wiki/OpenAdapt-Architecture-(draft)
Anthropic Computer Use & OpenAI Operator
- Operator system card — https://openai.com/index/operator-system-card/
- OpenCUA (open foundations CUA, xLANG / HKU) — https://opencua.xlang.ai/
- Computer Use 2026 review — https://tech-insider.org/anthropic-claude-computer-use-agent-2026/
Cradle (BAAI)
- Paper arXiv 2403.03186 — https://arxiv.org/pdf/2403.03186
- GitHub — https://github.com/BAAI-Agents/Cradle
- Project page — https://baai-agents.github.io/Cradle/
Visual diff / VLM-as-judge / LLM-as-judge
- « Screenshot Comparison Algorithms » — https://wopee.io/blog/screenshot-comparison-algorithms-visual-testing/ (pHash positionné comme pre-filter, VLM comme triage layer)
- DINOv2 (Meta) — https://github.com/facebookresearch/dinov2
- CLIP vs DINOv2 image similarity — https://medium.com/aimonks/clip-vs-dinov2-in-image-similarity-6fa5aa7ed8c6
- « Aha Moment Revisited: Are VLMs Truly Capable of Self Verification » (arXiv 2506.17417) — https://arxiv.org/pdf/2506.17417
- Vision-Language Model Verifier (review) — https://www.emergentmind.com/topics/vision-language-model-vlm-verifier
- LLM-as-a-Judge guide 2026 — https://labelyourdata.com/articles/llm-as-a-judge
- « Why Success is Lying to You: The 2026 Agent Eval Stack » — https://micheallanham.substack.com/p/why-success-is-lying-to-you-the-2026
EDDOps (Evaluation-Driven Development & Operations)
- Paper arXiv 2411.13768 (v3, 2026) — https://arxiv.org/html/2411.13768v3
Doc interne rpa_vision_v3 (référencée)
docs/INSPIRATION_FRAMEWORKS_2026-05-10.md§3.1 — Planner-Actor-Validatordocs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md— bug archétype step 10docs/BUG_PRECHECK_SPATIAL_BLINDNESS_2026-05-08.md— DETTE-001agent_v0/server_v1/replay_verifier.py—verify_with_criticdéjà câblécore/grounding/title_verifier.py— TitleVerifier déjà câblé- Memory
reference_vwb_action_types.md— matrice action_types VWB
11. Dépendances avec autres axes
- AXE_A4 (OCR) :
OcrRoiCheckerrepose sur EasyOCR/docTR rapides. Si AXE_A4 livre un OCR ROI < 100 ms calibré sur petits crops, le check primaire devient ultra-fiable. Bloquant : qualité OCR sur crop 120×120 px. - AXE_A5 (tokenisation écran) : si on a un parseur UI type OmniParser qui renvoie une liste d'éléments avec bbox + label, le check ROI devient déterministe (matche
target == element_at_point(cx, cy).label). Forte synergie : un Validator + un tokenizer = on rentre dans le territoire Skyvern 2.0. - AXE_B4 (ORA) : ORA peut consommer les
ValidationResultdu Validator comme signal d'observation. Si TERMINATE → ORA ré-observe et propose une re-action. Le Validator devient l'œil de l'Actor. - DETTE-008 (pre-check VLM par-clic désactivé par
if False:) : ce Validator est sa version refaite-proprement. La désactivation actuelle est juste, mais le besoin reste — c'est ce livrable. - DETTE-001 (pre-check OCR spatialement aveugle) :
OcrRoiCheckeravecradius_px=60est exactement l'Option B mentionnée dans la note de Dom. Réduire radius + bboxes individuelles = même direction.
Document de recherche, lecture seule. Aucune décision d'implémentation prise par cet axe — décision relève de Dom et d'un planning de réintégration coordonné avec AXE_A4, A5, B4.