Ferme Gap C : _edge_to_normalized_actions produit désormais une action navigate (handler serveur atteignable). Ajoute _coerce_action_coords : cast x_pct/y_pct en float APRÈS résolution des templates, JAMAIS de fallback (0,0) — template non résolu / valeur invalide → pause_for_human (safety_level=high). Non-régression prouvée sur mouse_click classiques (idempotent sur floats). ⚠️ NE FERME PAS le write-only : Gap A (P1-B) non livré — aucun step click/type ne déclare encore consommer navigate_login_coords. TestCompilerGapLiteralFloats assert l'état ouvert. Boucle complète = chantier suivant (P1-B + test e2e edge→action). 21 tests verts, boot OK. Revue croisée Claude (GO jalon partiel). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
19 KiB
D1 — NavigateCoords Implementation Plan
Auteur: Qwen
Date: 2026-07-02
Statut: EN ATTENTE GO Dom/Claude — Option 1 vs Option 2
Référence: docs/DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md (3 gaps documentés)
Résumé des gaps à résoudre
| Gap | Description | Fichier:Ligne | Preuve |
|---|---|---|---|
| A | Compiler bake floats littéraux — aucun template pour coords | replay_engine.py:1821-1833 |
x_pct = px (literal float) |
| B | Zéro consommateur de navigate_*_coords variables |
replay_engine.py + api_stream.py |
grep: 0 occurrences |
| C | _edge_to_normalized_actions pas de branche navigate → [] |
replay_engine.py:1951-1953 |
else: return [] |
Infrastructure existante (non-modifiée)
_ALLOWED_ACTION_TYPES (replay_engine.py:35-50)
"navigate" est déjà présent (ligne 44). La validation de sécurité l'accepte déjà.
_SERVER_SIDE_ACTION_TYPES (replay_engine.py:55-64)
"navigate" est déjà présent (ligne 59). Le dispatch loop le traite comme serveur-side.
_handle_navigate_action (core/navigation/init.py:24-113)
Handler déjà câblé dans api_stream.py (ligne 4459-4467). Résout screenshot, OCR/VLM, stocke coords dans replay_state["variables"].
_resolve_runtime_vars (replay_engine.py:2031-2045)
Resolver existant pour {{var.field}} — récursif sur dict/list/str. Retourne str(value) au niveau leaf → float→string conversion nécessaire pour coords.
OPTION 1 — Compiler Injection (~2h)
Principe
Ajouter une branche navigate dans _edge_to_normalized_actions + ajouter coords_var mechanism dans les branches mouse_click/text_input + runtime resolution + float conversion.
Patch P1-A : Branche navigate dans _edge_to_normalized_actions
Fichier: agent_v0/server_v1/replay_engine.py
Position: Après elif action_type == "llm_generate": (ligne 1949), avant else: (ligne 1951)
elif action_type == "navigate":
normalized["type"] = "navigate"
normalized["parameters"] = {
"login_field": action_params.get("login_field", "login"),
"password_field": action_params.get("password_field", "password"),
"submit_button": action_params.get("submit_button", "submit"),
"login_coords_var": action_params.get("login_coords_var", "navigate_login_coords"),
"password_coords_var": action_params.get("password_coords_var", "navigate_password_coords"),
"submit_coords_var": action_params.get("submit_coords_var", "navigate_submit_coords"),
}
return [normalized]
Justification: Action serveur-side — pas besoin de x_pct/y_pct ni target_spec. Le handler _handle_navigate_action lit parameters pour config, résout coords au runtime.
Impact: Gap C résolu. Navigate edge → 1 normalized action au lieu de [].
Patch P1-B : coords_var dans branches mouse_click / text_input
Fichier: agent_v0/server_v1/replay_engine.py
Position: Lignes 1844-1856 (branches click et type)
mouse_click (ligne 1844-1848) — AVANT :
if action_type == "mouse_click":
normalized["type"] = "click"
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
normalized["button"] = action_params.get("button", "left")
mouse_click — APRES :
if action_type == "mouse_click":
normalized["type"] = "click"
coords_var = action_params.get("coords_var")
if coords_var:
normalized["x_pct"] = f"{{{{{coords_var}.x_pct}}}}"
normalized["y_pct"] = f"{{{{{coords_var}.y_pct}}}}"
normalized["coords_var"] = coords_var
else:
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
normalized["button"] = action_params.get("button", "left")
text_input (ligne 1850-1856) — AVANT :
elif action_type == "text_input":
normalized["type"] = "type"
text = action_params.get("text", "")
text = _substitute_variables(text, params, action_params.get("defaults", {}))
normalized["text"] = text
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
text_input — APRES :
elif action_type == "text_input":
normalized["type"] = "type"
text = action_params.get("text", "")
text = _substitute_variables(text, params, action_params.get("defaults", {}))
normalized["text"] = text
coords_var = action_params.get("coords_var")
if coords_var:
normalized["x_pct"] = f"{{{{{coords_var}.y_pct}}}}"
normalized["y_pct"] = f"{{{{{coords_var}.y_pct}}}}"
normalized["coords_var"] = coords_var
else:
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
⚠️ BUG dans le draft ci-dessus: x_pct template pour text_input doit être {{coords_var.x_pct}} (pas .y_pct deux fois). Version corrigée :
elif action_type == "text_input":
normalized["type"] = "type"
text = action_params.get("text", "")
text = _substitute_variables(text, params, action_params.get("defaults", {}))
normalized["text"] = text
coords_var = action_params.get("coords_var")
if coords_var:
normalized["x_pct"] = f"{{{{{coords_var}.x_pct}}}}"
normalized["y_pct"] = f"{{{{{coords_var}.y_pct}}}}"
normalized["coords_var"] = coords_var
else:
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
Justification: coords_var = mécanisme minimal pour déclarer "ces coords viennent de la variable navigate_login_coords". Template strings résolus au runtime par _resolve_runtime_vars.
Impact: Gap A résolu. Gap B partiellement — les actions click/type deviennent consommatrices via coords_var.
Patch P1-C : Coercion helper après resolver existant
⚠️ CORRECTION IMPORTANT (2026-07-02 14:45) : Le plan original sur-dimensionnait P1-C en proposant un second resolver runtime. Codex a correctement identifié que _resolve_runtime_vars est déjà appelé dans la boucle dispatch à api_stream.py:4331-4335 :
# L4331-4335 (EXISTANT, ne pas modifier)
if owning_replay is not None:
runtime_vars = owning_replay.get("variables") or {}
if runtime_vars:
action = _resolve_runtime_vars(action, runtime_vars)
Besoin réel = coercion helper uniquement : _resolve_runtime_vars résout les templates {{var.field}} mais retourne str(value) au leaf → {{navigate_login_coords.x_pct}} devient "0.15" (string), pas 0.15 (float). Le client attend des floats pour x_pct/y_pct.
Fichier: agent_v0/server_v1/api_stream.py
Position: Juste après la ligne 4335 (action = _resolve_runtime_vars(action, runtime_vars))
Politique coords_var non résolu : Skip + pause supervisée (AGREED Qwen/Codex). Jamais fallback 0.0/0.0 — un clic sur coords (0,0) = top-left = potentiellement dangereux.
def _coerce_action_coords(action: dict) -> dict:
"""Cast x_pct/y_pct en float après template resolution par _resolve_runtime_vars.
Politique : si string non convertible ou template encore present → skip + pause_for_human.
Idempotent sur les actions qui ont déjà des floats (mouse_click existant).
Appelé APRÈS _resolve_runtime_vars dans la boucle dispatch (api_stream.py ~L4335).
"""
for key in ("x_pct", "y_pct"):
val = action.get(key)
if val is None:
continue
if isinstance(val, float):
continue # déjà float, idempotent
if isinstance(val, str):
# Template encore présent = non résolu par _resolve_runtime_vars
if val.startswith("{{") and val.endswith("}}"):
action["_skip_reason"] = f"coords_var non résolu: {key}={val}"
action["type"] = "pause_for_human"
action["safety_level"] = "high"
return action
try:
action[key] = float(val)
except (ValueError, TypeError):
action["_skip_reason"] = f"coords invalide: {key}={val}"
action["type"] = "pause_for_human"
action["safety_level"] = "high"
return action
return action
Appel dans la boucle dispatch (à insérer après L4335) :
# L4335 existant: action = _resolve_runtime_vars(action, runtime_vars)
# NOUVEAU — coercion coords après resolver existant
action = _coerce_action_coords(action)
Justification: _resolve_runtime_vars (existant à L4335) résout les templates → strings. _coerce_action_coords cast les strings en floats. Si template non résolu ou conversion impossible → pause_for_human (fail-safe), jamais fallback coords (0,0). Idempotent sur actions existantes (floats déjà présents).
Risques additionnels identifiés :
- Résolution partielle : si seul y_pct est résolu mais x_pct reste template →
_coerce_action_coordsconvertit pause_for_human (safe stop, pas top-left click). - Idempotence : si action existante a déjà x_pct=0.35 (float) → helper passe sans modification (isinstance(float) → continue).
- Race condition : variables dict partagé entre navigate handler et dispatch loop — mais BFS séquentiel garantit que navigate stocke AVANT click consomme.
Impact: Gap B résolu — les coords navigate sont consommées au runtime par click/type, avec coercion + fail-safe.
Patch P1-D : VWB YAML schema — coords_var field
Fichier: Schema VWB (workflow YAML format) — documentation
Nature: Ajout d'un champ coords_var dans action.parameters pour les steps mouse_click et text_input
Exemple de workflow YAML avec navigate + click consommateur :
steps:
- id: s1
action:
type: navigate
parameters:
login_coords_var: navigate_login_coords
password_coords_var: navigate_password_coords
to_node: n2
- id: s2
action:
type: mouse_click
parameters:
coords_var: navigate_login_coords
button: left
to_node: n3
- id: s3
action:
type: text_input
parameters:
coords_var: navigate_password_coords
text: "${password}"
to_node: n4
Justification: Le VWB builder doit savoir qu'un click peut référencer une variable coords au lieu de fournir des pixels littéraux. C'est un changement de schema minimal (1 champ optionnel).
OPTION 2 — Declarative YAML Templates (~4h)
Principe
Introduire un coords_template field dans les step definitions + un resolver typed qui extrait directement les floats du dict variables sans passage string→float.
Patch P2-A : Même branche navigate (identique à P1-A)
Inchangé — Gap C résolu par la même branche.
Patch P2-B : coords_template field + typed resolver
Fichier: agent_v0/server_v1/replay_engine.py
Nouvelle fonction _resolve_coords_template :
def _resolve_coords_template(
coords_template: str,
variables: Dict[str, Any],
) -> Optional[Dict[str, float]]:
"""Résoudre un coords_template en dict {x_pct, y_pct, bbox_pct} depuis variables.
Retourne None si la variable n'existe pas ou si les champs ne sont pas floats.
Pas de conversion string→float : les valeurs doivent déjà être des floats.
"""
coords_dict = variables.get(coords_template)
if not coords_dict or not isinstance(coords_dict, dict):
return None
x_pct = coords_dict.get("x_pct")
y_pct = coords_dict.get("y_pct")
if not isinstance(x_pct, (int, float)) or not isinstance(y_pct, (int, float)):
logger.warning(
f"coords_template {coords_template}: x_pct/y_pct not numeric "
f"(x_pct={x_pct}, y_pct={y_pct})"
)
return None
result = {"x_pct": float(x_pct), "y_pct": float(y_pct)}
bbox_pct = coords_dict.get("bbox_pct")
if bbox_pct:
result["bbox_pct"] = bbox_pct # tuple, pas de conversion
return result
Patch P2-C : Branches mouse_click / text_input avec coords_template
if action_type == "mouse_click":
normalized["type"] = "click"
coords_template = action_params.get("coords_template")
if coords_template:
normalized["coords_template"] = coords_template
# x_pct/y_pct résolus au runtime par _resolve_coords_template
normalized["x_pct"] = None # placeholder → resolved at runtime
normalized["y_pct"] = None
else:
normalized["x_pct"] = x_pct
normalized["y_pct"] = y_pct
normalized["button"] = action_params.get("button", "left")
Patch P2-D : Runtime resolution typed dans dispatch loop
# --- Résolution coords_template (typed, no string→float) ---
if action.get("coords_template"):
variables = owning_replay.replay_state.get("variables", {})
from agent_v0.server_v1.replay_engine import _resolve_coords_template
coords = _resolve_coords_template(action["coords_template"], variables)
if coords:
action["x_pct"] = coords["x_pct"]
action["y_pct"] = coords["y_pct"]
if coords.get("bbox_pct"):
action["bbox_pct"] = coords["bbox_pct"]
del action["coords_template"] # résolu, pas besoin de garder le ref
else:
logger.warning(
f"coords_template {action['coords_template']} unresolved — skipping action"
)
# skip → next action
Avantage Option 2: Pas de string→float conversion. Les coords restent des floats du navigate handler au click handler. Plus clean, plus safe.
Inconvénient Option 2: _resolve_coords_template est une nouvelle fonction + le x_pct = None placeholder nécessite que le client tolère les None temporairement (ou que la resolution se fasse AVANT transmission). Le schema VWB doit documenter coords_template comme champ alternatif à by_position.
Comparative Table — Patches
| Aspect | Option 1 (Compiler Injection) | Option 2 (YAML Templates) |
|---|---|---|
| Gap C fix | Identique (branche navigate) | Identique (branche navigate) |
| Gap A fix | Template strings {{var.field}} dans x_pct/y_pct |
x_pct = None placeholder + typed resolver |
| Gap B fix | _resolve_runtime_vars + float conversion |
_resolve_coords_template typed (no conversion) |
| String→float | Nécessaire (design smell) | Aucun (floats passent directement) |
| Nouvelles fonctions | 0 (reuse _resolve_runtime_vars) |
1 (_resolve_coords_template) |
| Schema VWB | 1 champ coords_var |
1 champ coords_template |
| Temps implémentation | ~2h | ~4h |
| Extensibilité | Limitée (coupling navigate→click) | Extensible (any coords source) |
| Risque POC | Minimal | Moyen (placeholder None + typed resolver) |
| Migration post-POC | Option 2 refactor needed | Already Option 2 |
Test Rouge Proposal
Test TR-1 : Prouve Gap C (navigate → [])
def test_edge_to_normalized_actionsnavigate_returns_empty():
"""Gap C: _edge_to_normalized_actions retourne [] pour navigate type."""
from agent_v0.server_v1.replay_engine import _edge_to_normalized_actions
edge = WorkflowEdge(
edge_id="e1",
from_node="n1",
to_node="n2",
action=ActionSpec(
type="navigate",
parameters={"login_coords_var": "navigate_login_coords"},
),
)
result = _edge_to_normalized_actions(edge, {})
# BEFORE fix: result == [] (Gap C)
# AFTER fix: result == [{"type": "navigate", "parameters": {...}}]
assert len(result) >= 1, "navigate must produce at least 1 normalized action"
assert result[0]["type"] == "navigate"
Test TR-2 : Prouve coords_var resolution (Option 1)
def test_coords_var_runtime_resolution():
"""Option 1: coords_var template resolved + float conversion."""
from agent_v0.server_v1.replay_engine import _resolve_runtime_vars
variables = {
"navigate_login_coords": {
"x_pct": 0.15,
"y_pct": 0.35,
"method": "ocr+vlm",
}
}
action = {
"type": "click",
"x_pct": "{{navigate_login_coords.x_pct}}",
"y_pct": "{{navigate_login_coords.y_pct}}",
"coords_var": "navigate_login_coords",
}
resolved = _resolve_runtime_vars(action, variables)
# resolved["x_pct"] == "0.15" (string) → needs float conversion
assert resolved["x_pct"] == "0.15" # string from resolver
assert float(resolved["x_pct"]) == 0.15 # conversion works
Test TR-3 : Prouve coords_template typed resolution (Option 2)
def test_coords_template_typed_resolution():
"""Option 2: coords_template returns floats directly, no conversion."""
from agent_v0.server_v1.replay_engine import _resolve_coords_template
variables = {
"navigate_login_coords": {
"x_pct": 0.15,
"y_pct": 0.35,
"method": "ocr+vlm",
}
}
coords = _resolve_coords_template("navigate_login_coords", variables)
assert coords is not None
assert isinstance(coords["x_pct"], float) # float, not string
assert coords["x_pct"] == 0.15
assert coords["y_pct"] == 0.35
BFS Ordonnancement — Risque scheduling
Le dispatch loop (api_stream.py:get_next_action) traite les actions séquentiellement par path BFS. Navigate est serveur-side → traité en boucle interne avant transmission. Click/type consommant coords_var/template sont visuels → transmis au client.
Flows correct:
- BFS traverse edge navigate → normalized action
type=navigate - Loop interne:
_handle_navigate_action→ stocke coords dans variables - BFS traverse edge click → normalized action avec
coords_var - Loop: resolution runtime → float conversion → transmission client
Risque: Si le BFS ordonnance le click AVANT le navigate (par ex. edges parallèles), coords_var sera unresolved → fallback 0.0/0.0.
Mitigation: VWB builder doit garantir que navigate edge précède click consommateur dans le path topologique. C'est une contrainte de schema, pas un bug runtime.
Decision Matrix
| Critère | Option 1 | Option 2 | Recommandation POC |
|---|---|---|---|
| Temps | 2h | 4h | Option 1 |
| Risque runtime | string→float edge | None placeholder | Option 1 (conversion simple) |
| Extensibilité | Limitée | Extensible | Option 1 pour POC, migration Option 2 post-POC |
| Code mort risk | 0 nouvelles fonctions | 1 nouvelle fonction | Option 1 |
| Test coverage | TR-1 + TR-2 | TR-1 + TR-3 | Option 1 |
Recommandation Qwen: Option 1 pour POC (2h, minimal risk, reuse infrastructure existante). Migration Option 2 post-POC si scaling multi-coords est confirmé (search, dossier).
GO requis: Dom + Claude (décision D1).
Qwen — plan implémentation D1 déposé, awaiting GO.