# 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) ```python 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 : ```python 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 : ```python 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 : ```python 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 : ```python 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 : ```python 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` : ```python # 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. ```python 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) : ```python # 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** : 1. **Résolution partielle** : si seul y_pct est résolu mais x_pct reste template → `_coerce_action_coords` convertit pause_for_human (safe stop, pas top-left click). 2. **Idempotence** : si action existante a déjà x_pct=0.35 (float) → helper passe sans modification (isinstance(float) → continue). 3. **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 : ```yaml 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` : ```python 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 ```python 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 ```python # --- 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 → []) ```python 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) ```python 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) ```python 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**: 1. BFS traverse edge navigate → normalized action `type=navigate` 2. Loop interne: `_handle_navigate_action` → stocke coords dans variables 3. BFS traverse edge click → normalized action avec `coords_var` 4. 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.*