feat(browser): auto-commit con 44 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: render_ax_outline
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str"
|
||||
description: "Convierte nodos AX tree CDP en un outline indentado jerárquico y legible. Nodos accionables (button, link, textbox, etc.) llevan #ref=nodeId para que el LLM pueda referenciarlos en acciones. Poda nodos ignored y roles sin valor semántico."
|
||||
tags: [browser, cdp, ax-tree, perception, navegator, pure, llm]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: nodes
|
||||
desc: "Lista de AXNode en formato CDP (campos: nodeId, role, name, childIds, parentId, ignored). Devuelto por cdp_get_ax_tree. Pasar trim_ax_tree(nodes) antes para reducir ruido."
|
||||
- name: max_chars
|
||||
desc: "Si > 0, trunca la salida a ese número de caracteres y añade '…[outline truncado]'. 0 = sin límite (default)."
|
||||
output: "String multi-línea con el outline indentado. Nodos accionables llevan ' #ref=nodeId' alineado a columna 60. Vacío si nodes está vacío o todos los nodos son ignorados."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/core/render_ax_outline.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from core.render_ax_outline import render_ax_outline
|
||||
|
||||
# Nodos de muestra (formato real CDP simplificado)
|
||||
nodes = [
|
||||
{"nodeId": "1", "role": {"value": "RootWebArea"}, "name": {"value": "Gmail"},
|
||||
"childIds": ["2", "3"], "ignored": False},
|
||||
{"nodeId": "2", "role": {"value": "navigation"}, "name": {"value": ""},
|
||||
"childIds": ["4", "5"], "ignored": False},
|
||||
{"nodeId": "3", "role": {"value": "main"}, "name": {"value": ""},
|
||||
"childIds": ["6"], "ignored": False},
|
||||
{"nodeId": "4", "role": {"value": "button"}, "name": {"value": "Redactar"},
|
||||
"childIds": [], "ignored": False},
|
||||
{"nodeId": "5", "role": {"value": "link"}, "name": {"value": "Recibidos (3)"},
|
||||
"childIds": [], "ignored": False},
|
||||
{"nodeId": "6", "role": {"value": "textbox"}, "name": {"value": "Buscar correo"},
|
||||
"childIds": [], "ignored": False},
|
||||
]
|
||||
|
||||
outline = render_ax_outline(nodes)
|
||||
print(outline)
|
||||
# RootWebArea "Gmail"
|
||||
# navigation
|
||||
# button "Redactar" #ref=4
|
||||
# link "Recibidos (3)" #ref=5
|
||||
# main
|
||||
# textbox "Buscar correo" #ref=6
|
||||
|
||||
# Con límite de caracteres para contexto comprimido:
|
||||
outline_short = render_ax_outline(nodes, max_chars=100)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Después de obtener el AX tree con `cdp_get_ax_tree` (y opcionalmente podarlo con `trim_ax_tree`), cuando necesitas dar al LLM una vista compacta de la página para que decida qué elemento accionar. El outline con `#ref` permite al LLM responder "haz clic en #ref=4" sin ambigüedad. Úsala directamente o como parte del pipeline `cdp_perceive_outline_py_pipelines`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Esta función es pura: no llama a Chrome ni tiene I/O. Solo transforma la lista de nodos → string.
|
||||
- Pasar los nodos crudos de `cdp_get_ax_tree` funciona, pero el outline será más verboso. Usar `trim_ax_tree` antes reduce el ruido considerablemente.
|
||||
- Nodos con `ignored: true` se saltan silenciosamente (no aparecen en el outline).
|
||||
- Roles sin valor semántico (`none`, `presentation`) también se saltan; sus hijos se renderizan un nivel arriba.
|
||||
- Si `max_chars` corta a mitad de un nodo accionable importante, el LLM no verá su `#ref`. Para páginas grandes usar `cdp_perceive_outline` con `max_chars=20000` o chunking via `chunk_ax_tree`.
|
||||
@@ -0,0 +1,139 @@
|
||||
"""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).
|
||||
_ACTIONABLE_ROLES = frozenset({
|
||||
"button",
|
||||
"link",
|
||||
"textbox",
|
||||
"searchbox",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"combobox",
|
||||
"listbox",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"tab",
|
||||
"option",
|
||||
"switch",
|
||||
"slider",
|
||||
"spinbutton",
|
||||
"treeitem",
|
||||
"gridcell",
|
||||
})
|
||||
|
||||
# Roles sin valor semántico para el outline: se omiten si tampoco tienen hijos.
|
||||
_SKIP_ROLES = frozenset({"none", "presentation", "ignored"})
|
||||
|
||||
|
||||
def _role_val(node: dict) -> str:
|
||||
"""Extrae el valor de role del nodo CDP."""
|
||||
r = node.get("role", {})
|
||||
if isinstance(r, dict):
|
||||
return r.get("value", "")
|
||||
return str(r) if r else ""
|
||||
|
||||
|
||||
def _name_val(node: dict) -> str:
|
||||
"""Extrae el valor de name del nodo CDP."""
|
||||
n = node.get("name", {})
|
||||
if isinstance(n, dict):
|
||||
return n.get("value", "")
|
||||
return str(n) if n else ""
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
if not nodes:
|
||||
return ""
|
||||
|
||||
# Construir lookup por nodeId
|
||||
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
|
||||
all_child_ids: set[str] = set()
|
||||
for node in nodes:
|
||||
for cid in node.get("childIds", []):
|
||||
all_child_ids.add(cid)
|
||||
|
||||
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] = []
|
||||
|
||||
def _render_node(node: dict, depth: int) -> None:
|
||||
"""Renderiza un nodo y sus hijos recursivamente."""
|
||||
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
|
||||
for cid in node.get("childIds", []):
|
||||
child = by_id.get(cid)
|
||||
if child:
|
||||
_render_node(child, depth)
|
||||
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
|
||||
|
||||
lines.append(base)
|
||||
|
||||
# Renderizar hijos en orden
|
||||
for cid in node.get("childIds", []):
|
||||
child = by_id.get(cid)
|
||||
if child:
|
||||
_render_node(child, depth + 1)
|
||||
|
||||
for root in roots:
|
||||
_render_node(root, 0)
|
||||
|
||||
result = "\n".join(lines)
|
||||
|
||||
if max_chars > 0 and len(result) > max_chars:
|
||||
result = result[:max_chars].rstrip()
|
||||
result += "\n…[outline truncado]"
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: cdp_perceive_outline
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def cdp_perceive_outline(debug_port: int, tab_id: str, max_chars: int = 20000) -> str"
|
||||
description: "Pipeline de percepción: conecta a Chrome via CDP, obtiene el AX tree completo, lo poda y lo convierte en un outline indentado legible para LLMs. Cada nodo accionable lleva #ref=nodeId. Reemplaza enviar 1k-50k nodos JSON crudos al modelo."
|
||||
tags: [browser, cdp, ax-tree, perception, navegator, llm]
|
||||
uses_functions: [cdp_get_ax_tree_py_pipelines, trim_ax_tree_py_core, render_ax_outline_py_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [argparse, sys, os]
|
||||
params:
|
||||
- name: debug_port
|
||||
desc: "Puerto de debug remoto de Chrome (ej. 9333). Chrome debe estar corriendo con --remote-debugging-port=PORT."
|
||||
- name: tab_id
|
||||
desc: "ID del tab CDP, campo 'id' de GET http://127.0.0.1:{port}/json/list. Usar cdp_list_tabs_go_browser para listarlo."
|
||||
- name: max_chars
|
||||
desc: "Límite de caracteres del outline resultante. Default 20000 (~5k tokens). 0 = sin límite. Si la página es muy densa, reducir a 10000 para no saturar el context window."
|
||||
output: "String multi-línea con el outline indentado de la página. Nodos accionables tienen ' #ref=nodeId' alineado. El LLM puede responder 'haz clic en #ref=44' para operar la página."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/pipelines/cdp_perceive_outline.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Via fn run (patrón canónico para agentes)
|
||||
./fn run cdp_perceive_outline --debug-port 9333 --tab-id <id>
|
||||
|
||||
# Obtener tab_id primero:
|
||||
curl -s http://127.0.0.1:9333/json/list | python3 -m json.tool | grep '"id"'
|
||||
./fn run cdp_perceive_outline --debug-port 9333 --tab-id "A1B2C3D4..." --max-chars 15000
|
||||
```
|
||||
|
||||
```python
|
||||
# Uso desde Python (heredoc o pipeline propio)
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from pipelines.cdp_perceive_outline import cdp_perceive_outline
|
||||
|
||||
outline = cdp_perceive_outline(debug_port=9333, tab_id="A1B2C3D4...")
|
||||
print(outline)
|
||||
# RootWebArea "GitHub"
|
||||
# navigation "Site navigation"
|
||||
# link "Homepage" #ref=12
|
||||
# button "Search" #ref=18
|
||||
# main
|
||||
# heading "Repositories"
|
||||
# link "fn_registry" #ref=44
|
||||
# textbox "Filter repositories" #ref=51
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando un agente LLM necesita "ver" una página Chrome ya abierta para decidir qué elemento accionar a continuación. Sustituye enviar el AX tree crudo (1k-50k nodos JSON) al modelo por un outline compacto de ~200-500 líneas. El `#ref=nodeId` hace que el LLM pueda responder con una referencia exacta sin ambigüedad.
|
||||
|
||||
Flujo típico de un agente browser:
|
||||
1. `cdp_list_tabs` → obtener `tab_id`
|
||||
2. `cdp_perceive_outline` → outline compacto de la página
|
||||
3. LLM decide acción (clic en #ref=44, texto en #ref=51, etc.)
|
||||
4. `cdp_click_node` / `cdp_type_text` con el nodeId extraído del #ref
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Chrome debe estar corriendo con `--remote-debugging-port=<port>`. En Linux nativo: `chromium --remote-debugging-port=9333 &`. Con CDP global activado en `/etc/chromium.d/cdp`, el puerto 9222 siempre está disponible.
|
||||
- El tab no puede tener DevTools abierto (toma el debugger exclusivo). Cerrar DevTools antes de llamar.
|
||||
- `Accessibility.getFullAXTree` puede tardar 2-10s en páginas muy pesadas (SPAs tipo Gmail con miles de nodos). El timeout total es 15s.
|
||||
- El outline resultante puede superar `max_chars` en ~100 chars si el último nodo visible es muy largo. Usar margen holgado (ej. 18000 en vez de 20000 si el context window es ajustado).
|
||||
- Si la página no tiene contenido accesible (ej. canvas puro, PDF embebido), el outline estará vacío o solo tendrá el RootWebArea. En ese caso usar CDP JS evaluation directamente.
|
||||
- `tab_id` es el campo `"id"` del JSON de `/json/list`, no `"targetId"`. Son diferentes.
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Pipeline: obtiene el AX tree de un tab Chrome y lo convierte en outline legible."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from pipelines.cdp_get_ax_tree import cdp_get_ax_tree
|
||||
from core.trim_ax_tree import trim_ax_tree
|
||||
from core.render_ax_outline import render_ax_outline
|
||||
|
||||
|
||||
def cdp_perceive_outline(
|
||||
debug_port: int,
|
||||
tab_id: str,
|
||||
max_chars: int = 20000,
|
||||
) -> str:
|
||||
"""Obtiene el AX tree de un tab Chrome y devuelve un outline indentado legible.
|
||||
|
||||
Compone tres pasos:
|
||||
1. cdp_get_ax_tree — obtiene nodos crudos via CDP WebSocket.
|
||||
2. trim_ax_tree — poda nodos irrelevantes (ignored, generic sin hijos, etc.).
|
||||
3. render_ax_outline — convierte en outline indentado con #ref para accionables.
|
||||
|
||||
Args:
|
||||
debug_port: Puerto de debug remoto de Chrome (ej. 9333).
|
||||
Chrome debe estar corriendo con --remote-debugging-port=PORT.
|
||||
tab_id: ID del tab CDP. Obtenerlo via GET http://127.0.0.1:{port}/json/list
|
||||
o con cdp_list_tabs_go_browser.
|
||||
max_chars: Límite de caracteres del outline resultante. 0 = sin límite.
|
||||
Default 20000 (~5k tokens), apropiado para context window de Claude.
|
||||
|
||||
Returns:
|
||||
String con el outline indentado. Cada nodo accionable tiene #ref=nodeId
|
||||
para que el LLM pueda referenciarlo en acciones posteriores.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Si Chrome no responde, el tab no existe, o falla la conexión WS.
|
||||
TimeoutError: Si Accessibility.getFullAXTree no responde en 15s.
|
||||
"""
|
||||
nodes = cdp_get_ax_tree(debug_port=debug_port, tab_id=tab_id)
|
||||
trimmed = trim_ax_tree(nodes)
|
||||
return render_ax_outline(trimmed, max_chars=max_chars)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Obtiene el outline del AX tree de un tab Chrome via CDP."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug-port",
|
||||
type=int,
|
||||
default=9222,
|
||||
help="Puerto de debug remoto de Chrome (default: 9222).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tab-id",
|
||||
required=True,
|
||||
help="ID del tab CDP (campo 'id' de GET /json/list).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-chars",
|
||||
type=int,
|
||||
default=20000,
|
||||
help="Límite de caracteres del outline. 0 = sin límite (default: 20000).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
outline = cdp_perceive_outline(
|
||||
debug_port=args.debug_port,
|
||||
tab_id=args.tab_id,
|
||||
max_chars=args.max_chars,
|
||||
)
|
||||
print(outline)
|
||||
except (RuntimeError, TimeoutError) as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user