Diagrama — constitution.yaml alimentando un executor Python con compuerta halt-on-reject
9 min read

Los agentes LLM ignoran las reglas. Este patrón no.

#LLM #Agents #Python #Claude Code #Anthropic #Policy #Open Source

El problema

Cuando ejecutas un agente LLM sobre una base de código real, necesitas que respete reglas. No sugerencias — reglas. “Esta es la dirección legal del negocio; nunca sustituyas una dirección del área de servicio.” “Sin force pushes.” “Nunca uses estos términos de marca prohibidos en el contenido.” “El esquema LocalBusiness siempre debe usar la dirección principal, no el área de cobertura.”

El enfoque obvio: poner las reglas en el system prompt. “NO DEBES modificar el campo de dirección.” El problema es que los LLMs ignoran las instrucciones del system prompt bajo presión de tarea, ante input adversarial, o simplemente por accidente. Esto no es una preocupación teórica. Está documentado y es reproducible.

El issue #19874 en el repositorio anthropics/claude-code — abierto en enero de 2026 y cerrado como “not planned” sin corregir la arquitectura subyacente, aunque el bug subyacente está documentado desde v1.0.95 (agosto de 2025) en múltiples issues anteriores — lo dice claramente:

Plan mode provides no actual enforcement of read-only restrictions. The “safety guarantee” is implemented purely as a system prompt instruction that the LLM can (and regularly does) ignore.

El issue incluye un diagrama de flujo que hace explícita la brecha arquitectónica: no hay una capa de aplicación entre la decisión del LLM y la ejecución de la herramienta. Cuando el LLM ignora la instrucción de seguridad, la escritura del archivo ocurre de todas formas.

Dos issues relacionados ilustran el patrón: #21292 describe cómo Claude procede con acciones no-readonly cuando ExitPlanMode es rechazado por el usuario; #19021 documenta un caso concreto en el que el agente, tras editar archivos durante plan mode, intentó revertir sus cambios con git checkout y destruyó trabajo no commiteado del usuario. La causa raíz es la misma: el mensaje de rechazo se interpreta como orientación posterior a la implementación, no como una continuación de la restricción del plan mode.

La solución correcta no es un prompt mejor. Es la aplicación en la capa del executor — una verificación en código que el LLM no puede eludir.

Qué hacen las herramientas existentes (y qué no hacen)

Tres enfoques abordan partes de este problema. Ninguno lo cubre completamente.

Claude Code Plan Mode — revisión en propuestas, no en ejecución

Plan Mode resuelve el bucle de revisión de propuestas: el agente muestra lo que pretende hacer antes de hacerlo. Es útil, pero no es aplicación del lado del executor. Si el LLM procede a pesar de un rechazo — lo cual hace, según los issues citados arriba — no hay un respaldo. La aprobación ocurre en tiempo de propuesta; la ejecución permanece sin protección.

AWS Bedrock Guardrails — filtrado en inferencia, no en acción

Bedrock Guardrails aplica en tiempo de inferencia. Ahora soporta aplicación entre cuentas y a nivel organizacional, lo cual es una mejora significativa. Pero opera sobre la salida del modelo: filtrado de contenido, listas de temas denegados, detección de PII. No puedes expresar “si esta acción del agente escribiría la dirección NAP incorrecta en un esquema LocalBusiness, detente.” Puedes expresar “si la salida del modelo contiene PII, fíltrala.” Esto es valioso, pero opera sobre tokens, no sobre acciones — Bedrock no sabe nada del schema LocalBusiness ni del campo NAP que tu agente está a punto de escribir.

LangGraph interrupt() — human-in-the-loop, sin objeto de política

LangGraph interrupt() pausa un grafo en ejecución y entrega el control a un humano. El patrón Command(resume=...) te permite aprobar, rechazar o modificar la siguiente acción antes de que continúe la ejecución. Ésta es la forma correcta para flujos de trabajo human-in-the-loop. Pero LangGraph no tiene un objeto de política — ningún mecanismo para cargar reglas declarativas desde un archivo, fusionar anulaciones por ejecución encima, y aplicar el resultado de forma determinista. Las verificaciones se conectan manualmente dentro de los nodos — cada nodo que necesita aplicar una regla escribe su propia condición Python en código, sin un punto único donde declarar “estos son los invariantes del proyecto”.

GitHub Spec Kit — inyección de contexto, no aplicación

GitHub Spec Kit (github/spec-kit) introduce .specify/memory/constitution.md — un archivo de principios no negociables cargado como contexto LLM antes de los comandos del ciclo de vida. El naming es correcto y la idea es correcta. Pero es inyección de contexto, no aplicación. El LLM lee la constitución y se espera que la respete. No hay una parada a nivel de código cuando no lo hace.

La brecha es la combinación: reglas declarativas por capas fusionadas de forma determinista, con una parada aplicada en el executor, no en el prompt.

La convergencia

El problema de capas se ha resuelto en el mundo de la infraestructura desde 2018: los overlays de Kustomize.

Kustomize aborda “tengo una configuración base y necesito correcciones por entorno” con una fusión determinista de rightmost-wins. La base define los valores predeterminados; los overlays anulan campos específicos; el resultado es reproducible y depurable. Su diseño evita explícitamente cualquier fusión basada en lógica — la fusión es una operación estructural pura.

Aplicado a la política de agentes, la forma es:

.agent/
  constitution.yaml    # escrito por humanos, committed, invariantes base
  corrections.yaml     # anulaciones por ejecución, gitignored

El algoritmo de fusión es simple: final_rules = deep_merge(constitution, corrections). Los dicts se fusionan recursivamente; los escalares y listas son reemplazados por el valor más a la derecha. Sin lógica, sin participación del LLM, sin sorpresas.

En la implementación actual, corrections.yaml se escribe a mano por el desarrollador para una ejecución específica — por ejemplo, “esta migración puede tocar 200 archivos, no 50”. Un flujo de aprobación automatizado, donde Plan Mode propone correcciones y el usuario aprueba antes de que se aplique, es una integración natural pero vive fuera de la biblioteca v0.1.

Luego — la parte que falta en cada herramienta actual — las reglas fusionadas se aplican en Python, no en el prompt:

@halt_on_reject(constitution)
def write_local_business_schema(address: dict) -> None:
    legal = constitution.get("business.home_address")
    if address.get("addressLocality") != legal.get("addressLocality"):
        raise PolicyReject(
            f"address mismatch: schema has {address['addressLocality']!r}, "
            f"constitution requires {legal['addressLocality']!r}"
        )
    # ... lógica de escritura real

PolicyReject se propaga incondicionalmente. El LLM no puede instruir al runtime de Python para que omita un raise. Éste es el respaldo que Plan Mode no tiene.

Implementación de referencia

Construí este patrón como una pequeña biblioteca Python: constitution-overlay — licencia MIT, ~300 líneas, mypy --strict limpio.

La API pública tiene cuatro símbolos:

from constitution_overlay import (
    Constitution,        # carga y fusiona capas YAML/dict
    halt_on_reject,      # fábrica de decoradores — aplicación
    PolicyReject,        # tipo de excepción — detiene el executor
    ConstitutionContext, # vista de solo lectura para funciones de verificación
)

Carga y fusión

# Desde dicts
base = {
    "business": {
        "legal_name": "Arnold Wender",
        "home_address": {"addressLocality": "Halle (Saale)"},
    },
    "limits": {"max_files_per_commit": 50},
}
corrections = {"limits": {"max_files_per_commit": 200}}  # esta ejecución necesita más

constitution = Constitution.from_layers(base, corrections)
constitution.get("limits.max_files_per_commit")  # 200
constitution.get("business.home_address.addressLocality")  # "Halle (Saale)"

# Desde archivos
constitution = Constitution.from_layers(
    "constitution.yaml",
    "corrections.yaml",
)

from_layers() acepta dicts, rutas de cadena y objetos Path indistintamente. Los archivos ausentes lanzan FileNotFoundError inmediatamente — sin fallback silencioso.

Aplicación

@halt_on_reject(constitution)
def commit_files(files: list[str]) -> None:
    limit = constitution.get("limits.max_files_per_commit")
    if len(files) > limit:
        raise PolicyReject(f"too many files: {len(files)} > {limit}")
    # proceder con el commit

@halt_on_reject(constitution)
def write_copy(text: str) -> None:
    prohibited = constitution.get("brand.prohibited_terms", [])
    hits = [t for t in prohibited if t in text.lower()]
    if hits:
        raise PolicyReject(f"prohibited brand terms: {hits}")
    # proceder con la escritura

El decorador es un marcador de contrato. PolicyReject se propaga incondicionalmente — nada en el decorador lo absorbe. Las versiones futuras soportarán verificaciones de reglas previas a la ejecución directamente desde la constitución, pero v0.1 se mantiene sencilla: las verificaciones viven en la función envuelta, la aplicación vive en el decorador.

Un constitution.yaml realista

business:
  model: remote-services
  legal_name: "Arnold Wender"
  home_address:
    streetAddress: "Franckestrasse 3a"
    addressLocality: "Halle (Saale)"
    postalCode: "06110"
    addressCountry: "DE"
  telephone: "+49 345 6867 6857"
service_area:
  - "Bayern"
brand:
  prohibited_terms: ["billig", "guenstigster"]
constraints:
  - "LocalBusiness schema MUST use home_address as address; service_area goes in areaServed"
  - "City pages reference target locality in content only — never in NAP"
require_review:
  - "schema.LocalBusiness"
  - "url-impact:destructive"

La lista service_area es lo que el sitio web tiene como objetivo. La home_address es donde opera realmente el negocio. Un agente LLM trabajando en un sitio orientado a Bayern no debe confundirlas — la constitución hace esa restricción explícita y aplicable.

Limitaciones

v0.1 es deliberadamente estrecho. Lo que no hace:

  • Sin soporte async. Solo síncrono. Los wrappers asíncronos están planeados para v0.2.
  • Sin validación de esquema de la constitución. La biblioteca fusiona cualquier YAML que se le dé. La validación JSON Schema de la constitución en sí está en el roadmap.
  • Sin compuerta de revisión integrada. El flujo de aprobación (mostrar el conflicto, pedir aprobar/rechazar/editar, escribir corrections.yaml) está fuera de la biblioteca. La biblioteca solo maneja la fusión y la aplicación.
  • La fusión de listas es solo replace. Gana el valor más a la derecha. Aún no hay directiva de append, lo que significa que prohibited_terms en un overlay reemplaza la lista base en lugar de extenderla. Esto está en el roadmap.
  • No es un reemplazo de LangGraph, Claude Code o Bedrock. Es una capa de aplicación que complementa a estas herramientas.

Lo que me gustaría ver en Claude Code

El patrón de parada del lado del executor pertenece nativamente en Claude Code como un primitivo de primera clase. Específicamente:

Un constitution.yaml en la raíz del proyecto (o en .claude/) que Claude Code lea antes de cualquier ejecución de herramienta — no como inyección de contexto sino como una lista de restricciones duras. Antes de cualquier llamada a Edit, Write o Bash, Claude Code verifica la acción contra la constitución. La violación lanza, no advierte.

Las correcciones como concepto de primera clase: cuando Plan Mode identifica un conflicto entre una acción propuesta y la constitución, escribe un corrections.yaml con la resolución propuesta. El executor lee ese archivo, lo fusiona sobre la constitución y procede con la acción corregida — o se detiene si la corrección dice decision: rejected.

La semántica de fusión es la misma que Kustomize ha usado desde 2018: determinista, rightmost wins, sin lógica de fusión impulsada por LLM. El resultado es reproducible y auditable.

La implementación de referencia está en github.com/arnoldwender/constitution-overlay. Tiene licencia MIT, 69 pruebas, 98% de cobertura, y pasa mypy --strict. Si esto es útil como punto de partida para un port a TypeScript o como referencia de diseño, con gusto ayudo.

Para ver un ejemplo práctico de herramientas Python para flujos con LLMs, el caso de estudio de MemPalace ofrece una perspectiva complementaria.

Cómo citar este trabajo

DOI

Wender, A. (2026). constitution-overlay: Kustomize-style YAML rule layering with executor-side halt-on-reject for LLM agents (Version v0.1.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.19773588

ORCID iD: ORCID iD icon https://orcid.org/0009-0005-1750-818X