Routing Agent — Python (implementación completa con LLM)

Ejemplo ejecutable de agente Routing en Python con estilo de producción, con route schema, policy boundary, allowlist, fallback de reroute, presupuestos y stop reasons.
En esta página
  1. Esencia del patrón (breve)
  2. Qué demuestra este ejemplo
  3. Arquitectura
  4. Estructura del proyecto
  5. Cómo ejecutar
  6. Tarea
  7. Solución
  8. Código
  9. tools.py — workers especializados
  10. gateway.py — policy boundary (la capa más importante)
  11. llm.py — routing decision + final synthesis
  12. main.py — Route -> Delegate -> Finalize
  13. requirements.txt
  14. Ejemplo de salida
  15. stop_reason típicos
  16. Qué NO se muestra aquí
  17. Qué probar después
  18. Código completo en GitHub

Esencia del patrón (breve)

Routing Agent es un patrón donde el agente no ejecuta la tarea directamente, sino que elige al ejecutor especializado más adecuado para cada tipo de solicitud.

El LLM toma la decisión de ruta, y la ejecución la realiza solo la execution layer mediante un policy boundary.

Qué demuestra este ejemplo

  • etapa Route separada antes de la ejecución
  • policy boundary entre decisión de routing (LLM) y workers (execution layer)
  • validación estricta para route-action (kind, target, args, allowed keys)
  • allowlist (deny by default) para routing
  • fallback mediante needs_reroute con número limitado de intentos
  • presupuestos de run: max_route_attempts, max_delegations, max_seconds
  • stop_reason explícitos para depuración, alertas y monitoreo en producción
  • raw_route en la respuesta si el LLM devolvió un route JSON inválido

Arquitectura

  1. El LLM recibe el goal y devuelve route-intent en JSON (kind="route", target, args).
  2. El policy boundary valida la ruta como input no confiable (incluyendo args.ticket obligatorio).
  3. RouteGateway delega la tarea al worker seleccionado (allowlist, budgets, loop detection).
  4. La observation se agrega a history y se vuelve evidencia para el siguiente intento de ruta (si se necesita reroute).
  5. Si el intento previo tuvo needs_reroute, la policy no permite repetir el mismo target.
  6. Cuando el worker devuelve done, un paso LLM Finalize separado compone la respuesta final sin llamar workers.

El LLM devuelve intent (route JSON), tratado como input no confiable: el policy boundary primero lo valida y luego (si está permitido) llama workers. La allowlist se aplica dos veces: en route validation (invalid_route:route_not_allowed:*) y en ejecución (route_denied:*).

Así Routing se mantiene controlable: el agente elige al ejecutor y la ejecución pasa por una capa controlada.


Estructura del proyecto

TEXT
examples/
└── agent-patterns/
    └── routing-agent/
        └── python/
            ├── main.py           # Route -> Delegate -> (optional reroute) -> Finalize
            ├── llm.py            # router + final synthesis
            ├── gateway.py        # policy boundary: route validation + delegation control
            ├── tools.py          # deterministic specialists (billing/technical/sales)
            └── requirements.txt

Cómo ejecutar

BASH
git clone https://github.com/AgentPatterns-tech/agentpatterns.git
cd agentpatterns

cd examples/agent-patterns/routing-agent/python
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Se requiere Python 3.11+.

Opción con export:

BASH
export OPENAI_API_KEY="sk-..."
# optional:
# export OPENAI_MODEL="gpt-4.1-mini"
# export OPENAI_TIMEOUT_SECONDS="60"

python main.py
Opción con .env (opcional)
BASH
cat > .env <<'EOF_ENV'
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4.1-mini
OPENAI_TIMEOUT_SECONDS=60
EOF_ENV

set -a
source .env
set +a

python main.py

Esta es la variante de shell (macOS/Linux). En Windows es más fácil usar variables con set o, si quieres, python-dotenv para cargar .env automáticamente.


Tarea

Imagina que un usuario escribe a soporte:

"Me cobraron una suscripción hace 10 días. ¿Puedo obtener un reembolso?"

El agente no debe resolverlo por sí solo. Debe:

  • entender el tipo de solicitud (billing / technical / sales)
  • elegir al especialista correcto
  • delegar la tarea al worker
  • cambiar la ruta si hace falta (needs_reroute)
  • dar la respuesta final solo después del resultado del worker

Solución

Aquí el agente no "resuelve el caso" por sí mismo. Solo elige a quién delegar la solicitud.

  • el modelo indica a quién dirigir la solicitud
  • el sistema verifica que esa ruta esté permitida
  • el especialista (worker) realiza el trabajo
  • si la ruta no encaja, el agente elige otra
  • cuando hay un resultado listo, el agente compone la respuesta final
  • No ReAct: aquí no hacen falta muchos pasos/herramientas, hace falta una elección correcta del ejecutor.
  • No Orchestrator: aquí no hay subtareas en paralelo, hay una sola ruta de dominio para delegar.

Código

tools.py — workers especializados

PYTHON
from __future__ import annotations

from typing import Any

USERS = {
    42: {"id": 42, "name": "Anna", "country": "US", "tier": "pro"},
    7: {"id": 7, "name": "Max", "country": "US", "tier": "free"},
}

BILLING = {
    42: {
        "currency": "USD",
        "plan": "pro_monthly",
        "price_usd": 49.0,
        "days_since_first_payment": 10,
    },
    7: {
        "currency": "USD",
        "plan": "free",
        "price_usd": 0.0,
        "days_since_first_payment": 120,
    },
}


def _extract_user_id(ticket: str) -> int:
    if "user_id=7" in ticket:
        return 7
    return 42


def _contains_any(text: str, keywords: list[str]) -> bool:
    lowered = text.lower()
    return any(keyword in lowered for keyword in keywords)


def billing_specialist(ticket: str) -> dict[str, Any]:
    if not _contains_any(ticket, ["refund", "charge", "billing", "invoice"]):
        return {
            "status": "needs_reroute",
            "reason": "ticket_not_billing",
            "domain": "billing",
        }

    user_id = _extract_user_id(ticket)
    user = USERS.get(user_id)
    billing = BILLING.get(user_id)
    if not user or not billing:
        return {"status": "done", "domain": "billing", "error": "user_not_found"}

    is_refundable = (
        billing["plan"] == "pro_monthly" and billing["days_since_first_payment"] <= 14
    )
    refund_amount = billing["price_usd"] if is_refundable else 0.0

    return {
        "status": "done",
        "domain": "billing",
        "result": {
            "user_name": user["name"],
            "plan": billing["plan"],
            "currency": billing["currency"],
            "refund_eligible": is_refundable,
            "refund_amount_usd": refund_amount,
            "reason": "Pro monthly subscriptions are refundable within 14 days.",
        },
    }


def technical_specialist(ticket: str) -> dict[str, Any]:
    if not _contains_any(ticket, ["error", "bug", "incident", "api", "latency"]):
        return {
            "status": "needs_reroute",
            "reason": "ticket_not_technical",
            "domain": "technical",
        }

    return {
        "status": "done",
        "domain": "technical",
        "result": {
            "incident_id": "INC-4021",
            "service": "public-api",
            "state": "mitigated",
            "next_update_in_minutes": 30,
        },
    }


def sales_specialist(ticket: str) -> dict[str, Any]:
    if not _contains_any(ticket, ["price", "pricing", "quote", "plan", "discount"]):
        return {
            "status": "needs_reroute",
            "reason": "ticket_not_sales",
            "domain": "sales",
        }

    return {
        "status": "done",
        "domain": "sales",
        "result": {
            "recommended_plan": "team_plus",
            "currency": "USD",
            "monthly_price_usd": 199.0,
            "reason": "Best fit for teams that need priority support and usage controls.",
        },
    }

Qué es lo más importante aquí (en palabras simples)

  • Los workers son una execution layer determinística y no contienen lógica de LLM.
  • El router decide a quién llamar, pero no ejecuta por sí mismo la lógica de negocio del dominio.
  • needs_reroute da una señal segura para re-enrutamiento en lugar de un resultado "inventado".

gateway.py — policy boundary (la capa más importante)

PYTHON
from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from typing import Any, Callable


class StopRun(Exception):
    def __init__(self, reason: str):
        super().__init__(reason)
        self.reason = reason


@dataclass(frozen=True)
class Budget:
    max_route_attempts: int = 3
    max_delegations: int = 3
    max_seconds: int = 60


def _stable_json(value: Any) -> str:
    if value is None or isinstance(value, (bool, int, float, str)):
        return json.dumps(value, ensure_ascii=True, sort_keys=True)
    if isinstance(value, list):
        return "[" + ",".join(_stable_json(item) for item in value) + "]"
    if isinstance(value, dict):
        parts = []
        for key in sorted(value):
            parts.append(
                json.dumps(str(key), ensure_ascii=True) + ":" + _stable_json(value[key])
            )
        return "{" + ",".join(parts) + "}"
    return json.dumps(str(value), ensure_ascii=True)


def _normalize_for_hash(value: Any) -> Any:
    if isinstance(value, str):
        return " ".join(value.strip().split())
    if isinstance(value, list):
        return [_normalize_for_hash(item) for item in value]
    if isinstance(value, dict):
        return {str(key): _normalize_for_hash(value[key]) for key in sorted(value)}
    return value


def _normalize_ticket(value: str) -> str:
    return " ".join(value.strip().split())


def args_hash(args: dict[str, Any]) -> str:
    normalized = _normalize_for_hash(args or {})
    raw = _stable_json(normalized)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]


def validate_route_action(
    action: Any,
    *,
    allowed_routes: set[str],
    previous_target: str | None = None,
    previous_status: str | None = None,
) -> dict[str, Any]:
    if not isinstance(action, dict):
        raise StopRun("invalid_route:not_object")

    kind = action.get("kind")
    if kind == "invalid":
        raise StopRun("invalid_route:non_json")
    if kind != "route":
        raise StopRun("invalid_route:bad_kind")

    allowed_keys = {"kind", "target", "args"}
    if set(action.keys()) - allowed_keys:
        raise StopRun("invalid_route:extra_keys")

    target = action.get("target")
    if not isinstance(target, str) or not target.strip():
        raise StopRun("invalid_route:missing_target")
    target = target.strip()
    if target not in allowed_routes:
        raise StopRun(f"invalid_route:route_not_allowed:{target}")

    args = action.get("args", {})
    if args is None:
        args = {}
    if not isinstance(args, dict):
        raise StopRun("invalid_route:bad_args")
    ticket = args.get("ticket")
    if not isinstance(ticket, str) or not ticket.strip():
        raise StopRun("invalid_route:missing_ticket")
    ticket = _normalize_ticket(ticket)
    normalized_args = {**args, "ticket": ticket}

    if previous_status == "needs_reroute" and target == previous_target:
        raise StopRun("invalid_route:repeat_target_after_reroute")

    return {"kind": "route", "target": target, "args": normalized_args}


class RouteGateway:
    def __init__(
        self,
        *,
        allow: set[str],
        registry: dict[str, Callable[..., dict[str, Any]]],
        budget: Budget,
    ):
        self.allow = set(allow)
        self.registry = registry
        self.budget = budget
        self.delegations = 0
        self.seen_routes: set[str] = set()

    def call(self, target: str, args: dict[str, Any]) -> dict[str, Any]:
        self.delegations += 1
        if self.delegations > self.budget.max_delegations:
            raise StopRun("max_delegations")

        if target not in self.allow:
            raise StopRun(f"route_denied:{target}")

        worker = self.registry.get(target)
        if worker is None:
            raise StopRun(f"route_missing:{target}")

        signature = f"{target}:{args_hash(args)}"
        if signature in self.seen_routes:
            raise StopRun("loop_detected")
        self.seen_routes.add(signature)

        try:
            return worker(**args)
        except TypeError as exc:
            raise StopRun(f"route_bad_args:{target}") from exc
        except Exception as exc:
            raise StopRun(f"route_error:{target}") from exc

Qué es lo más importante aquí (en palabras simples)

  • validate_route_action(...) es la governance/control layer para la decisión de ruta del LLM.
  • La ruta se trata como input no confiable y pasa por validación estricta (ticket obligatorio, normalización de ticket, policy guard después de reroute).
  • RouteGateway.call(...) es el límite agent ≠ executor: el router decide la ruta, el gateway delega al worker de forma segura.
  • loop_detected detecta exact-repeat (target + args_hash), y args_hash normaliza espacios en argumentos string.

llm.py — routing decision + final synthesis

El LLM ve solo el catálogo de rutas disponibles; si una ruta no está en allowlist, el policy boundary detiene el run.

PYTHON
from __future__ import annotations

import json
import os
from typing import Any

from openai import APIConnectionError, APITimeoutError, OpenAI

MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
LLM_TIMEOUT_SECONDS = float(os.getenv("OPENAI_TIMEOUT_SECONDS", "60"))


class LLMTimeout(Exception):
    pass


class LLMEmpty(Exception):
    pass


ROUTER_SYSTEM_PROMPT = """
You are a routing decision engine.
Return only one JSON object in this exact shape:
{"kind":"route","target":"<route_name>","args":{"ticket":"..."}}

Rules:
- Choose exactly one target from available_routes.
- Never choose targets from forbidden_targets.
- Keep args minimal and valid for that target.
- If previous attempts failed with needs_reroute, choose a different target.
- Respect routing budgets and avoid unnecessary retries.
- Do not answer the user directly.
- Never output markdown or extra keys.
""".strip()

FINAL_SYSTEM_PROMPT = """
You are a support response assistant.
Write a short final answer in English for a US customer.
Use only evidence from delegated specialist observation.
Include: selected specialist, final decision, and one reason.
For billing refunds, include amount in USD when available.
""".strip()

ROUTE_CATALOG = [
    {
        "name": "billing_specialist",
        "description": "Handle refunds, charges, invoices, and billing policy",
        "args": {"ticket": "string"},
    },
    {
        "name": "technical_specialist",
        "description": "Handle errors, incidents, API issues, and outages",
        "args": {"ticket": "string"},
    },
    {
        "name": "sales_specialist",
        "description": "Handle pricing, plan recommendations, and quotes",
        "args": {"ticket": "string"},
    },
]


def _get_client() -> OpenAI:
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        raise EnvironmentError(
            "OPENAI_API_KEY is not set. Run: export OPENAI_API_KEY='sk-...'"
        )
    return OpenAI(api_key=api_key)


def _build_state_summary(history: list[dict[str, Any]]) -> dict[str, Any]:
    routes_used = [
        step.get("route", {}).get("target")
        for step in history
        if isinstance(step, dict)
        and isinstance(step.get("route"), dict)
        and step.get("route", {}).get("kind") == "route"
    ]
    routes_used_unique = list(dict.fromkeys(route for route in routes_used if route))
    last_route_target = routes_used[-1] if routes_used else None
    last_observation = history[-1].get("observation") if history else None
    last_observation_status = (
        last_observation.get("status") if isinstance(last_observation, dict) else None
    )
    return {
        "attempts_completed": len(history),
        "routes_used_unique": routes_used_unique,
        "last_route_target": last_route_target,
        "last_observation_status": last_observation_status,
        "last_observation": last_observation,
    }


def decide_route(
    goal: str,
    history: list[dict[str, Any]],
    *,
    max_route_attempts: int,
    remaining_attempts: int,
    forbidden_targets: list[str],
) -> dict[str, Any]:
    recent_history = history[-3:]
    payload = {
        "goal": goal,
        "budgets": {
            "max_route_attempts": max_route_attempts,
            "remaining_attempts": remaining_attempts,
        },
        "forbidden_targets": forbidden_targets,
        "state_summary": _build_state_summary(history),
        "recent_history": recent_history,
        "available_routes": ROUTE_CATALOG,
    }

    client = _get_client()
    try:
        completion = client.chat.completions.create(
            model=MODEL,
            temperature=0,
            timeout=LLM_TIMEOUT_SECONDS,
            response_format={"type": "json_object"},
            messages=[
                {"role": "system", "content": ROUTER_SYSTEM_PROMPT},
                {"role": "user", "content": json.dumps(payload, ensure_ascii=True)},
            ],
        )
    except (APITimeoutError, APIConnectionError) as exc:
        raise LLMTimeout("llm_timeout") from exc

    text = completion.choices[0].message.content or "{}"
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return {"kind": "invalid", "raw": text}


def compose_final_answer(
    goal: str, selected_route: str, history: list[dict[str, Any]]
) -> str:
    payload = {
        "goal": goal,
        "selected_route": selected_route,
        "history": history,
    }

    client = _get_client()
    try:
        completion = client.chat.completions.create(
            model=MODEL,
            temperature=0,
            timeout=LLM_TIMEOUT_SECONDS,
            messages=[
                {"role": "system", "content": FINAL_SYSTEM_PROMPT},
                {"role": "user", "content": json.dumps(payload, ensure_ascii=True)},
            ],
        )
    except (APITimeoutError, APIConnectionError) as exc:
        raise LLMTimeout("llm_timeout") from exc

    text = completion.choices[0].message.content or ""
    text = text.strip()
    if not text:
        raise LLMEmpty("llm_empty")
    return text

Qué es lo más importante aquí (en palabras simples)

  • decide_route(...) es la decision stage para elegir ejecutor.
  • Para estabilidad en producción, el prompt usa state_summary + recent_history + budgets, no todo el log crudo.
  • forbidden_targets da al LLM una prohibición explícita de repetir el mismo target tras needs_reroute.
  • state_summary estabiliza el routing con routes_used_unique, last_route_target, last_observation_status.
  • timeout=LLM_TIMEOUT_SECONDS y LLMTimeout permiten una parada controlada ante problemas de red/modelo.
  • Una respuesta final vacía no se enmascara con fallback: se devuelve llm_empty explícito.

main.py — Route -> Delegate -> Finalize

PYTHON
from __future__ import annotations

import json
import time
from typing import Any

from gateway import Budget, RouteGateway, StopRun, args_hash, validate_route_action
from llm import LLMEmpty, LLMTimeout, compose_final_answer, decide_route
from tools import billing_specialist, sales_specialist, technical_specialist

GOAL = (
    "User Anna (user_id=42) asks: Can I get a refund for my pro_monthly subscription "
    "charged 10 days ago? Route to the correct specialist and provide a short final answer."
)

BUDGET = Budget(max_route_attempts=3, max_delegations=3, max_seconds=60)

ROUTE_REGISTRY = {
    "billing_specialist": billing_specialist,
    "technical_specialist": technical_specialist,
    "sales_specialist": sales_specialist,
}

ALLOWED_ROUTE_TARGETS_POLICY = {
    "billing_specialist",
    "technical_specialist",
    "sales_specialist",
}

ALLOWED_ROUTE_TARGETS_EXECUTION = {
    "billing_specialist",
    "technical_specialist",
    "sales_specialist",
}


def run_routing(goal: str) -> dict[str, Any]:
    started = time.monotonic()
    trace: list[dict[str, Any]] = []
    history: list[dict[str, Any]] = []

    gateway = RouteGateway(
        allow=ALLOWED_ROUTE_TARGETS_EXECUTION,
        registry=ROUTE_REGISTRY,
        budget=BUDGET,
    )

    for attempt in range(1, BUDGET.max_route_attempts + 1):
        elapsed = time.monotonic() - started
        if elapsed > BUDGET.max_seconds:
            return {
                "status": "stopped",
                "stop_reason": "max_seconds",
                "trace": trace,
                "history": history,
            }

        previous_step = history[-1] if history else None
        previous_observation = (
            previous_step.get("observation")
            if isinstance(previous_step, dict)
            else None
        )
        previous_route = previous_step.get("route") if isinstance(previous_step, dict) else None
        previous_status = (
            previous_observation.get("status")
            if isinstance(previous_observation, dict)
            else None
        )
        previous_target = (
            previous_route.get("target")
            if isinstance(previous_route, dict)
            else None
        )
        forbidden_targets = (
            [previous_target]
            if previous_status == "needs_reroute" and isinstance(previous_target, str)
            else []
        )

        try:
            raw_route = decide_route(
                goal=goal,
                history=history,
                max_route_attempts=BUDGET.max_route_attempts,
                remaining_attempts=(BUDGET.max_route_attempts - attempt + 1),
                forbidden_targets=forbidden_targets,
            )
        except LLMTimeout:
            return {
                "status": "stopped",
                "stop_reason": "llm_timeout",
                "phase": "route",
                "trace": trace,
                "history": history,
            }

        try:
            route_action = validate_route_action(
                raw_route,
                allowed_routes=ALLOWED_ROUTE_TARGETS_POLICY,
                previous_target=previous_target,
                previous_status=previous_status,
            )
        except StopRun as exc:
            return {
                "status": "stopped",
                "stop_reason": exc.reason,
                "phase": "route",
                "raw_route": raw_route,
                "trace": trace,
                "history": history,
            }

        target = route_action["target"]
        route_args = route_action["args"]

        try:
            observation = gateway.call(target, route_args)
            trace.append(
                {
                    "attempt": attempt,
                    "target": target,
                    "args_hash": args_hash(route_args),
                    "ok": True,
                }
            )
        except StopRun as exc:
            trace.append(
                {
                    "attempt": attempt,
                    "target": target,
                    "args_hash": args_hash(route_args),
                    "ok": False,
                    "stop_reason": exc.reason,
                }
            )
            return {
                "status": "stopped",
                "stop_reason": exc.reason,
                "phase": "delegate",
                "route": route_action,
                "trace": trace,
                "history": history,
            }

        history.append(
            {
                "attempt": attempt,
                "route": route_action,
                "observation": observation,
            }
        )

        observation_status = observation.get("status")
        if trace:
            trace[-1]["observation_status"] = observation_status
            if isinstance(observation, dict) and observation.get("domain"):
                trace[-1]["domain"] = observation.get("domain")
        if observation_status == "needs_reroute":
            continue
        if observation_status != "done":
            return {
                "status": "stopped",
                "stop_reason": "route_bad_observation",
                "phase": "delegate",
                "route": route_action,
                "expected_statuses": ["needs_reroute", "done"],
                "received_status": observation_status,
                "bad_observation": observation,
                "trace": trace,
                "history": history,
            }

        try:
            answer = compose_final_answer(
                goal=goal,
                selected_route=target,
                history=history,
            )
        except LLMTimeout:
            return {
                "status": "stopped",
                "stop_reason": "llm_timeout",
                "phase": "finalize",
                "route": route_action,
                "trace": trace,
                "history": history,
            }
        except LLMEmpty:
            return {
                "status": "stopped",
                "stop_reason": "llm_empty",
                "phase": "finalize",
                "route": route_action,
                "trace": trace,
                "history": history,
            }

        return {
            "status": "ok",
            "stop_reason": "success",
            "selected_route": target,
            "answer": answer,
            "trace": trace,
            "history": history,
        }

    return {
        "status": "stopped",
        "stop_reason": "max_route_attempts",
        "trace": trace,
        "history": history,
    }


def main() -> None:
    result = run_routing(GOAL)
    print(json.dumps(result, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()

Qué es lo más importante aquí (en palabras simples)

  • run_routing(...) controla el ciclo completo Route -> Delegate -> Finalize.
  • El router (LLM) no ejecuta el trabajo: la execution la hace solo el worker a través de RouteGateway.
  • Si la ruta es inválida, se devuelve raw_route para depuración.
  • Si se necesita reroute, la policy no permite repetir el mismo target (invalid_route:repeat_target_after_reroute).
  • Para depuración, las respuestas de parada incluyen phase (route / delegate / finalize).
  • history registra de forma transparente decisiones de ruta y observation de cada intento.

requirements.txt

TEXT
openai==2.21.0

Ejemplo de salida

La ruta y el orden de intentos de ruta pueden variar entre ejecuciones, pero los policy gates y stop reasons se mantienen estables.

JSON
{
  "status": "ok",
  "stop_reason": "success",
  "selected_route": "billing_specialist",
  "answer": "The billing specialist reviewed your request and confirmed that your pro_monthly subscription charged 10 days ago is eligible for a refund. You will receive a refund of $49.00 because pro monthly subscriptions are refundable within 14 days.",
  "trace": [
    {
      "attempt": 1,
      "target": "billing_specialist",
      "args_hash": "5e89...",
      "ok": true,
      "observation_status": "done",
      "domain": "billing"
    }
  ],
  "history": [{...}]
}

Este es un ejemplo resumido: en una ejecución real, trace puede incluir varios intentos de ruta.

history es el registro de ejecución: para cada attempt hay route y observation.

args_hash es un hash de argumentos tras normalizar strings (trim + collapse spaces), por eso loop detection captura mejor repeticiones semánticamente iguales.


stop_reason típicos

  • success — ruta elegida, worker completado, respuesta final generada
  • invalid_route:* — el route JSON del LLM no pasó policy validation
  • invalid_route:non_json — el LLM no devolvió un route JSON válido
  • invalid_route:missing_ticket — route args no contiene el ticket obligatorio
  • invalid_route:route_not_allowed:<target> — ruta fuera de allowlist policy
  • invalid_route:repeat_target_after_reroute — tras needs_reroute se eligió de nuevo el mismo target
  • max_route_attempts — límite de intentos de reroute superado
  • max_delegations — límite de llamadas de delegation agotado
  • max_seconds — time budget del run superado
  • llm_timeout — el LLM no respondió dentro de OPENAI_TIMEOUT_SECONDS
  • llm_empty — el LLM devolvió una respuesta final vacía en finalize
  • route_denied:<target> — target bloqueado por execution allowlist
  • route_missing:<target> — target ausente en ROUTE_REGISTRY
  • route_bad_args:<target> — la ruta contiene argumentos inválidos
  • route_bad_observation — el worker devolvió una observation fuera de contrato (el resultado incluye expected_statuses, received_status, bad_observation)
  • loop_detected — exact repeat (target + args_hash)

En runs detenidos también se devuelve phase para ver rápido dónde se produjo la parada.


Qué NO se muestra aquí

  • No hay auth/PII ni controles de acceso de producción para datos personales.
  • No hay políticas de retry/backoff para LLM y execution layer.
  • No hay presupuestos por tokens/costo (cost guardrails).
  • Los workers aquí son mocks determinísticos para aprendizaje, no sistemas externos reales.

Qué probar después

  • Quita billing_specialist de ALLOWED_ROUTE_TARGETS_POLICY y verifica invalid_route:route_not_allowed:*.
  • Quita billing_specialist solo de ALLOWED_ROUTE_TARGETS_EXECUTION y verifica route_denied:*.
  • Agrega un target inexistente al route JSON y verifica route_missing:*.
  • Cambia GOAL a un incidente técnico y verifica el enrutamiento a technical_specialist.
  • Prueba una ruta sin ticket en args y verifica invalid_route:missing_ticket.

Código completo en GitHub

En el repositorio está la versión runnable completa de este ejemplo: route decision, policy boundary, delegation, fallback de reroute y stop reasons.

Ver código completo en GitHub ↗
⏱️ 16 min de lecturaActualizado Mar, 2026Dificultad: ★★☆
Integrado: control en producciónOnceOnly
Guardrails para agentes con tool-calling
Lleva este patrón a producción con gobernanza:
  • Presupuestos (pasos / topes de gasto)
  • Permisos de herramientas (allowlist / blocklist)
  • Kill switch y parada por incidente
  • Idempotencia y dedupe
  • Audit logs y trazabilidad
Mención integrada: OnceOnly es una capa de control para sistemas de agentes en producción.
Autor

Esta documentación está curada y mantenida por ingenieros que despliegan agentes de IA en producción.

El contenido es asistido por IA, con responsabilidad editorial humana sobre la exactitud, la claridad y la relevancia en producción.

Los patrones y las recomendaciones se basan en post-mortems, modos de fallo e incidentes operativos en sistemas desplegados, incluido durante el desarrollo y la operación de infraestructura de gobernanza para agentes en OnceOnly.