← Volver al Jardín

Bonsai 27B + Hermes Agent: Arreglando el Bug del Parser JSON Schema de llama.cpp

#LLM#1-Bit#PrismML#GGUF#llama.cpp#Hermes#Agente#MCP#Tool-Calling#Proxy#Debug

DEBUG_LOG // BONSAI_27B // HERMES_AGENT // LLAMA_CPP // PROXY_FIX

Después de hacer benchmark de Bonsai 27B en una GPU de 8GB y lograr 27.35 tok/s con 160K de contexto, el siguiente paso lógico era integrarlo en mi flujo de trabajo de agente en producción: Hermes Agent, el framework que impulsa mis reportes diarios AM/PM de infraestructura, triage de correos, facturación electrónica SRI, y monitoreo de GitHub.

El modelo funcionó perfectamente para chat de texto simple vía hermes -p bonsai_27b. Pero en cuanto intenté ejecutarlo a través del scheduler de cron jobs de Hermes — que inyecta los tool schemas de los servidores MCP (hledger, email, facturador-sri, payphone) — todo colapsó con un opaco HTTP 400.


El Error

HTTP 400: Unable to generate parser for this template.
Automatic parser generation failed: JSON schema conversion failed:
Error resolving ref #/properties/begin/anyOf/0: anyOf not in 
{"type":"string","pattern":"^\\d{4}(-\\d{2}(-\\d{2})?)?$","nullable":true}

El mensaje se repetía 16 veces — una por cada herramienta MCP en la petición. Hermes enviaba 119 herramientas con ~170KB de schemas JSON, y el parser interno de gramática de llama-server no podía procesarlos.


Diagnóstico: Cuatro Capas de Profundidad

Capa 1 — --jinja Fue una Pista Falsa

Mi primer instinto fue eliminar --jinja de los flags de llama-server. El motor de plantillas Jinja procesa los templates de chat, y asumí que estaba conflictuando con los tool schemas de Hermes.

Resultado: El error persistió de forma idéntica. --jinja controla el formateo de mensajes de chat, no la generación de gramática JSON. Son subsistemas completamente distintos.

Capa 2 — anyOf en los Schemas del Email

Construí un proxy de debug entre Hermes y llama-server para inspeccionar el payload real. El sanitizador detectó bloques anyOf en los parámetros de las herramientas de email (to, cc, bcc — campos que aceptan tanto array como string):

"to": {
  "anyOf": [
    {"items": {"type": "string"}, "type": "array"},
    {"type": "string"}
  ]
}

Eliminé esos. Seguía fallando.

Capa 3 — El $ref Fantasma

Al inspeccionar el request crudo de 170KB apareció el verdadero culpable. El schema_sanitizer interno de Hermes ya había limpiado el campo begin de la herramienta hledger — reemplazando su anyOf con un type: string plano. Pero el campo end todavía tenía un $ref colgante apuntando al anyOf ahora eliminado:

"begin": {
  "type": "string",
  "pattern": "^\\d{4}(-\\d{2}(-\\d{2})?)?$",
  "nullable": true
},
"end": {
  "$ref": "#/properties/begin/anyOf/0",
  "nullable": true
}

llama-server perseguía ese $ref, no encontraba ningún anyOf en el destino, y lanzaba el error. Referencias $ref huérfanas a rutas de esquema ya sanitizadas.

Capa 4 — Los MCP Servers se Cargan a Nivel de Perfil

Incluso con enabled_toolsets=[] en el cron job, los servidores MCP definidos en config.yaml (hledger, email, facturador-sri, payphone, contífico) inyectan sus tool schemas en el system prompt de todas formas. No existe filtrado MCP por job — son globales al perfil.


La Solución: Proxy Sanitizador

Un proxy ligero en Python se interpone entre Hermes y llama-server, sanitizando los tool schemas en cada petición. Elimina tanto los bloques anyOf (aplanándolos con su primer tipo no-nulo) como los punteros $ref (reemplazándolos con un type: string seguro).

Código del Proxy

#!/usr/bin/env python3
"""Proxy sanitizador para llama.cpp: elimina anyOf y $ref de los tool schemas."""
import json, http.server, urllib.request

TARGET = "http://<llama-server-host>:11433/v1/chat/completions"
PORT = 11434

def sanitize(obj):
    if isinstance(obj, dict):
        if "anyOf" in obj:
            types = [t.get("type") for t in obj["anyOf"]
                     if isinstance(t, dict) and t.get("type") != "null"]
            if types:
                obj["type"] = types[0]
            del obj["anyOf"]
        if "$ref" in obj:
            del obj["$ref"]
            obj.setdefault("type", "string")
        for v in obj.values():
            sanitize(v)
    elif isinstance(obj, list):
        for item in obj:
            sanitize(item)

class Handler(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length)
        try:
            data = json.loads(body)
            sanitize(data)
            cleaned = json.dumps(data).encode("utf-8")
        except Exception:
            cleaned = body
        req = urllib.request.Request(
            TARGET, data=cleaned,
            headers={"Content-Type": "application/json"}
        )
        try:
            with urllib.request.urlopen(req, timeout=300) as resp:
                self.send_response(resp.status)
                for k, v in resp.headers.items():
                    self.send_header(k, v)
                self.end_headers()
                self.wfile.write(resp.read())
        except urllib.error.HTTPError as e:
            self.send_response(502)
            self.end_headers()
            self.wfile.write(
                json.dumps({"error": f"HTTP Error {e.code}: {e.reason}"}).encode()
            )
    def log_message(self, *args):
        pass

http.server.HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()

Servicio Systemd

[Unit]
Description=Bonsai 27B Proxy Sanitizer
After=network.target

[Service]
Type=simple
User=root
ExecStart=/usr/bin/python3 /root/.hermes/profiles/bonsai_27b/proxy_sanitizer.py
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

Actualización de Config en Hermes

custom_providers:
  - name: bonsai-27b
    base_url: http://127.0.0.1:11434/v1   # ← proxy, no directo
    api_key: llama.cpp
    model: Bonsai-27B-Q1_0.gguf

Diagrama de Arquitectura

┌──────────┐    170KB JSON + 119 tools     ┌────────────┐    sanitizado    ┌─────────────┐
│  Hermes  │ ────────────────────────────→ │   Proxy    │ ──────────────→ │ llama-server │
│  Agent   │ ←──────────────────────────── │ :11434     │ ←────────────── │ :11433       │
└──────────┘    text/stream response       └────────────┘  text/stream    └─────────────┘

                                    elimina anyOf + $ref de todos
                                    los tool schemas en tiempo real

Hermes + Bonsai: Lo Que Funciona Ahora

Caso de Uso Estado Notas
Chat de texto simple (bonsai_27b chat -q) ✅ Funciona Sin herramientas = sin conflicto
Cron jobs sin MCP ✅ Funciona enabled_toolsets=[] + proxy
Cron jobs CON MCP (email, hledger, SRI) ✅ Funciona El proxy limpia los schemas problemáticos
Agente interactivo con herramientas completas ⚠️ Parcial Funciona si las herramientas no generan anyOf o $ref huérfanos

Causa Raíz Upstream

Esto es en última instancia un problema de llama.cpp — el conversor json_schema_to_grammar en llama-server no maneja keywords anyOf ni resolución de $ref en los schemas de tool calling. El workaround documentado aquí es un proxy del lado del cliente hasta que el parser upstream tenga soporte para anyOf y resolución de $ref.

Issue candidato para seguimiento: ggml-org/llama.cpp — buscar “Unable to generate parser JSON schema conversion anyOf”.


Conclusión Clave

Si estás intentando emparejar un modelo de razonamiento (Bonsai, Qwen3, DeepSeek) servido vía llama-server con un framework de agente complejo (Hermes, OpenCode, CrewAI) que tiene herramientas MCP con schemas JSON ricos, te vas a topar con este muro. El patrón del proxy sanitizador es genérico — reemplaza los tool schemas en el cable antes de que lleguen a llama.cpp, y el modelo funciona sin problemas.

Código fuente completo y unidad systemd disponibles en mis configs de homelab.