2 Commits

Author SHA1 Message Date
egutierrez 029dbf57bd feat(core): auto-commit con 10 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 13:20:36 +02:00
egutierrez 3f6b652f3f chore(agents): subir los 6 agentes fn de sonnet a opus
Los agentes del ciclo reactivo (constructor, executor, recopilador,
analizador, mejorador, orquestador) corrian con model: sonnet. Se suben
todos a model: opus para mejorar la calidad del codigo generado y del
razonamiento durante el ciclo CONSTRUIR -> EJECUTAR -> RECOPILAR ->
ANALIZAR -> MEJORAR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:17:46 +02:00
16 changed files with 414 additions and 61 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-analizador
description: "Agente analizador (Fase 4) del ciclo reactivo. Lee `e2e_checks` declarados en app.md, ejecuta la suite via `e2e_run_checks_go_infra`, evalua assertions activas, calcula drift de metricas vs historico, persiste resultado en `e2e_runs` de operations.db y devuelve veredicto caveman pass/fail. NO modifica codigo ni propone fixes — eso es trabajo de fn-mejorador (Fase 5)."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-constructor
description: "Agente constructor (Fase 1) del ciclo reactivo. Construye funciones, tests y tipos en Go, Python, TypeScript y Bash para fn_registry."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-executor
description: "Agente ejecutor (Fase 2) del ciclo reactivo. Prepara apps, ejecuta pipelines/funciones Go y Python, y registra ejecuciones en operations.db."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-mejorador
description: "Agente mejorador (Fase 5) del ciclo reactivo. Lee resultados fallidos de fn-analizador desde `e2e_runs`/`assertion_results`, busca contexto en el registry, y crea proposals con evidencia trazable. NO modifica codigo: solo abre proposals para que un humano (o el bucle autonomo del issue 0069) decida."
model: sonnet
model: opus
tools: Read, Bash, Grep, Glob
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-orquestador
description: "Meta-orquestador (Fase 6) del ciclo reactivo. Toma un issue o task_spec y recorre CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR despachando a fn-constructor/executor/recopilador/analizador/mejorador hasta convergencia, estancamiento, timeout o tope de iteraciones. Trabaja SIEMPRE en rama sandbox `auto/<issue>`, NUNCA mergea a master, persiste progreso en `task_runs`. Issue 0069."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-recopilador
description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta. Modo extra `design-e2e <app_id>`: propone bloque `e2e_checks` para que la fase 4 (fn-analizador) pueda validar la app sin iteracion humana."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+4 -26
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"math/rand"
"strings"
"time"
)
// CdpClickHuman hace click en el elemento identificado por selector CSS con
@@ -53,31 +52,10 @@ func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error {
toX := bx + bw/2 + offX
toY := by + bh/2 + offY
// Mover el ratón con trayectoria humana
if err := CdpMoveMouseHuman(c, toX, toY, opts); err != nil {
return fmt.Errorf("cdp click human: mover raton: %w", err)
}
// mousePressed
clickParams := map[string]any{
"type": "mousePressed",
"x": toX,
"y": toY,
"button": "left",
"clickCount": 1,
}
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click human: mousePressed: %w", err)
}
// Micro-pausa humana entre press y release (3090 ms)
pauseMs := 30 + rand.Intn(61)
time.Sleep(time.Duration(pauseMs) * time.Millisecond)
// mouseReleased
clickParams["type"] = "mouseReleased"
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click human: mouseReleased: %w", err)
// Delegar en el primitivo compartido: mueve el ratón con trayectoria humana
// y despacha press/release con micro-pausa.
if err := CdpClickXYHuman(c, toX, toY, opts); err != nil {
return fmt.Errorf("cdp click human: %w", err)
}
return nil
+40
View File
@@ -0,0 +1,40 @@
package browser
import "fmt"
// refBoxCenter resuelve el centro (x,y) en coords de página de un nodo DOM por su
// backendDOMNodeId, vía DOM.getBoxModel. El content quad son 8 floats (4 esquinas).
func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) {
res, err := c.sendCDP("DOM.getBoxModel", map[string]any{"backendNodeId": backendNodeID})
if err != nil {
return 0, 0, fmt.Errorf("getBoxModel ref %d: %w", backendNodeID, err)
}
model, ok := res["model"].(map[string]any)
if !ok {
return 0, 0, fmt.Errorf("ref %d: sin boxModel (nodo no visible o inexistente)", backendNodeID)
}
content, ok := model["content"].([]any)
if !ok || len(content) < 8 {
return 0, 0, fmt.Errorf("ref %d: content quad invalido", backendNodeID)
}
num := func(i int) float64 { f, _ := content[i].(float64); return f }
cx := (num(0) + num(2) + num(4) + num(6)) / 4
cy := (num(1) + num(3) + num(5) + num(7)) / 4
return cx, cy, nil
}
// CdpClickRef hace click humanizado (Bézier + jitter) sobre el elemento del #ref.
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
// Hace scroll al elemento si es necesario antes de calcular las coordenadas.
func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp click ref: conexión nil")
}
// scroll al elemento si no está visible; ignorar error (no fatal)
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
cx, cy, err := refBoxCenter(c, backendNodeID)
if err != nil {
return fmt.Errorf("cdp click ref: %w", err)
}
return CdpClickXYHuman(c, cx, cy, opts)
}
+51
View File
@@ -0,0 +1,51 @@
---
name: cdp_click_ref
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
description: "Click humanizado (Bézier + jitter) sobre el elemento identificado por su #ref del AX outline. El #ref es el backendDOMNodeId estable del nodo DOM. Hace scroll al elemento si no está en viewport antes de calcular las coordenadas vía DOM.getBoxModel."
tags: [cdp, browser, action, ref, humanized, navegator]
uses_functions: [cdp_click_xy_human_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: c
desc: "Conexión CDP activa al tab objetivo."
- name: backendNodeID
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
- name: opts
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
output: "nil si el click se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el click CDP falla."
file_path: "functions/browser/cdp_click_ref.go"
---
## Ejemplo
```go
// Tras un page_perceive que devuelve outline con #ref=1234:
conn, _ := CdpConnect(9222)
err := CdpClickRef(conn, 1234, MouseHumanOpts{})
if err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Tras `page_perceive` / `render_ax_outline`, cuando el agente tiene el `#ref` de un elemento del outline y quiere hacer click sobre él sin necesitar un selector CSS — cierra el bucle percibir→actuar. Preferir sobre `CdpClickHuman` cuando el nodo viene del AX outline (más estable que un selector).
## Gotchas
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado (display:none, fuera del shadow DOM accesible, o ya eliminado). El error describe la causa.
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal) — si el elemento no es scrollable al viewport el click puede caer en coordenadas incorrectas.
- El click va por `CdpClickXYHuman` (Bézier): no despaches `Input.dispatchMouseEvent` crudo en código que use esta función.
+49
View File
@@ -0,0 +1,49 @@
package browser
import (
"fmt"
"math/rand"
"time"
)
// CdpClickXYHuman hace click en las coordenadas absolutas (x, y) de la página con
// comportamiento humano: mueve el ratón hasta el punto por una trayectoria de
// Bézier cúbica (CdpMoveMouseHuman) y despacha mousePressed/mouseReleased con una
// micro-pausa variable (30-90 ms) entre ambos.
//
// Es el PRIMITIVO de click compartido por las tres vías de acción del agente:
// - por selector CSS → CdpClickHuman (obtiene el bbox y llama aquí).
// - por #ref del AX tree → CdpClickRef (resuelve backendDOMNodeId → bbox → aquí).
// - por visión → click sobre el bounding box que devuelve OCR/YOLO.
// Construir un único primitivo evita tener tres caminos de click divergentes.
func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp click xy human: conexion nula")
}
// Mover el ratón hasta el destino con trayectoria humana.
if err := CdpMoveMouseHuman(c, x, y, opts); err != nil {
return fmt.Errorf("cdp click xy human: mover raton: %w", err)
}
clickParams := map[string]any{
"type": "mousePressed",
"x": x,
"y": y,
"button": "left",
"clickCount": 1,
}
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click xy human: mousePressed: %w", err)
}
// Micro-pausa humana entre press y release (30-90 ms).
time.Sleep(time.Duration(30+rand.Intn(61)) * time.Millisecond)
clickParams["type"] = "mouseReleased"
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click xy human: mouseReleased: %w", err)
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
---
id: cdp_click_xy_human_go_browser
name: cdp_click_xy_human
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Click humanizado en coordenadas absolutas (x,y): mueve el ratón con trayectoria Bézier y despacha mousePressed/mouseReleased con micro-pausa variable. Primitivo de click compartido por las tres vías de acción del agente: por selector, por #ref del AX tree y por visión (bounding box de OCR/YOLO)."
tags: [cdp, browser, action, humanized, click, navegator]
signature: "func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error"
uses_functions:
- cdp_move_mouse_human_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_click_xy_human.go"
example: |
conn, _ := browser.CdpConnect(9333)
defer browser.CdpClose(conn, 0)
// Click humanizado en el centro de un elemento detectado por visión (bbox):
browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{})
params:
- name: c
desc: "Conexión CDP activa (de CdpConnect)."
- name: x
desc: "Coordenada X absoluta en la página, en px CSS del viewport."
- name: y
desc: "Coordenada Y absoluta en la página, en px CSS del viewport."
- name: opts
desc: "Opciones de la trayectoria humana (zero-value = defaults). Origen del movimiento via FromX/FromY."
output: "error si el movimiento del ratón o el despacho de eventos falla; nil en éxito."
---
## Ejemplo
```go
conn, _ := browser.CdpConnect(9333)
defer browser.CdpClose(conn, 0)
// El centro del bounding box lo da el #ref del AX tree (DOM.getBoxModel) o la
// detección de visión (OCR/YOLO). Aquí, click humanizado sobre ese punto:
if err := browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{}); err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Cuando ya tienes las coordenadas de píxel del objetivo: el centro del bounding box de un elemento
(resuelto por `#ref` del AX outline vía `DOM.getBoxModel`, o detectado por visión OCR/YOLO). Es el
único primitivo de click del agente — no despaches `Input.dispatchMouseEvent` a mano.
## Gotchas
- Coordenadas en el sistema de la página (px CSS del viewport), no de pantalla física.
- La humanización añade latencia (movimiento Bézier + micro-pausa). Para scraping masivo de alto
volumen, el llamador debe usar un preset rápido de `MouseHumanOpts` (política de sesión `fast`),
no humanización completa por acción.
- El destino debe estar dentro del viewport visible; haz scroll al elemento antes si hace falta.
+19
View File
@@ -0,0 +1,19 @@
package browser
import "fmt"
// CdpHoverRef mueve el ratón con trayectoria humanizada (Bézier) sobre el
// elemento del #ref. Útil para activar menús y tooltips que reaccionan a hover.
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp hover ref: conexión nil")
}
// scroll al elemento si no está visible; ignorar error (no fatal)
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
cx, cy, err := refBoxCenter(c, backendNodeID)
if err != nil {
return fmt.Errorf("cdp hover ref: %w", err)
}
return CdpMoveMouseHuman(c, cx, cy, opts)
}
+53
View File
@@ -0,0 +1,53 @@
---
name: cdp_hover_ref
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
description: "Mueve el ratón con trayectoria humanizada (Bézier) sobre el elemento identificado por su #ref del AX outline. Útil para activar menús desplegables, tooltips y cualquier interacción que dependa de hover. El #ref es el backendDOMNodeId estable del nodo DOM."
tags: [cdp, browser, action, ref, humanized, navegator]
uses_functions: [cdp_move_mouse_human_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: c
desc: "Conexión CDP activa al tab objetivo."
- name: backendNodeID
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
- name: opts
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
output: "nil si el movimiento de ratón se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el movimiento CDP falla."
file_path: "functions/browser/cdp_hover_ref.go"
---
## Ejemplo
```go
// Activar un menú desplegable cuyo trigger tiene #ref=9999:
conn, _ := CdpConnect(9222)
err := CdpHoverRef(conn, 9999, MouseHumanOpts{})
if err != nil {
log.Fatal(err)
}
// esperar a que el menú aparezca y re-percibir el outline
```
## Cuando usarla
Tras `page_perceive` / `render_ax_outline`, cuando el agente necesita hacer hover sobre un elemento del `#ref` para revelar contenido oculto (menús, submenús, tooltips, dropdowns) — cierra el bucle percibir→actuar para interacciones hover. Seguir con otro `page_perceive` tras el hover para capturar el nuevo estado del DOM.
## Gotchas
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado. El error describe la causa.
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal).
- Solo mueve el ratón — no hace click. Para activar elementos que requieren click usar `CdpClickRef`.
- Algunos menús hover requieren un pequeño `time.Sleep` o `CdpWaitIdle` tras el hover para que el DOM se actualice antes del siguiente `page_perceive`.
+16
View File
@@ -0,0 +1,16 @@
package browser
import "fmt"
// CdpTypeRef enfoca el elemento del #ref vía CDP y escribe el texto dado.
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
// Equivale a: focus(ref) → CdpTypeText. El elemento debe aceptar input de texto.
func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error {
if c == nil {
return fmt.Errorf("cdp type ref: conexión nil")
}
if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil {
return fmt.Errorf("cdp type ref: focus ref %d: %w", backendNodeID, err)
}
return CdpTypeText(c, text)
}
+51
View File
@@ -0,0 +1,51 @@
---
name: cdp_type_ref
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error"
description: "Enfoca el elemento identificado por su #ref del AX outline vía DOM.focus y escribe el texto dado usando CdpTypeText. El #ref es el backendDOMNodeId estable del nodo DOM. El elemento debe aceptar input de texto (input, textarea, contenteditable)."
tags: [cdp, browser, action, ref, humanized, navegator]
uses_functions: [cdp_type_text_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: c
desc: "Conexión CDP activa al tab objetivo."
- name: backendNodeID
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
- name: text
desc: "Texto a escribir en el elemento enfocado. Se envía carácter a carácter simulando escritura humana."
output: "nil si el focus y la escritura se completaron; error si la conexión es nil, DOM.focus falla, o CdpTypeText falla."
file_path: "functions/browser/cdp_type_ref.go"
---
## Ejemplo
```go
// Tras un page_perceive que devuelve un input con #ref=5678:
conn, _ := CdpConnect(9222)
err := CdpTypeRef(conn, 5678, "hola mundo")
if err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Tras `page_perceive` / `render_ax_outline`, cuando el agente quiere escribir en un campo de texto identificado por su `#ref` — cierra el bucle percibir→actuar para inputs. Preferir sobre secuencias manuales `DOM.focus` + `CdpTypeText` cuando el nodo viene directamente del AX outline.
## Gotchas
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
- `DOM.focus` falla si el elemento no es focusable (no es `input`, `textarea`, `contenteditable`, o similar). El error indica el ref y la causa.
- Si el elemento necesita un click previo para activarse (algunos inputs con JS custom), combinar con `CdpClickRef` antes de `CdpTypeRef`.
- No hace scroll previo — si el elemento no está visible en el viewport el focus CDP puede fallar en algunos navegadores. Combinar con `CdpClickRef` (que sí hace scroll) si hay dudas.
+63 -29
View File
@@ -1,7 +1,7 @@
"""Convierte una lista de AXNode CDP en un outline indentado legible por LLMs."""
# Roles que se consideran accionables (el LLM puede referirlos con #ref=nodeId).
# Roles que se consideran accionables (el LLM puede referirlos con #ref).
_ACTIONABLE_ROLES = frozenset({
"button",
"link",
@@ -23,9 +23,12 @@ _ACTIONABLE_ROLES = frozenset({
"gridcell",
})
# Roles sin valor semántico para el outline: se omiten si tampoco tienen hijos.
# Roles sin valor semántico para el outline: se omiten (sus hijos se elevan).
_SKIP_ROLES = frozenset({"none", "presentation", "ignored"})
# Límite de profundidad: evita RecursionError en árboles AX patológicos.
_MAX_DEPTH = 60
def _role_val(node: dict) -> str:
"""Extrae el valor de role del nodo CDP."""
@@ -43,37 +46,63 @@ def _name_val(node: dict) -> str:
return str(n) if n else ""
def _value_val(node: dict) -> str:
"""Estado actual del nodo: texto escrito en un input, valor de un slider o
combobox, etc. El LLM necesita saber qué hay ya en el campo."""
v = node.get("value", {})
if isinstance(v, dict):
val = v.get("value", "")
return str(val) if val not in (None, "") else ""
return str(v) if v else ""
def _ref_id(node: dict) -> str:
"""Ref ESTABLE para acciones por referencia.
Usa backendDOMNodeId (apunta al nodo DOM real, estable mientras el nodo viva)
en lugar del nodeId del AX tree, que es efímero y cambia en cada
Accessibility.getFullAXTree. Esto hace que el #ref que lee el LLM siga siendo
válido cuando actúa sobre él un instante después. Fallback al nodeId si el
backend no viene poblado.
"""
bid = node.get("backendDOMNodeId")
if bid is not None:
return str(bid)
return str(node.get("nodeId", ""))
def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
"""Convierte nodos AX tree CDP en un outline indentado legible y accionable.
Reconstruye la jerarquía padre→hijo usando childIds/parentId y genera
una representación de texto con indentación de 2 espacios por nivel.
Los nodos accionables llevan un marcador #ref=nodeId para que el LLM
pueda referenciarlos en acciones posteriores.
Reconstruye la jerarquía padre→hijo usando childIds/parentId y genera una
representación de texto con indentación de 2 espacios por nivel. Los nodos
accionables llevan un marcador #ref=<backendDOMNodeId> para que el LLM pueda
referenciarlos en acciones posteriores (click_ref/type_ref/hover_ref). El
estado actual de inputs (texto escrito, valor) se muestra como `= 'valor'`.
Args:
nodes: Lista de AXNode en formato CDP (campos: nodeId, role, name,
childIds, parentId, ignored). Formato devuelto por
cdp_get_ax_tree. Se puede pasar trim_ax_tree(nodes) antes
para reducir ruido.
max_chars: Si > 0, trunca la salida a ese número de caracteres y
añade una línea final '…[outline truncado]'. 0 = sin límite.
nodes: Lista de AXNode en formato CDP (campos: nodeId, backendDOMNodeId,
role, name, value, childIds, parentId, ignored). Formato devuelto
por cdp_get_ax_tree. Se puede pasar trim_ax_tree(nodes) antes para
reducir ruido (conserva backendDOMNodeId y value.value).
max_chars: Si > 0, trunca la salida a ese número de caracteres y añade una
línea final '…[outline truncado]'. 0 = sin límite.
Returns:
String multi-línea con el outline indentado. Vacío si nodes es vacío
o todos los nodos son ignorados/sin role útil.
String multi-línea con el outline indentado. Vacío si nodes es vacío o
todos los nodos son ignorados/sin role útil.
"""
if not nodes:
return ""
# Construir lookup por nodeId
# Lookup por nodeId (la jerarquía childIds usa nodeId, no backendDOMNodeId).
by_id: dict[str, dict] = {}
for node in nodes:
nid = node.get("nodeId")
if nid:
by_id[nid] = node
# Detectar nodos raíz: nodeId no aparece como childId de nadie visible
# Detectar nodos raíz: nodeId que no aparece como childId de nadie visible.
all_child_ids: set[str] = set()
for node in nodes:
for cid in node.get("childIds", []):
@@ -81,19 +110,25 @@ def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
roots = [n for n in nodes if n.get("nodeId") not in all_child_ids]
if not roots:
# Fallback: usar el primer nodo
roots = [nodes[0]]
lines: list[str] = []
visited: set[str] = set() # guard de ciclo: un nodeId no se renderiza dos veces
def _render_node(node: dict, depth: int) -> None:
"""Renderiza un nodo y sus hijos recursivamente."""
nid = node.get("nodeId")
if depth > _MAX_DEPTH or (nid and nid in visited):
return
if nid:
visited.add(nid)
if node.get("ignored", False):
return
role = _role_val(node)
if not role or role in _SKIP_ROLES:
# Nodos sin role útil: intentar renderizar hijos directamente
# Nodos sin role útil: elevar los hijos al nivel actual.
for cid in node.get("childIds", []):
child = by_id.get(cid)
if child:
@@ -101,27 +136,26 @@ def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
return
name = _name_val(node)
node_id = node.get("nodeId", "")
indent = " " * depth
# Construir línea base: role ["name"]
if name:
base = f'{indent}{role} "{name}"'
else:
base = f"{indent}{role}"
# Añadir #ref si es accionable
if role in _ACTIONABLE_ROLES and node_id:
# Alinear #ref a columna 60 para legibilidad, o adjunto si la línea es larga
ref_tag = f"#ref={node_id}"
if len(base) < 58:
base = base.ljust(60) + ref_tag
else:
base = base + " " + ref_tag
# Estado actual del campo (texto escrito, valor del slider/combobox).
value = _value_val(node)
if value:
base += f" = {value!r}"
# Ref accionable, sin padding (el relleno con espacios gastaba tokens).
if role in _ACTIONABLE_ROLES:
ref = _ref_id(node)
if ref:
base += f" #ref={ref}"
lines.append(base)
# Renderizar hijos en orden
for cid in node.get("childIds", []):
child = by_id.get(cid)
if child: