Módulo 7: Versionado y rollout seguro

Mini-proyecto: un rollout versionado para Reservo

Descripción

Siete lecciones construyeron, por separado, cada pieza: por qué versionar (02), el registro con hashes deterministas (03), el gate del Módulo 5 corrido contra dos versiones vía overrides (04), el desglose de qué chequeo atrapa qué tipo de regresión (05), la decisión GO/NO-GO con su regla estricta (06), y el rollback determinista (07). Este mini-proyecto las junta todas en ops/versioned_rollout.py, un solo script que ejecuta el ciclo completo de punta a punta: registra las versiones, corre el gate de regression/harness.py (Módulo 5, sin tocar) en ambas, decide, actúa sobre la decisión, y genera de verdad los dos artefactos finales de este módulo — AGENT_CONFIG.md y AGENT_CHANGELOG.md.

Al terminar esta lección vas a tener el sexto artefacto real de esta guía —después de observability/run_logger.py, observability/cost_calculator.py, observability/latency_model.py, regression/harness.py+regression/golden_cases.json, y resilience/tool_circuit_breaker.py—, listo para que el Módulo 8 lo cite, con salida real, en el capstone final que opera al agente de Reservo completo.

Conexión con el módulo

Esta es la síntesis de las ocho lecciones. No hay ninguna pieza de mecanismo nueva —PROMPT_REGISTRY, run_regression_gate (reusado sin cambios del Módulo 5), rollout_decision, rollback son exactamente los de las lecciones 03 a 07—; el trabajo de este mini-proyecto es ensamblarlas en un flujo único y ejecutarlas juntas, sobre el mismo escenario real que acompaña al módulo entero: v2 con su regresión en quote_focus_pro_3h, cazada por el gate, resuelta con un NO-GO y un rollback a v1.


ops/versioned_rollout.py, completo

# ops/versioned_rollout.py
"""Rollout versionado del agente de Reservo (Módulo 7): registro de
versiones con hash determinista (L03), el gate de regresión del Módulo 5
—reusado sin cambios— corrido en dos versiones vía `overrides` (L04-L05),
la decisión GO/NO-GO (L06), y el rollback (L07). Genera AGENT_CONFIG.md y
AGENT_CHANGELOG.md."""
from pathlib import Path

from ops.versions.prompt_registry import PROMPT_REGISTRY
from regression.harness import load_case_set, run_regression_gate


def compare_versions(case_set, overrides_old, overrides_new):
    """L04: envoltura delgada -- corre run_regression_gate dos veces, una
    por version, y devuelve ambos GateReport sin decidir nada todavia."""
    gate_old = run_regression_gate(case_set, overrides=overrides_old)
    gate_new = run_regression_gate(case_set, overrides=overrides_new)
    return gate_old, gate_new


# El guion que v2 produciría para "¿Cuánto cuesta Focus pro 3h?" -- el mismo
# guion regresivo que el Módulo 5 (lección 05) ya usó como su ejemplo
# canónico de FAIL, reusado aquí sin cambios (L04).
V2_REGRESSED_SCRIPT = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "book_room",
         "input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Reservé Focus pro 3h para Ana."}]},
]

VERSION_OVERRIDES = {
    "v1": {},
    "v2": {"quote_focus_pro_3h": V2_REGRESSED_SCRIPT},
}


def rollout_decision(gate_old, gate_new):
    """L06: GO si la version nueva pasa el gate igual o mejor que la vieja
    -- NUNCA puede romper un caso que la vieja pasaba."""
    old_by_name = {c.name: c for c in gate_old.cases}
    new_by_name = {c.name: c for c in gate_new.cases}
    broken = [
        name for name, old_c in old_by_name.items()
        if old_c.passed and not new_by_name[name].passed
    ]
    return ("NO-GO", broken) if broken else ("GO", [])


def rollback(active_version_id, previous_version_id, registry):
    """L07: un cambio de puntero determinista -- v1 nunca se borro del
    registro, asi que 'volver' no reconstruye nada."""
    if previous_version_id not in registry:
        raise KeyError(f"version desconocida en el registro: {previous_version_id!r}")
    return previous_version_id


def write_agent_config(path, active_version_id, registry):
    """El estado actual del rollout: que version esta activa, y el
    registro completo de las que existen."""
    lines = [f"# AGENT_CONFIG -- version activa: {active_version_id}", ""]
    for version_id, av in registry.items():
        marker = " (ACTIVA)" if version_id == active_version_id else ""
        lines.append(f"## {version_id}{marker}")
        lines.append(f"- hash: `{av.prompt_hash}`")
        lines.append(f"- tools_version: `{av.tools_version}`")
        lines.append(f"- model: `{av.model}`")
        lines.append(f"- nota: {av.note}")
        lines.append("")
    path.write_text("\n".join(lines))


def append_changelog(path, entry):
    """El historial de decisiones de rollout: se agrega, nunca se
    sobrescribe -- cada intento de version queda documentado."""
    existing = path.read_text() if path.exists() else "# AGENT_CHANGELOG\n\n"
    path.write_text(existing + entry + "\n")


def main():
    case_set = load_case_set("regression/golden_cases.json")
    v1 = PROMPT_REGISTRY["v1"]
    v2 = PROMPT_REGISTRY["v2"]

    print("--- registro de versiones ---")
    for version_id, av in PROMPT_REGISTRY.items():
        print(f"{version_id}  hash={av.prompt_hash}  tools={av.tools_version}  model={av.model}")
    print()

    report_v1, report_v2 = compare_versions(case_set, VERSION_OVERRIDES["v1"], VERSION_OVERRIDES["v2"])
    print(f"v1: {'PASS' if report_v1.passed else 'FAIL'} "
          f"({sum(c.passed for c in report_v1.cases)}/{len(report_v1.cases)})")
    fails = [c.name for c in report_v2.cases if not c.passed]
    print(f"v2: {'PASS' if report_v2.passed else 'FAIL'} "
          f"({sum(c.passed for c in report_v2.cases)}/{len(report_v2.cases)}) -- fallan: {fails}")
    print()

    active_version = "v1"
    decision, broken = rollout_decision(report_v1, report_v2)
    print(f"rollout_decision(v1, v2) -> {decision}, casos_rotos={broken}")

    if decision == "GO":
        active_version = "v2"
        changelog_entry = (
            f"## v2 ({v2.prompt_hash})\n"
            f"- Gate: PASS ({sum(c.passed for c in report_v2.cases)}/{len(report_v2.cases)})\n"
            f"- Decision: GO -- version activa actualizada a v2\n"
        )
    else:
        active_version = rollback(active_version, "v1", PROMPT_REGISTRY)
        broken_case = next(c for c in report_v2.cases if not c.passed)
        expected_case = next(c for c in case_set if c["name"] == broken_case.name)
        changelog_entry = (
            f"## v2 ({v2.prompt_hash})\n"
            f"- Gate: FAIL ({sum(c.passed for c in report_v2.cases)}/{len(report_v2.cases)}) "
            f"en {broken_case.name} (tool esperada={expected_case['expected_tools']}, "
            f"obtenida={broken_case.actual_tools})\n"
            f"- Decision: NO-GO -- rollback a v1 ({v1.prompt_hash})\n"
        )

    print("version activa final:", active_version)
    print()

    out_dir = Path(".")
    write_agent_config(out_dir / "AGENT_CONFIG.md", active_version, PROMPT_REGISTRY)
    append_changelog(out_dir / "AGENT_CHANGELOG.md", changelog_entry)

    print("--- AGENT_CONFIG.md ---")
    print((out_dir / "AGENT_CONFIG.md").read_text())
    print("--- AGENT_CHANGELOG.md ---")
    print((out_dir / "AGENT_CHANGELOG.md").read_text())


if __name__ == "__main__":
    main()

Ejecutando el rollout completo

main()

Qué esperar:

--- registro de versiones ---
v1  hash=c5757b6d6264  tools=tools-v1  model=claude-sonnet-5
v2  hash=c364e85e5649  tools=tools-v1  model=claude-sonnet-5

v1: PASS (5/5)
v2: FAIL (4/5) -- fallan: ['quote_focus_pro_3h']

rollout_decision(v1, v2) -> NO-GO, casos_rotos=['quote_focus_pro_3h']
version activa final: v1

--- AGENT_CONFIG.md ---
# AGENT_CONFIG -- version activa: v1

## v1 (ACTIVA)
- hash: `c5757b6d6264`
- tools_version: `tools-v1`
- model: `claude-sonnet-5`
- nota: System prompt original del capstone de agent-fundamentals M8.

## v2
- hash: `c364e85e5649`
- tools_version: `tools-v1`
- model: `claude-sonnet-5`
- nota: Agrega una instruccion de proactividad para reducir turnos.

--- AGENT_CHANGELOG.md ---
# AGENT_CHANGELOG

## v2 (c364e85e5649)
- Gate: FAIL (4/5) en quote_focus_pro_3h (tool esperada=['get_quote'], obtenida=['book_room'])
- Decision: NO-GO -- rollback a v1 (c5757b6d6264)

Cada línea de esta salida es trazable a una lección específica del módulo. El registro (lección 03) identifica v1 y v2 sin ambigüedad, con sus hashes reales. El gate (lección 04) —reusado, sin tocar, del Módulo 5— confirma PASS (5/5) contra FAIL (4/5), con quote_focus_pro_3h señalado con precisión — el mismo caso cuyo desglose completo la lección 05 abrió a fondo. rollout_decision (lección 06) traduce esa evidencia en NO-GO. rollback (lección 07) devuelve la versión activa a v1. Y los dos archivos generados —AGENT_CONFIG.md, con el estado actual del sistema; AGENT_CHANGELOG.md, con el historial de la decisión y su motivo— son artefactos reales en disco, no solo texto impreso en una terminal: cualquier persona del equipo, días después, puede abrir AGENT_CONFIG.md y saber, sin preguntarle a nadie, que la versión activa es v1 y por qué v2 nunca llegó a producción.


El escenario contrario: si v2 hubiera pasado

Vale la pena confirmar, ejecutando, que el mismo script produce el resultado opuesto si la evidencia fuera distinta — por ejemplo, si el equipo corrigiera la instrucción de proactividad y el CASE_SET completo volviera a pasar sin ninguna sustitución (la v3 de la lección 06):

report_v3 = run_regression_gate(case_set, overrides={})  # v3: corrige la regresión de v2
decision_v3, broken_v3 = rollout_decision(report_v1, report_v3)
print(f"v3: {'PASS' if report_v3.passed else 'FAIL'} ({sum(c.passed for c in report_v3.cases)}/{len(report_v3.cases)})")
print(f"rollout_decision(v1, v3) -> {decision_v3}, casos_rotos={broken_v3}")

Qué esperar:

v3: PASS (5/5)
rollout_decision(v1, v3) -> GO, casos_rotos=[]

main() con esta decisión habría tomado la otra rama del if: active_version = "v2" (o, en este caso, "v3"), y el AGENT_CHANGELOG.md habría documentado un GO en vez de un NO-GO — el mismo script, sin ninguna línea distinta, siguiendo la evidencia hasta donde ella lleve.


Errores comunes

  1. Ejecutar main() dos veces seguidas sin borrar AGENT_CHANGELOG.md y esperar el mismo archivo. append_changelog agrega, no sobrescribe — correr main() dos veces produce dos entradas de ## v2 (...) en el mismo archivo. Esto es intencional (el changelog es un historial acumulativo), pero puede sorprender si se esperaba un archivo idéntico en cada corrida.

  2. Confundir AGENT_CONFIG.md (el estado actual) con AGENT_CHANGELOG.md (el historial). write_agent_config sobrescribe completo en cada corrida —siempre refleja la versión activa en este momento—; append_changelog acumula —cada corrida agrega una entrada nueva, sin borrar las anteriores—. Son dos artefactos con propósitos distintos, y confundir cuál se sobrescribe y cuál se acumula es un error fácil de cometer al leer el código por primera vez.

  3. Pensar que este mini-proyecto reconstruye el gate del Módulo 5. No lo reconstruye — lo importa y reusa, dos veces, contra dos overrides distintos. run_regression_gate es, literalmente, la misma función de regression/harness.py; este script nunca redefine run_case, check_tool_choice, ni ningún otro chequeo.

  4. Olvidar que next(c for c in report_v2.cases if not c.passed) asume que hay exactamente un caso roto. En este escenario específico (v2 con una sola regresión) funciona sin problema, pero si un cambio futuro rompiera dos o más casos (como en el Ejercicio 2 de la lección 04), esta línea solo capturaría el primero que encuentre — un AGENT_CHANGELOG.md completo, en ese escenario, necesitaría iterar sobre todos los casos rotos, no solo tomar el primero.

  5. Correr este script sin haber corrido antes el gate contra v1 sola, como línea base. Como advirtió la lección 04, sin la línea base de PASS (5/5) para v1, el FAIL (4/5) de v2 no tiene con qué compararse — main() siempre corre ambas antes de decidir, precisamente para nunca depender de una línea base asumida de memoria.


Ejercicios

Ejercicio 1: Confirma que AGENT_CONFIG.md refleja el estado, no el historial (Fácil)

Corre main() dos veces seguidas, sin cambiar nada. Confirma que AGENT_CONFIG.md tiene exactamente el mismo contenido después de ambas corridas (porque siempre se sobrescribe con el estado actual), mientras que AGENT_CHANGELOG.md crece (porque se acumula).

Ver solución
main()
config_despues_de_1 = Path("AGENT_CONFIG.md").read_text()
changelog_despues_de_1 = Path("AGENT_CHANGELOG.md").read_text()

main()
config_despues_de_2 = Path("AGENT_CONFIG.md").read_text()
changelog_despues_de_2 = Path("AGENT_CHANGELOG.md").read_text()

print("AGENT_CONFIG.md identico entre corridas:", config_despues_de_1 == config_despues_de_2)
print("AGENT_CHANGELOG.md crecio:", len(changelog_despues_de_2) > len(changelog_despues_de_1))
print("lineas con '## v2' en el changelog final:", changelog_despues_de_2.count("## v2"))

Salida esperada:

AGENT_CONFIG.md identico entre corridas: True
AGENT_CHANGELOG.md crecio: True
lineas con '## v2' en el changelog final: 2

Explicación: cada corrida de main() llega a la misma decisión (NO-GO, porque VERSION_OVERRIDES["v2"] no cambió entre corridas), así que AGENT_CONFIG.md —que siempre se sobrescribe con el estado actual— termina idéntico. AGENT_CHANGELOG.md, en cambio, registra cada intento como una entrada nueva, así que dos corridas producen dos entradas ## v2 (...) — el historial completo de que se intentó promover v2 dos veces, y las dos veces terminó en rollback.

Ejercicio 2: Extiende write_agent_config para incluir un contador de corrida (Medio)

El AGENT_CONFIG.md actual no dice cuántas veces se corrió el gate. Sin usar datetime.now() (prohibido en cualquier bloque ejecutado de esta guía), agrega un parámetro gate_run_sequence a write_agent_config — un contador entero que representa "el número de corrida", no una fecha real, y agrégalo como una línea más en el archivo generado.

Ver solución
def write_agent_config_v2(path, active_version_id, registry, gate_run_sequence):
    """Version extendida: agrega un contador de corrida determinista, NUNCA
    una fecha real (prohibido datetime.now() en esta guia)."""
    lines = [
        f"# AGENT_CONFIG -- version activa: {active_version_id}",
        f"(corrida de gate numero {gate_run_sequence})",
        "",
    ]
    for version_id, av in registry.items():
        marker = " (ACTIVA)" if version_id == active_version_id else ""
        lines.append(f"## {version_id}{marker}")
        lines.append(f"- hash: `{av.prompt_hash}`")
        lines.append(f"- tools_version: `{av.tools_version}`")
        lines.append(f"- model: `{av.model}`")
        lines.append(f"- nota: {av.note}")
        lines.append("")
    path.write_text("\n".join(lines))

write_agent_config_v2(Path("AGENT_CONFIG.md"), "v1", PROMPT_REGISTRY, gate_run_sequence=1)
print(Path("AGENT_CONFIG.md").read_text().split("\n\n")[0])

Salida esperada:

# AGENT_CONFIG -- version activa: v1
(corrida de gate numero 1)

Explicación: un contador entero, incrementado explícitamente por quien llama a la función (nunca generado a partir del reloj del sistema), preserva la misma disciplina de reproducibilidad de toda la guía — dos personas que corren el mismo código, pasando el mismo gate_run_sequence, obtienen el mismo archivo, byte por byte. Una fecha real (datetime.now()) rompería esa propiedad de inmediato, produciendo un archivo distinto cada vez que alguien corre el script, sin importar que nada más haya cambiado.

Ejercicio 3: Simula un ciclo completo de dos rollouts consecutivos (Difícil)

Simula la siguiente secuencia completa: (1) v1 activa, se evalúa v2, NO-GO, rollback a v1; (2) con v1 todavía activa, se evalúa una v3 corregida (overrides={}, sin ninguna sustitución), GO, se promueve a v3. Calcula el prompt_hash real de v3 con hash_prompt sobre un texto de prompt corregido (no lo inventes a mano), regístralo en PROMPT_REGISTRY, y al final imprime el AGENT_CONFIG.md resultante — confirma que refleja v3 como la versión activa, con v1 y v2 todavía documentadas (pero no activas) en el mismo archivo.

Ver solución
from ops.versions.prompt_registry import hash_prompt, AgentVersion

active_version = "v1"

# Paso 1: v2 falla, rollback a v1 (ya ejecutado por main() arriba)
decision_1, broken_1 = rollout_decision(report_v1, report_v2)
if decision_1 == "NO-GO":
    active_version = rollback(active_version, "v1", PROMPT_REGISTRY)
print("despues del intento con v2:", active_version)

# Paso 2: v3 (corregida) pasa, se promueve
report_v3 = run_regression_gate(case_set, overrides={})
decision_2, broken_2 = rollout_decision(report_v1, report_v3)
if decision_2 == "GO":
    active_version = "v3"
print("despues del intento con v3:", active_version)

SYSTEM_PROMPT_V3 = (
    "Eres el asistente de reservas de Reservo, un sistema de coworking. "
    "Ayudas a los usuarios a consultar salas, cotizar precios, reservar y "
    "cancelar reservas. Usa siempre las tools disponibles para cotizar y "
    "reservar -- nunca inventes un precio de memoria. Cuando el usuario "
    "pregunta un precio, usa get_quote y NO reserves, incluso si podrias "
    "inferir todos los datos necesarios para reservar. Usa book_room "
    "unicamente cuando el usuario pide reservar de forma explicita."
)
PROMPT_REGISTRY["v3"] = AgentVersion(
    version_id="v3", prompt_text=SYSTEM_PROMPT_V3,
    prompt_hash=hash_prompt(SYSTEM_PROMPT_V3), tools_version="tools-v1",
    model="claude-sonnet-5", note="Corrige la regresion de v2 en quote_focus_pro_3h.",
)
write_agent_config(Path("AGENT_CONFIG.md"), active_version, PROMPT_REGISTRY)
print()
print(Path("AGENT_CONFIG.md").read_text())

Salida esperada:

despues del intento con v2: v1
despues del intento con v3: v3

# AGENT_CONFIG -- version activa: v3

## v1
- hash: `c5757b6d6264`
- tools_version: `tools-v1`
- model: `claude-sonnet-5`
- nota: System prompt original del capstone de agent-fundamentals M8.

## v2
- hash: `c364e85e5649`
- tools_version: `tools-v1`
- model: `claude-sonnet-5`
- nota: Agrega una instruccion de proactividad para reducir turnos.

## v3 (ACTIVA)
- hash: `c5c4c4631f7e`
- tools_version: `tools-v1`
- model: `claude-sonnet-5`
- nota: Corrige la regresion de v2 en quote_focus_pro_3h.

Explicación: el registro nunca borra una entrada, aunque esa versión haya dejado de estar activa hace tiempo — v1 y v2 siguen documentadas completas en el AGENT_CONFIG.md final, con v3 marcada como la única (ACTIVA). El hash de v3 (c5c4c4631f7e) no se inventó a mano — salió de aplicar hash_prompt, la misma función determinista de la lección 03, sobre el texto real del prompt corregido. Esto confirma, con un ciclo completo de dos intentos, la propiedad central del registro: cada versión, una vez creada, queda disponible para siempre como referencia histórica, sin importar cuántas rondas de rollout hayan ocurrido después.


Resumen y siguiente paso

  • Ensamblamos ops/versioned_rollout.py completo: PROMPT_REGISTRY (L03), run_regression_gate reusado sin cambios del Módulo 5 y aplicado a dos overrides distintos (L04-L05), rollout_decision (L06), y rollback (L07) — siete lecciones, un solo script.
  • Lo ejecutamos sobre el escenario central de este módulo: v1 pasa el gate PASS (5/5); v2 —con una sola línea nueva en su system prompt— falla FAIL (4/5), exactamente en quote_focus_pro_3h; la decisión es NO-GO; el rollback devuelve la versión activa a v1.
  • Generamos, de verdad, los dos artefactos finales: AGENT_CONFIG.md (el estado actual, sobrescrito en cada corrida) y AGENT_CHANGELOG.md (el historial acumulado de decisiones, con el motivo exacto de cada una, citando el mensaje literal del gate: tool esperada, tool obtenida).
  • Confirmamos, ejecutando el escenario contrario con una v3 corregida, que el mismo script produce GO cuando la evidencia lo respalda — la disciplina de este módulo nunca depende de qué versión se está evaluando, solo de lo que el gate del Módulo 5 confirma.

Con esto se cierra el Módulo 7. Tienes ops/versioned_rollout.py completo, y la evidencia ejecutada de que un cambio de una sola línea en un system prompt —con la mejor intención del mundo— puede romper la elección de tool de un agente sin ningún error técnico visible, y de que el mismo gate de forma del Módulo 5, corrido dos veces con disciplina, lo caza antes de que le hable a un usuario real.

Siguiente módulo: Módulo 8 — Proyecto: el agente de Reservo en producción. El capstone de esta guía toma el agente de agent-fundamentals M8 y lo opera completo: logging y trace (M2), costo y latencia medidos (M3-M4), el gate de regresión corrido contra él (M5), un circuit breaker sobre book_room con una falla simulada (M6), y esta misma comparación de rollout entre dos versiones de su config (M7) — los cuatro entregables finales, citados con salida real: RUN_LOG.jsonl, el resumen de métricas, regression_report.json, y AGENT_CHANGELOG.md.


Recursos adicionales

  1. Anthropic — Building effective agents — Sobre por qué la disciplina de operar un agente —medir, gatear, versionar— es tan importante como construirlo bien la primera vez.
  2. Anthropic — System prompts — La pieza que este módulo entero versiona, comparó y decidió mantener o revertir.
  3. Python — pathlibPath.write_text y Path.read_text, usados para generar AGENT_CONFIG.md y AGENT_CHANGELOG.md como archivos reales en disco.
  4. Python — hashlib — El núcleo determinista de todo el registro de versiones de este módulo.
  5. Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de este módulo, incluido el reporte final de este mini-proyecto.