Brave como respaldo de DuckDuckGo, y arreglo de la guardia de bf16

Cadena de búsqueda con respaldo. DuckDuckGo sigue de primario porque no exige
credenciales: en el camino habitual ningún tercero se entera de qué busca la
familia. Pero su endpoint lite no es una API con contrato, así que cuando
limite por tasa o cambie el HTML, Brave responde.

Dos matices del encadenado:

- Cero resultados NO dispara el respaldo. Si el primario respondió bien y no
  encontró nada, esa es la respuesta correcta; encadenar gastaría cuota y
  devolvería resultados peores. Solo se avanza ante un error real.
- La cadena recuerda quién respondió: al depurar una respuesta rara, lo
  primero que hay que saber es de dónde salió.

Un respaldo sin credencial falla al arrancar y no en la primera consulta —
que es justo cuando el primario ya falló y el respaldo tiene que funcionar.

Aparte, verificando torch en la 2060 apareció un defecto real en backends.py:
torch.cuda.is_bf16_supported() usa including_emulation=True por defecto, así
que devuelve True en Turing, donde bf16 no existe en silicio. La guardia no
habría atrapado el perfil de la 5090 corriendo en la 2060: el entrenamiento
seguía, emulado y silencioso. Medido en la placa: 4,49 ms por matmul en bf16
contra 1,75 ms en fp16, unas 2,6x más lento. Ahora se consulta con
including_emulation=False, con respaldo por compute capability.

11 tests nuevos (113 en total).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 00:39:13 -03:00
parent 5b9f2a58bc
commit 36040d7cc8
8 changed files with 433 additions and 43 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"ROS2.distro": "jazzy"
}
+19 -7
View File
@@ -1,17 +1,28 @@
# Herramientas del agente. # Herramientas del agente.
# #
# La búsqueda web usa DuckDuckGo, que no exige API key ni cuenta en ningún # La búsqueda web usa DuckDuckGo como primario: no exige API key ni cuenta, así
# servicio: no hay credenciales que gestionar ni un tercero al que se le informe # que no hay credenciales que gestionar ni un tercero al que se le informe qué
# qué busca la familia. # busca la familia.
# #
# Aclaración sobre el endpoint: la API oficial de DuckDuckGo ("Instant Answer") # Aclaración sobre el endpoint: la API oficial de DuckDuckGo ("Instant Answer")
# solo devuelve definiciones y fichas de entidades, no resultados web. Los # solo devuelve definiciones y fichas de entidades, no resultados web. Los
# resultados reales salen del endpoint lite, que no es una API con contrato: el # resultados reales salen del endpoint lite, que no es una API con contrato: el
# HTML puede cambiar y hay límite de tasa. Por eso existe el backend `searxng`, # HTML puede cambiar y hay límite de tasa. Por eso existe la cadena de respaldo.
# autoalojado y estable, como reemplazo de una línea cuando haga falta.
search: search:
backend: duckduckgo backend: duckduckgo
region: es-es
# Se prueban en orden, y solo ante un error real del anterior. Cero
# resultados no dispara el respaldo: si el primario respondió bien y no
# encontró nada, esa es la respuesta correcta.
#
# Brave sí es una API con contrato y límites claros, pero cada consulta pasa
# por un tercero asociado a una cuenta — por eso va de respaldo y no de
# primario. SearxNG autoalojado es el destino natural cuando el volumen lo
# justifique: agrega DuckDuckGo entre sus fuentes sin exponer las consultas.
fallbacks: [brave]
region: es-es # DuckDuckGo
country: uy # Brave
language: es language: es
# Presupuesto de contexto. Con seq_len 2048 hay que repartir entre resultados # Presupuesto de contexto. Con seq_len 2048 hay que repartir entre resultados
@@ -28,5 +39,6 @@ search:
# idénticas en pocos minutos. # idénticas en pocos minutos.
cache_ttl: 300.0 cache_ttl: 300.0
# Solo se usa con backend=searxng. Sale de .env y queda vacío si no está. # Credenciales, desde .env. Quedan vacías si la variable no está definida.
brave_api_key: ${oc.env:ENLACE_BRAVE_API_KEY,""}
searxng_url: ${oc.env:ENLACE_SEARXNG_URL,""} searxng_url: ${oc.env:ENLACE_SEARXNG_URL,""}
+134 -11
View File
@@ -147,12 +147,72 @@ class DuckDuckGoBackend:
raise SearchError(f"DuckDuckGo no respondió tras {self.retries + 1} intentos: {ultimo}") raise SearchError(f"DuckDuckGo no respondió tras {self.retries + 1} intentos: {ultimo}")
class BraveBackend:
"""Brave Search API. Una API con contrato, a diferencia del endpoint lite.
Existe como red de seguridad del primario: cuando DuckDuckGo limite por tasa
o cambie el HTML, esto responde. La contrapartida es que cada consulta pasa
por un tercero asociado a una cuenta, así que no se usa como primario salvo
que se elija explícitamente.
"""
name = "brave"
ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
def __init__(
self, api_key: str, timeout: float = 10.0, country: str = "uy", language: str = "es"
) -> None:
self.api_key = api_key
self.timeout = timeout
self.country = country
self.language = language
def search(self, query: str, max_results: int) -> list[SearchResult]:
url = self.ENDPOINT + "?" + urllib.parse.urlencode(
{
"q": query,
"count": max_results,
"country": self.country,
"search_lang": self.language,
}
)
request = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"X-Subscription-Token": self.api_key,
"User-Agent": _USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as respuesta:
payload = json.loads(respuesta.read())
except urllib.error.HTTPError as exc:
# 429 es el caso interesante: si el fallback también está limitado,
# el mensaje tiene que decirlo en vez de parecer un fallo genérico.
detalle = "límite de tasa alcanzado" if exc.code == 429 else f"HTTP {exc.code}"
raise SearchError(f"Brave rechazó la consulta: {detalle}") from None
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
raise SearchError(f"Brave no respondió: {exc}") from None
resultados = payload.get("web", {}).get("results", [])
return [
SearchResult(
title=_clean(item.get("title", "")),
url=item.get("url", ""),
snippet=_clean(item.get("description", "")),
)
for item in resultados[:max_results]
if item.get("url", "").startswith("http")
]
class SearxNGBackend: class SearxNGBackend:
"""SearxNG autoalojado: la opción estable y sin depender de terceros. """SearxNG autoalojado: la opción estable y sin depender de terceros.
Es el destino natural cuando el endpoint lite empiece a fallar o cuando el Es el destino natural cuando el volumen de consultas de la casa lo
volumen de consultas de la casa lo justifique. SearxNG puede agregar justifique. SearxNG puede agregar DuckDuckGo entre sus fuentes, así que el
DuckDuckGo entre sus fuentes, así que el resultado es equivalente. resultado es equivalente sin exponer las consultas a un tercero.
""" """
name = "searxng" name = "searxng"
@@ -183,6 +243,51 @@ class SearxNGBackend:
] ]
class CadenaDeBackends:
"""Prueba los backends en orden y devuelve el primero que responda.
La razón de existir: el endpoint lite de DuckDuckGo no es una API con
contrato — puede limitar por tasa o cambiar el HTML sin aviso. Con esto un
fallo deja de ser una respuesta vacía para la familia y pasa a ser un
reintento contra otro proveedor.
Dos matices que importan:
- **Cero resultados no es un fallo.** Si el primario responde bien y no
encontró nada, esa es la respuesta correcta; encadenar al siguiente
proveedor por eso gastaría cuota y devolvería resultados peores.
Solo se avanza ante un error real.
- **Se recuerda quién respondió** (`ultimo_backend`), porque al depurar
una respuesta rara lo primero que hay que saber es de dónde salió.
"""
def __init__(self, backends: list[SearchBackend]) -> None:
if not backends:
raise SearchError("la cadena de búsqueda no tiene ningún backend")
self.backends = backends
self.ultimo_backend: str | None = None
@property
def name(self) -> str:
return " -> ".join(getattr(b, "name", type(b).__name__) for b in self.backends)
def search(self, query: str, max_results: int) -> list[SearchResult]:
fallos: list[str] = []
for backend in self.backends:
nombre = getattr(backend, "name", type(backend).__name__)
try:
resultados = backend.search(query, max_results)
except SearchError as exc:
fallos.append(f"{nombre}: {exc}")
continue
self.ultimo_backend = nombre
return resultados
raise SearchError(
"ningún backend de búsqueda respondió — " + " | ".join(fallos)
)
class WebSearch: class WebSearch:
"""Tool de búsqueda: backend + presupuesto de contexto + caché. """Tool de búsqueda: backend + presupuesto de contexto + caché.
@@ -234,15 +339,23 @@ class WebSearch:
) )
def build_backend(config) -> SearchBackend: def _construir_uno(nombre: str, config) -> SearchBackend:
"""Construye el backend declarado en la config del agente.""" if nombre == "duckduckgo":
if config.backend == "duckduckgo":
return DuckDuckGoBackend( return DuckDuckGoBackend(
region=config.region, region=config.region, timeout=config.timeout, retries=config.retries
timeout=config.timeout,
retries=config.retries,
) )
if config.backend == "searxng": if nombre == "brave":
if not config.brave_api_key:
raise SearchError(
"backend brave sin brave_api_key: definí ENLACE_BRAVE_API_KEY en .env"
)
return BraveBackend(
api_key=config.brave_api_key,
timeout=config.timeout,
country=config.country,
language=config.language,
)
if nombre == "searxng":
if not config.searxng_url: if not config.searxng_url:
raise SearchError( raise SearchError(
"backend searxng sin searxng_url: definí ENLACE_SEARXNG_URL en .env" "backend searxng sin searxng_url: definí ENLACE_SEARXNG_URL en .env"
@@ -250,7 +363,17 @@ def build_backend(config) -> SearchBackend:
return SearxNGBackend( return SearxNGBackend(
base_url=config.searxng_url, timeout=config.timeout, language=config.language base_url=config.searxng_url, timeout=config.timeout, language=config.language
) )
raise SearchError(f"backend de búsqueda desconocido: {config.backend}") raise SearchError(f"backend de búsqueda desconocido: {nombre}")
def build_backend(config) -> SearchBackend:
"""Construye la cadena declarada en la config: primario + respaldos.
Con un solo backend devuelve ese backend pelado, para no envolver en una
cadena algo que no la necesita.
"""
cadena = [_construir_uno(nombre, config) for nombre in config.cadena]
return cadena[0] if len(cadena) == 1 else CadenaDeBackends(cadena)
def build_search(config) -> WebSearch: def build_search(config) -> WebSearch:
+7
View File
@@ -15,6 +15,8 @@ from typing import Any
from omegaconf import DictConfig, OmegaConf from omegaconf import DictConfig, OmegaConf
from enlace.config.env import load_dotenv
from enlace.config.schema import AgentConfig, Config, DistillConfig from enlace.config.schema import AgentConfig, Config, DistillConfig
# Familias de capas que puede declarar un YAML raíz, en el orden en que se # Familias de capas que puede declarar un YAML raíz, en el orden en que se
@@ -114,6 +116,11 @@ def _load_single(path: Path, overrides: list[str] | None, model_cls, etiqueta: s
if not path.is_file(): if not path.is_file():
raise ConfigError(f"no existe el archivo de config: {path}") raise ConfigError(f"no existe el archivo de config: {path}")
# `${oc.env:...}` lee os.environ al resolver, así que .env tiene que estar
# cargado antes: si no, una credencial bien escrita en el archivo se
# resuelve a vacío y el backend parece mal configurado.
load_dotenv()
merged = _load_yaml(path) merged = _load_yaml(path)
if overrides: if overrides:
merged = OmegaConf.merge(merged, OmegaConf.from_dotlist(list(overrides))) merged = OmegaConf.merge(merged, OmegaConf.from_dotlist(list(overrides)))
+38 -9
View File
@@ -202,15 +202,23 @@ class TrainConfig(_Base):
class SearchConfig(_Base): class SearchConfig(_Base):
"""Búsqueda web. Sin API key: DuckDuckGo no exige credenciales. """Búsqueda web, con cadena de respaldo.
`searxng` existe como alternativa autoalojada para cuando el endpoint de El primario es DuckDuckGo porque no exige credenciales: nada sale a un
DuckDuckGo falle o el volumen de consultas lo justifique; cambiar de backend tercero salvo la consulta misma. Pero su endpoint lite no es una API con
es cambiar este campo. contrato — puede limitar por tasa o cambiar el HTML sin aviso — así que
`fallbacks` define a quién preguntarle cuando eso pase.
El orden importa y es explícito: se prueba `backend` y después cada entrada
de `fallbacks`, en orden, hasta que alguno responda.
""" """
backend: Literal["duckduckgo", "searxng"] = "duckduckgo" backend: Literal["duckduckgo", "brave", "searxng"] = "duckduckgo"
fallbacks: list[Literal["duckduckgo", "brave", "searxng"]] = Field(
default_factory=list
)
region: str = "es-es" region: str = "es-es"
country: str = "uy"
language: str = "es" language: str = "es"
max_results: int = Field(default=5, gt=0, le=20) max_results: int = Field(default=5, gt=0, le=20)
# Presupuesto de contexto: con seq_len 2048 hay que elegir entre resultados # Presupuesto de contexto: con seq_len 2048 hay que elegir entre resultados
@@ -221,13 +229,34 @@ class SearchConfig(_Base):
retries: int = Field(default=2, ge=0) retries: int = Field(default=2, ge=0)
cache_ttl: float = Field(default=300.0, ge=0) cache_ttl: float = Field(default=300.0, ge=0)
searxng_url: str | None = None searxng_url: str | None = None
brave_api_key: str | None = None
@property
def cadena(self) -> list[str]:
"""El primario seguido de los respaldos, sin repetidos."""
orden = [self.backend, *self.fallbacks]
vistos: list[str] = []
for nombre in orden:
if nombre not in vistos:
vistos.append(nombre)
return vistos
@model_validator(mode="after") @model_validator(mode="after")
def _check_backend(self) -> SearchConfig: def _check_backends(self) -> SearchConfig:
if self.backend == "searxng" and not self.searxng_url: # Un backend sin credencial se descubriría recién en la primera consulta
# real, que es justo cuando el primario ya falló y el respaldo tiene que
# funcionar. Se valida al arrancar.
requisitos = {
"searxng": ("searxng_url", "ENLACE_SEARXNG_URL"),
"brave": ("brave_api_key", "ENLACE_BRAVE_API_KEY"),
}
for nombre in self.cadena:
requisito = requisitos.get(nombre)
if requisito and not getattr(self, requisito[0]):
rol = "backend" if nombre == self.backend else "fallback"
raise ValueError( raise ValueError(
"search.backend='searxng' exige searxng_url " f"search: el {rol} '{nombre}' exige {requisito[0]} "
"(definí ENLACE_SEARXNG_URL en .env)." f"(definí {requisito[1]} en .env)."
) )
return self return self
+23 -4
View File
@@ -27,6 +27,25 @@ def resolve_dtype(name: str) -> torch.dtype:
return _DTYPES[name] return _DTYPES[name]
def _bf16_nativo() -> bool:
"""¿La GPU tiene unidades bfloat16 de verdad, o PyTorch lo emula?
Trampa verificada en hardware: `torch.cuda.is_bf16_supported()` usa
`including_emulation=True` por defecto, así que devuelve True en Turing —
donde bf16 no existe en silicio y se emula. Medido en una RTX 2060, esa
emulación es ~2,6x más lenta que float16 (4,49 vs 1,75 ms por matmul de
2048x2048). Confiar en el default deja pasar el perfil equivocado y el
entrenamiento corre a un tercio de velocidad sin una sola advertencia.
bfloat16 nativo llega con Ampere (sm_80).
"""
try:
return torch.cuda.is_bf16_supported(including_emulation=False)
except TypeError:
# Versiones de torch anteriores al parámetro: se decide por capability.
return torch.cuda.get_device_capability(0) >= (8, 0)
def describe_device(cfg: HardwareConfig) -> str: def describe_device(cfg: HardwareConfig) -> str:
if cfg.device == "cpu": if cfg.device == "cpu":
return "cpu" return "cpu"
@@ -64,12 +83,12 @@ def setup_device(cfg: HardwareConfig) -> torch.device:
f"sm_{cap[0]}{cap[1]}." f"sm_{cap[0]}{cap[1]}."
) )
# Turing (sm_75) no tiene unidades bfloat16: PyTorch lo emula y el if cfg.dtype == "bfloat16" and not _bf16_nativo():
# entrenamiento se vuelve inservible en vez de fallar. Mejor fallar.
if cfg.dtype == "bfloat16" and not torch.cuda.is_bf16_supported():
raise HardwareError( raise HardwareError(
f"el perfil '{cfg.name}' pide bfloat16, que esta GPU (sm_{cap[0]}{cap[1]}) " f"el perfil '{cfg.name}' pide bfloat16, que esta GPU (sm_{cap[0]}{cap[1]}) "
"no soporta de forma nativa. Usá float16 con use_grad_scaler=true." "no soporta de forma nativa: PyTorch lo emula. Medido en una RTX 2060, "
"la emulación es ~2,6x más lenta que float16. "
"Usá float16 con use_grad_scaler=true."
) )
# FlashAttention-2 requiere Ampere o superior. # FlashAttention-2 requiere Ampere o superior.
+87 -8
View File
@@ -5,27 +5,97 @@
# servidor y no se descargan enteros. Solo vuelven artefactos chicos: métricas, # servidor y no se descargan enteros. Solo vuelven artefactos chicos: métricas,
# muestras generadas y logs. # muestras generadas y logs.
# #
# Dos direcciones para la misma máquina: la de LAN se prueba primero porque es
# el caso habitual y es mucho más rápida para rsync; si no responde, se cae a la
# pública. Forzar una con ENLACE_REMOTE_FORCE=lan|wan.
#
# scripts/remote.sh check # conectividad, GPU y entorno
# scripts/remote.sh sync # empuja el código al servidor # scripts/remote.sh sync # empuja el código al servidor
# scripts/remote.sh run <config.yaml> [args] # entrena bajo tmux # scripts/remote.sh run <config.yaml> [args] # entrena bajo tmux
# scripts/remote.sh logs [corrida] # sigue el log en vivo # scripts/remote.sh logs [corrida] # sigue el log en vivo
# scripts/remote.sh pull <corrida> # trae métricas y muestras # scripts/remote.sh pull <corrida> # trae métricas y muestras
# scripts/remote.sh gpu # estado de la GPU # scripts/remote.sh gpu # estado de la GPU
# scripts/remote.sh ssh [comando] # shell o comando suelto
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
[[ -f .env ]] && set -a && source .env && set +a [[ -f .env ]] && set -a && source .env && set +a
HOST="${ENLACE_REMOTE_HOST:?definí ENLACE_REMOTE_HOST en .env (ver .env.example)}" DIR="${ENLACE_REMOTE_DIR:?definí ENLACE_REMOTE_DIR en .env (ver .env.example)}"
DIR="${ENLACE_REMOTE_DIR:?definí ENLACE_REMOTE_DIR en .env}"
PY="${ENLACE_REMOTE_PYTHON:-$DIR/.venv/bin/python}" PY="${ENLACE_REMOTE_PYTHON:-$DIR/.venv/bin/python}"
# Timeout corto: si no estamos en la LAN, no tiene sentido esperar el TCP.
LAN_TIMEOUT="${ENLACE_LAN_TIMEOUT:-3}"
SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new)
_alcanzable() {
ssh "${SSH_OPTS[@]}" -o "ConnectTimeout=$1" "$2" true 2>/dev/null
}
elegir_host() {
local lan="${ENLACE_REMOTE_LAN_HOST:-}" wan="${ENLACE_REMOTE_HOST:-}"
case "${ENLACE_REMOTE_FORCE:-}" in
lan) echo "${lan:?ENLACE_REMOTE_FORCE=lan pero ENLACE_REMOTE_LAN_HOST no está definido}"; return ;;
wan) echo "${wan:?ENLACE_REMOTE_FORCE=wan pero ENLACE_REMOTE_HOST no está definido}"; return ;;
esac
if [[ -n "$lan" ]] && _alcanzable "$LAN_TIMEOUT" "$lan"; then
echo "$lan"
return
fi
if [[ -n "$wan" ]]; then
echo "$wan"
return
fi
echo "definí ENLACE_REMOTE_LAN_HOST o ENLACE_REMOTE_HOST en .env" >&2
exit 1
}
HOST="$(elegir_host)"
# A stderr para no contaminar la salida de los comandos que se parsean.
if [[ "$HOST" == "${ENLACE_REMOTE_LAN_HOST:-}" ]]; then
echo "[enlace] servidor: $HOST (LAN)" >&2
else
echo "[enlace] servidor: $HOST (remoto)" >&2
fi
comando="${1:-}" comando="${1:-}"
shift || true shift || true
case "$comando" in case "$comando" in
check)
# Todo lo que tiene que estar bien antes de comprometer horas de cómputo o
# gigabytes de corpus. Falla ruidoso y temprano.
ssh "${SSH_OPTS[@]}" "$HOST" bash -s -- "$DIR" "$PY" <<'REMOTO'
set -u
DIR="$1"; PY="$2"
echo "host: $(hostname)"
echo "repo: $DIR $( [ -d "$DIR/.git" ] && echo '(ok)' || echo '(FALTA: cloná el repo)')"
echo "python: $( [ -x "$PY" ] && "$PY" --version 2>&1 || echo 'FALTA el venv')"
echo "tmux: $(command -v tmux >/dev/null && tmux -V || echo 'FALTA (las corridas largas lo necesitan)')"
if command -v nvidia-smi >/dev/null; then
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader | sed 's/^/gpu: /'
else
echo "gpu: nvidia-smi no está en PATH"
fi
[ -x "$PY" ] && "$PY" - <<'PY'
try:
import torch
except ImportError:
print("torch: no instalado (corré `remote.sh sync`)"); raise SystemExit
print(f"torch: {torch.__version__} | cuda disponible: {torch.cuda.is_available()}")
if torch.cuda.is_available():
cap = torch.cuda.get_device_capability(0)
print(f" sm_{cap[0]}{cap[1]} | bf16: {torch.cuda.is_bf16_supported()}")
print(f" perfil sugerido: {'blackwell-5090' if cap >= (12,0) else 'turing-2060'}")
PY
REMOTO
;;
sync) sync)
git push git push
ssh "$HOST" "cd '$DIR' && git pull --ff-only && $PY -m pip install -q -e ." ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && git pull --ff-only && $PY -m pip install -q -e ."
;; ;;
run) run)
@@ -35,7 +105,8 @@ run)
# distintas no se pisan y `logs` sabe a cuál conectarse. # distintas no se pisan y `logs` sabe a cuál conectarse.
sesion="enlace-$(basename "$config" .yaml)" sesion="enlace-$(basename "$config" .yaml)"
# tmux es lo que hace que cortar el SSH no mate el entrenamiento. # tmux es lo que hace que cortar el SSH no mate el entrenamiento.
ssh -t "$HOST" "cd '$DIR' && tmux new-session -d -s '$sesion' \ ssh "${SSH_OPTS[@]}" -t "$HOST" "cd '$DIR' && mkdir -p runs && \
tmux new-session -d -s '$sesion' \
\"$PY -m enlace.train.train '$config' $* 2>&1 | tee -a 'runs/$sesion.log'\" \ \"$PY -m enlace.train.train '$config' $* 2>&1 | tee -a 'runs/$sesion.log'\" \
&& echo 'corriendo en tmux: $sesion'" && echo 'corriendo en tmux: $sesion'"
;; ;;
@@ -43,9 +114,9 @@ run)
logs) logs)
sesion="${1:-}" sesion="${1:-}"
if [[ -z "$sesion" ]]; then if [[ -z "$sesion" ]]; then
ssh "$HOST" "cd '$DIR' && ls -t runs/*.log | head -1 | xargs tail -f" ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && ls -t runs/*.log | head -1 | xargs tail -f"
else else
ssh "$HOST" "cd '$DIR' && tail -f 'runs/$sesion.log'" ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && tail -f 'runs/$sesion.log'"
fi fi
;; ;;
@@ -58,11 +129,19 @@ pull)
;; ;;
gpu) gpu)
ssh "$HOST" "nvidia-smi" ssh "${SSH_OPTS[@]}" "$HOST" "nvidia-smi"
;;
ssh)
if [[ $# -eq 0 ]]; then
ssh "${SSH_OPTS[@]}" -t "$HOST" "cd '$DIR' && exec \$SHELL -l"
else
ssh "${SSH_OPTS[@]}" "$HOST" "cd '$DIR' && $*"
fi
;; ;;
*) *)
sed -n '2,14p' "$0" sed -n '2,20p' "$0"
exit 2 exit 2
;; ;;
esac esac
+120 -2
View File
@@ -12,6 +12,8 @@ from pathlib import Path
import pytest import pytest
from enlace.agent.tools.search import ( from enlace.agent.tools.search import (
BraveBackend,
CadenaDeBackends,
DuckDuckGoBackend, DuckDuckGoBackend,
SearchError, SearchError,
SearchResult, SearchResult,
@@ -176,12 +178,18 @@ def test_consulta_vacia_da_error_util():
# --- config --------------------------------------------------------------- # --- config ---------------------------------------------------------------
def test_la_config_por_defecto_no_necesita_credenciales(): def test_el_primario_no_necesita_credenciales():
"""Lo que importa del default: la primera consulta sale sin credenciales.
La cadena puede tener respaldos que sí las exijan, pero el camino habitual
no le informa a ningún tercero qué busca la familia.
"""
cfg = load_agent_config() cfg = load_agent_config()
assert cfg.search.backend == "duckduckgo" assert cfg.search.backend == "duckduckgo"
assert cfg.search.region == "es-es" assert cfg.search.region == "es-es"
backend = build_backend(cfg.search) backend = build_backend(cfg.search)
assert isinstance(backend, DuckDuckGoBackend) primero = backend.backends[0] if isinstance(backend, CadenaDeBackends) else backend
assert isinstance(primero, DuckDuckGoBackend)
def test_searxng_exige_url(): def test_searxng_exige_url():
@@ -204,3 +212,113 @@ def test_el_presupuesto_de_contexto_tiene_un_tope_razonable():
def test_config_de_agente_inexistente_da_error_util(tmp_path): def test_config_de_agente_inexistente_da_error_util(tmp_path):
with pytest.raises(ConfigError, match="no existe"): with pytest.raises(ConfigError, match="no existe"):
load_agent_config(tmp_path / "no-existe.yaml") load_agent_config(tmp_path / "no-existe.yaml")
# --- cadena de respaldo ----------------------------------------------------
class BackendQueFalla:
name = "roto"
def __init__(self, mensaje: str = "caído") -> None:
self.mensaje = mensaje
self.llamadas = 0
def search(self, query: str, max_results: int) -> list[SearchResult]:
self.llamadas += 1
raise SearchError(self.mensaje)
def test_la_cadena_usa_el_primario_cuando_anda():
primario = BackendFalso([SearchResult("T", "https://e.com", "c")])
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
cadena = CadenaDeBackends([primario, respaldo])
assert cadena.search("q", 5)[0].title == "T"
assert respaldo.llamadas == 0 # no se toca la cuota del respaldo
assert cadena.ultimo_backend == "falso"
def test_la_cadena_cae_al_respaldo_ante_un_error():
roto = BackendQueFalla("límite de tasa alcanzado")
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
cadena = CadenaDeBackends([roto, respaldo])
assert cadena.search("q", 5)[0].title == "R"
assert roto.llamadas == 1
def test_cero_resultados_NO_dispara_el_respaldo():
"""Si el primario respondió bien y no encontró nada, esa es la respuesta.
Encadenar al siguiente proveedor por esto gastaría cuota y devolvería
resultados peores para una consulta que genuinamente no tiene respuesta.
"""
vacio = BackendFalso([])
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
cadena = CadenaDeBackends([vacio, respaldo])
assert cadena.search("consulta sin resultados", 5) == []
assert respaldo.llamadas == 0
def test_si_fallan_todos_el_error_dice_por_que_cada_uno():
cadena = CadenaDeBackends([BackendQueFalla("429"), BackendQueFalla("timeout")])
with pytest.raises(SearchError) as exc:
cadena.search("q", 5)
assert "429" in str(exc.value) and "timeout" in str(exc.value)
def test_la_cadena_recuerda_quien_respondio():
"""Al depurar una respuesta rara, lo primero es saber de dónde salió."""
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
respaldo.name = "respaldo"
cadena = CadenaDeBackends([BackendQueFalla(), respaldo])
cadena.search("q", 5)
assert cadena.ultimo_backend == "respaldo"
def test_una_cadena_vacia_es_un_error():
with pytest.raises(SearchError, match="ningún backend"):
CadenaDeBackends([])
# --- construcción desde config ---------------------------------------------
def test_un_solo_backend_no_se_envuelve_en_cadena():
cfg = SearchConfig(backend="duckduckgo")
assert isinstance(build_backend(cfg), DuckDuckGoBackend)
def test_config_con_fallback_arma_la_cadena():
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"], brave_api_key="x")
backend = build_backend(cfg)
assert isinstance(backend, CadenaDeBackends)
assert [type(b).__name__ for b in backend.backends] == [
"DuckDuckGoBackend",
"BraveBackend",
]
def test_el_fallback_repetido_no_se_duplica():
cfg = SearchConfig(backend="duckduckgo", fallbacks=["duckduckgo", "brave"], brave_api_key="x")
assert cfg.cadena == ["duckduckgo", "brave"]
def test_un_fallback_sin_credencial_falla_al_arrancar():
"""No en la primera consulta real — que es justo cuando el primario ya
falló y el respaldo tiene que funcionar."""
with pytest.raises(ValueError, match="fallback 'brave'"):
SearchConfig(backend="duckduckgo", fallbacks=["brave"])
def test_brave_como_primario_tambien_exige_credencial():
with pytest.raises(ValueError, match="backend 'brave'"):
SearchConfig(backend="brave")
def test_la_config_del_repo_declara_brave_de_respaldo():
cfg = load_agent_config()
assert cfg.search.backend == "duckduckgo"
assert "brave" in cfg.search.fallbacks