JARVIS/verde/arrancar.sh
sito 1e3e5ca20d carril verde (Hermes Agent): documentacion, panel, voz local y RAG
Rama JARVIS-GREEN, independiente de master (el nucleo naranja queda intacto).

- README propio del carril verde con diagrama de arquitectura y capturas del panel.
- verde/: arranque, panel web, puente de voz (escucha local con faster-whisper +
  habla con la voz del naranja), pruebas (07 voz, 08 escucha), config y notas.
- Integracion RAG: nucleo/saber/busca_cli.py + skill buscar-en-apuntes, para que
  el agente consulte el mismo indice que el nucleo.
- Todo local (127.0.0.1); sin datos personales (rutas y modelo de GPU scrubeados).
2026-08-18 11:26:42 +02:00

304 lines
14 KiB
Bash
Executable file

#!/usr/bin/env bash
# Deja el carril verde listo para usar: mide, configura y dictamina.
#
# ./arrancar.sh medir, configurar y dictaminar
# ./arrancar.sh --solo-medir medir sin tocar la configuracion
# ./arrancar.sh --igual no plantarse aunque dataset.py siga vivo
#
# ── Por que existe ─────────────────────────────────────────────────────────
#
# El primer arranque del verde topo con algo que no se anticipaba: el prompt
# fijo de Hermes son ~20.500 tokens (26 KB de sistema + 56 KB de esquemas de 19
# herramientas) y qwen3.5:4b-jarvis va con num_ctx 4096. Cinco veces por encima.
# No es que rinda mal: no entra la pregunta.
#
# Para eso existe qwen3.5:4b-verde. Pero cuanto contexto cabe en una GPU de
# 4 GB no se puede calcular sobre el papel —ollama no reporta head_count_kv—,
# hay que medirlo. Este script lo mide, escribe el resultado donde toca y
# contesta la unica pregunta que de verdad decide si esto va a funcionar.
#
# ── El suelo de 64k, que manda sobre todo lo anterior (16 ago) ──────────────
#
# La primera version de este script buscaba el mayor num_ctx que cupiera en la
# tarjeta y encontro 24576, con 32 capas en GPU y 3301 MiB. Todo correcto y
# todo inutil: al abrir Hermes salta
#
# Model qwen3.5:4b-verde has a context window of 24,576 tokens, which is
# below the minimum 64,000 required by Hermes Agent.
#
# Es un tope DURO del arnes, no un aviso: agent/model_metadata.py:405,
# MINIMUM_CONTEXT_LENGTH = 64_000, comprobado en agent/agent_init.py:2656.
#
# Hay una puerta de atras en ese mismo if —si provider es "lmstudio" y
# context_length esta puesto a mano, se salta el suelo— y NO se usa: el
# proveedor lmstudio llama a /api/v1/models, que es la API nativa de LM Studio
# y ollama no tiene, ademas de intentar precargar el modelo por su cuenta.
# Fingir ser otro servidor romperia mas adelante y de forma mas confusa.
#
# Tampoco se miente en el config.yaml poniendo 64000 con el modelo a 24576: el
# arnes creeria tener 64k, no comprimiria hasta acercarse, y ollama iria tirando
# los tokens mas viejos —que son el prompt de sistema y los esquemas de las
# herramientas—. El sintoma seria que el agente deja de llamar herramientas a
# mitad de sesion, sin un solo error en el log.
#
# Asi que el numero a buscar ya no es el contexto: es CUANTAS CAPAS caben en la
# tarjeta con num_ctx 65536 fijo. Medido el 16 ago:
#
# num_gpu 32 -> cudaMalloc failed num_gpu 16 -> 2795 MiB, CARGA
# num_gpu 24 -> cudaMalloc failed num_gpu 8 -> 1772 MiB, CARGA
# num_gpu 0 -> 0 MiB, todo en RAM
#
# ── Se planta en vez de seguir ─────────────────────────────────────────────
#
# Cada paso comprueba lo suyo y aborta si no cuadra. Es a proposito: un fallo
# aqui es de una linea, y el mismo fallo tres pasos mas adelante sale como
# "respuestas raras" y cuesta una tarde.
set -uo pipefail
AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
RAIZ=$(cd -P "$AQUI/.." && pwd)
CONFIG=$HOME/.hermes/config.yaml
OLLAMA=${OLLAMA_URL:-http://127.0.0.1:11434}
MODELO=qwen3.5:4b-verde
NARANJA=qwen3.5:4b-jarvis
# El suelo duro de Hermes es 64.000 (agent/model_metadata.py:405). Se pide la
# potencia de dos justo por encima, que es lo que entiende ollama sin discutir.
CTX_MINIMO=65536
SOLO_MEDIR=no
IGUAL=no
for a in "$@"; do
case "$a" in
--solo-medir) SOLO_MEDIR=si ;;
--igual) IGUAL=si ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
esac
done
rojo() { printf '\033[31m%s\033[0m\n' "$*"; }
verde() { printf '\033[32m%s\033[0m\n' "$*"; }
gris() { printf '\033[90m%s\033[0m\n' "$*"; }
titulo(){ printf '\n\033[1m── %s ─────────────────────────────────────\033[0m\n' "$*"; }
abortar(){ rojo "$*"; exit 1; }
vram() { nvidia-smi --query-gpu=memory.used --format=csv,noheader 2>/dev/null | tr -d ' MiB'; }
# ═══ 1. La tarjeta ═════════════════════════════════════════════════════════
titulo "1. Liberar la tarjeta"
if pgrep -f 'entrena/dataset.py' >/dev/null 2>&1; then
if [ "$IGUAL" = no ]; then
rojo " ✗ dataset.py sigue corriendo."
gris " Es un trabajo de 7-10 h que usa $NARANJA en la grafica."
gris " Competir con el por la VRAM estropea las dos cosas: el verde"
gris " no cargara y el dataset ira mas lento."
gris ""
gris " Por donde va:"
[ -f "$RAIZ/entrena/dataset.log" ] && sed 's/^/ /' "$RAIZ/entrena/dataset.log" | tail -3
gris ""
gris " Espera a que acabe, o --igual para seguir de todas formas."
exit 1
fi
rojo " ! dataset.py corriendo y --igual puesto: siguiendo bajo tu responsabilidad"
fi
# Restos del naranja. Se para el proceso; NO se toca ni un fichero suyo.
for p in 'nucleo/jarvis.py' 'whisper-server'; do
if pgrep -f "$p" >/dev/null 2>&1; then
gris " cerrando $p"
pkill -f "$p" 2>/dev/null
fi
done
ollama stop "$NARANJA" >/dev/null 2>&1
sleep 3
LIBRE=$(vram)
if [ -z "$LIBRE" ]; then
abortar "no hay nvidia-smi: esto necesita la grafica"
elif [ "$LIBRE" -gt 400 ]; then
rojo " ! la tarjeta sigue con $LIBRE MiB usados"
nvidia-smi --query-compute-apps=pid,used_memory,name --format=csv,noheader | sed 's/^/ /'
gris " Algo la esta usando. Mira la lista de arriba antes de seguir."
[ "$IGUAL" = no ] && exit 1
else
verde " ✓ tarjeta libre: $LIBRE MiB"
fi
# ═══ 2. Cuantas capas caben con el contexto que Hermes exige ═══════════════
titulo "2. Cuantas capas caben a 65536 (medido, no supuesto)"
gris " El contexto NO se negocia: Hermes rechaza cualquier modelo por debajo de"
gris " 64.000 tokens. Lo que se busca es el mayor num_gpu que cargue a 65536:"
gris " cada capa que baja a la CPU cuesta velocidad, asi que se cogen todas las"
gris " que quepan."
TMP=$(mktemp --suffix=.Modelfile); trap 'rm -f "$TMP"' EXIT
GANADOR=""; GANADOR_VRAM=""
probar_ctx() { # $1 = num_ctx $2 = num_gpu
printf 'FROM qwen3.5:4b\nPARAMETER num_gpu %s\nPARAMETER num_ctx %s\nPARAMETER num_predict 4096\nPARAMETER temperature 0.2\n' "$2" "$1" > "$TMP"
ollama create "$MODELO" -f "$TMP" >/dev/null 2>&1 || return 1
local r
r=$(curl -s --max-time 240 "$OLLAMA/v1/chat/completions" -H 'Content-Type: application/json' \
-d "{\"model\":\"$MODELO\",\"messages\":[{\"role\":\"user\",\"content\":\"ok\"}],\"max_tokens\":5}" 2>/dev/null)
echo "$r" | grep -q '"choices"' || return 1
return 0
}
for GPU in 32 24 16 8 0; do
printf ' num_ctx %-6s num_gpu %-3s ... ' "$CTX_MINIMO" "$GPU"
if probar_ctx "$CTX_MINIMO" "$GPU"; then
V=$(curl -fsS --max-time 5 "$OLLAMA/api/ps" 2>/dev/null | python3 -c "
import json,sys
m=json.load(sys.stdin)['models']
print(m[0]['size_vram']//1048576 if m else 0)" 2>/dev/null)
verde "CABE (en tarjeta $V MiB, tarjeta $(vram) MiB)"
GANADOR=$CTX_MINIMO; GANADOR_GPU=$GPU; GANADOR_VRAM=$V
break
fi
rojo "no cabe"
ollama stop "$MODELO" >/dev/null 2>&1; sleep 2
done
if [ -z "$GANADOR" ]; then
rojo " ✗ no carga a 65536 ni con TODAS las capas en la CPU."
gris " Eso ya no es la grafica: mira la RAM libre con free -h."
exit 1
fi
verde " ✓ num_ctx $GANADOR con $GANADOR_GPU de 32 capas en GPU, $GANADOR_VRAM MiB"
if [ "$GANADOR_GPU" -lt 32 ]; then
gris ""
gris " $(( 32 - GANADOR_GPU )) capas van por CPU y eso se paga en el prefill: el prompt fijo"
gris " son ~20.500 tokens que hay que procesar en CADA vuelta del agente."
gris ""
gris " Palanca para recuperar capas, y NO la aplico yo porque es del SERVICIO"
gris " de ollama, no del modelo: afectaria tambien al naranja la proxima vez"
gris " que arranque. Esa decision es tuya."
gris ""
gris " sudo systemctl edit ollama"
gris " [Service]"
gris " Environment=\"OLLAMA_FLASH_ATTENTION=1\""
gris " Environment=\"OLLAMA_KV_CACHE_TYPE=q8_0\""
gris " sudo systemctl restart ollama"
gris ""
gris " Parte el KV cache por la mitad. Si lo haces, anotalo en NOTAS.md y en"
gris " config/qwen3.5-4b-jarvis.Modelfile, o en tres semanas nadie sabra de"
gris " donde salio el cambio."
fi
HUECO=$(( GANADOR - 20500 ))
gris " quedan ~$HUECO tokens de conversacion tras el prompt fijo"
# ═══ 3. Configurar Hermes ══════════════════════════════════════════════════
titulo "3. Decirle a Hermes la verdad"
if [ "$SOLO_MEDIR" = si ]; then
gris " --solo-medir: no toco $CONFIG"
gris " Para aplicarlo a mano: model.default=$MODELO y context_length=$GANADOR"
else
cp "$CONFIG" "$CONFIG.antes-de-arrancar" 2>/dev/null
python3 - "$CONFIG" "$MODELO" "$GANADOR" <<'PY'
import re, sys
ruta, modelo, ctx = sys.argv[1], sys.argv[2], int(sys.argv[3])
lineas = open(ruta).read().splitlines(True)
# Se edita por LINEAS, no reescribiendo el YAML: el fichero son 1890 lineas de
# documentacion propia y volcarlo con yaml.dump la perderia entera.
hecho_modelo = hecho_ctx = False
for i, l in enumerate(lineas):
if not hecho_modelo and re.match(r'^ default: ', l):
lineas[i] = f' default: "{modelo}"\n'; hecho_modelo = True
# context_length viene comentada de fabrica dentro del bloque model:.
# La propia plantilla dice que es justo para "a local server with a custom
# num_ctx", que es exactamente este caso.
elif not hecho_ctx and re.match(r'^ # context_length: ', l):
lineas[i] = (f' # JARVIS verde: el num_ctx REAL del Modelfile. Sin esto Hermes\n'
f' # se cree lo que dice /api/show, que son 262144 (el maximo del\n'
f' # modelo) y no lo configurado.\n'
f' context_length: {ctx}\n')
hecho_ctx = True
if not hecho_modelo: sys.exit("no encontre la linea 'default:' en el bloque model")
if not hecho_ctx: sys.exit("no encontre la linea '# context_length:' comentada")
open(ruta, 'w').writelines(lineas)
PY
[ $? -ne 0 ] && abortar "no pude escribir la configuracion (copia en $CONFIG.antes-de-arrancar)"
# Verificar PARSEANDO, nunca leyendo: este fichero declara provider y
# base_url DOS veces dentro de model: y en YAML gana la ultima. Leyendolo
# parecia configurado y el trafico salia por OpenRouter.
python3 - "$CONFIG" "$MODELO" "$GANADOR" <<'PY'
import sys, yaml
ruta, modelo, ctx = sys.argv[1], sys.argv[2], int(sys.argv[3])
m = yaml.safe_load(open(ruta))['model']
fallos = []
if m.get('default') != modelo: fallos.append(f"default={m.get('default')}")
if m.get('context_length') != ctx: fallos.append(f"context_length={m.get('context_length')}")
if '127.0.0.1' not in str(m.get('base_url','')): fallos.append(f"base_url={m.get('base_url')}")
if fallos: sys.exit("la configuracion NO quedo como se pedia: " + ", ".join(fallos))
print(f" modelo={m['default']} context_length={m['context_length']} base_url={m['base_url']}")
PY
[ $? -ne 0 ] && abortar "verificacion fallida; restaura con: cp $CONFIG.antes-de-arrancar $CONFIG"
verde " ✓ configurado y verificado parseando"
fi
# ═══ 4. La prueba decisiva ═════════════════════════════════════════════════
titulo "4. ¿Sabe el modelo llamar herramientas?"
gris " Esto va ANTES de abrir el TUI a proposito. Separa dos preguntas que"
gris " juntas se contestan mal: ¿sabe el modelo emitir una llamada? y"
gris " ¿funciona Hermes? Si falla aqui, no hay nada que depurar en Hermes."
R=$(curl -s --max-time 300 "$OLLAMA/v1/chat/completions" -H 'Content-Type: application/json' -d "{
\"model\": \"$MODELO\",
\"messages\": [{\"role\":\"user\",\"content\":\"¿Cuánto espacio libre queda en el disco?\"}],
\"tools\": [{\"type\":\"function\",\"function\":{
\"name\":\"ejecuta_comando\",
\"description\":\"Ejecuta un comando de shell y devuelve su salida\",
\"parameters\":{\"type\":\"object\",\"properties\":{\"comando\":{\"type\":\"string\"}},\"required\":[\"comando\"]}}}]
}" 2>/dev/null)
echo "$R" | python3 -c "
import json, sys
try: d = json.load(sys.stdin)
except Exception: print(' RESPUESTA ILEGIBLE'); sys.exit(2)
if 'error' in d: print(' ERROR:', str(d['error'])[:200]); sys.exit(2)
msg = d['choices'][0]['message']
tc = msg.get('tool_calls') or []
if not tc:
print(' SIN tool_calls. Contesto en prosa:')
print(' ', repr((msg.get('content') or '')[:200]))
sys.exit(1)
f = tc[0]['function']
print(' herramienta :', f['name'])
print(' argumentos :', f['arguments'])
try:
json.loads(f['arguments']); print(' JSON valido')
except Exception: print(' JSON ROTO'); sys.exit(1)
sys.exit(0)
"
VEREDICTO=$?
titulo "Resumen"
printf ' contexto %s tokens (%s capas en GPU, %s MiB)\n' "$GANADOR" "$GANADOR_GPU" "$GANADOR_VRAM"
printf ' prompt fijo ~20.500 tokens -> quedan ~%s de conversacion\n' "$HUECO"
if [ "$VEREDICTO" -eq 0 ]; then
verde " ✓ el modelo llama herramientas. Lo que falle a partir de aqui es de Hermes."
gris ""
gris " Siguiente:"
gris " $AQUI/pruebas/ejecutar.sh las comprobaciones, ninguna abre el TUI"
gris " $AQUI/verde-launcher.sh el TUI en verde -> sigue ESCALERA.md"
else
rojo " ✗ el modelo NO emite llamadas usables en este formato."
gris ""
gris " Abrir el TUI ahora solo daria un fallo mas confuso. Dos salidas:"
gris " · modelo mayor por CPU: hay 49 GB de RAM y 8 nucleos fisicos."
gris " qwen3.5:9b esta en disco. Lento pero es un banco de pruebas."
gris " · recortar herramientas en platform_toolsets del config.yaml:"
gris " cli: [hermes-cli] -> una lista corta. Menos esquema, menos lio."
exit 1
fi