Essence du pattern (bref)
Routing Agent est un pattern où l’agent n’exécute pas la tâche directement, mais choisit le meilleur exécutant spécialisé selon le type de requête.
Le LLM prend la décision de routage, et l’exécution est faite uniquement par l’execution layer via la policy boundary.
Ce que cet exemple démontre
- étape
Routeséparée avant l’exécution - policy boundary entre décision de routage (LLM) et workers (execution layer)
- validation stricte de route-action (
kind,target,args, allowed keys) - allowlist (deny by default) pour le routage
- fallback via
needs_rerouteavec un nombre limité de tentatives - budgets de run :
max_route_attempts,max_delegations,max_seconds stop_reasonexplicites pour debug, alertes et monitoring productionraw_routedans la réponse si le LLM renvoie un route JSON invalide
Architecture
- Le LLM reçoit le goal et renvoie un route-intent en JSON (
kind="route",target,args). - La policy boundary valide la route comme input non fiable (avec
args.ticketobligatoire). - RouteGateway délègue la tâche au worker choisi (
allowlist, budgets, loop detection). - L’observation est ajoutée à
historyet devient une preuve pour la tentative de route suivante (si reroute nécessaire). - Si la tentative précédente avait
needs_reroute, la policy n’autorise pas de répéter le même target. - Quand le worker renvoie
done, une étape LLMFinalizeséparée produit la réponse finale sans appeler de workers.
Le LLM renvoie un intent (route JSON), traité comme input non fiable : la policy boundary le valide d’abord puis (si autorisé) appelle les workers.
L’allowlist est appliquée deux fois : en route validation (invalid_route:route_not_allowed:*) et en exécution (route_denied:*).
Ainsi Routing reste contrôlable : l’agent choisit l’exécutant, et l’exécution passe par une couche contrôlée.
Structure du projet
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
Lancer le projet
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
Python 3.11+ est requis.
Option via export :
export OPENAI_API_KEY="sk-..."
# optional:
# export OPENAI_MODEL="gpt-4.1-mini"
# export OPENAI_TIMEOUT_SECONDS="60"
python main.py
Option via .env (optionnel)
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
C’est la variante shell (macOS/Linux). Sur Windows, il est plus simple d’utiliser des variables set ou, si souhaité, python-dotenv pour charger .env automatiquement.
Tâche
Imagine qu’un utilisateur écrit au support :
"On m’a facturé un abonnement il y a 10 jours. Puis-je obtenir un remboursement ?"
L’agent ne doit pas décider cela lui-même. Il doit :
- comprendre le type de requête (
billing/technical/sales) - choisir le bon spécialiste
- déléguer la tâche au worker
- changer la route si nécessaire (
needs_reroute) - donner la réponse finale seulement après le résultat du worker
Solution
Ici, l’agent ne « résout » rien sur le fond lui-même. Il choisit seulement à qui transmettre la demande.
- le modèle indique vers qui router la demande
- le système vérifie que cette route est autorisée
- le spécialiste (worker) fait le travail
- si la route ne convient pas, l’agent en choisit une autre
- quand il y a un résultat prêt, l’agent compose la réponse finale
- Pas ReAct : ici, pas besoin de nombreux pas/outils, il faut un seul bon choix d’exécutant.
- Pas Orchestrator : ici, pas de sous-tâches parallèles, il y a une seule route de domaine pour déléguer.
Code
tools.py — workers spécialisés
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.",
},
}
Ce qui est le plus important ici (en mots simples)
- Les workers sont une execution layer déterministe et ne contiennent pas de logique LLM.
- Le router décide qui appeler, mais n’exécute pas lui-même la logique métier du domaine.
needs_reroutedonne un signal sûr de re-routage au lieu d’un résultat « inventé ».
gateway.py — policy boundary (la couche la plus importante)
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
Ce qui est le plus important ici (en mots simples)
validate_route_action(...)est la governance/control layer pour la décision de route du LLM.- La route est traitée comme input non fiable et passe une validation stricte (ticket obligatoire, normalisation de ticket, policy guard après reroute).
RouteGateway.call(...)est la frontièreagent ≠ executor: le router décide la route, le gateway délègue le worker en sécurité.loop_detecteddétecte les exact-repeat (target + args_hash), etargs_hashnormalise les espaces dans les arguments string.
llm.py — routing decision + final synthesis
Le LLM voit seulement le catalogue des routes disponibles ; si une route n’est pas dans l’allowlist, la policy boundary arrête le run.
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
Ce qui est le plus important ici (en mots simples)
decide_route(...)est la decision-stage pour choisir l’exécutant.- Pour une pratique production, le prompt contient
state_summary + recent_history + budgets, pas tout le log brut. forbidden_targetsdonne au LLM une interdiction explicite de répéter le même target aprèsneeds_reroute.state_summarystabilise le routage viaroutes_used_unique,last_route_target,last_observation_status.timeout=LLM_TIMEOUT_SECONDSetLLMTimeoutassurent un arrêt contrôlé en cas de problèmes réseau/modèle.- Une réponse finale vide n’est pas masquée par un fallback :
llm_emptyexplicite est renvoyé.
main.py — Route -> Delegate -> Finalize
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()
Ce qui est le plus important ici (en mots simples)
run_routing(...)pilote le cycle completRoute -> Delegate -> Finalize.- Le router (LLM) n’exécute pas le travail : l’execution est faite uniquement par le worker via
RouteGateway. - Si la route est invalide,
raw_routeest renvoyé pour le debug. - Si un reroute est nécessaire, la policy n’autorise pas de répéter le même target (
invalid_route:repeat_target_after_reroute). - Pour le debug, les réponses d’arrêt incluent
phase(route/delegate/finalize). historyenregistre de façon transparente les décisions de route et l’observation de chaque tentative.
requirements.txt
openai==2.21.0
Exemple de sortie
La route et l’ordre des tentatives peuvent varier entre runs, mais les policy-gates et stop reasons restent stables.
{
"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": [{...}]
}
Ceci est un exemple raccourci : dans un run réel, trace peut contenir plusieurs tentatives de route.
history est le journal d’exécution : pour chaque attempt, il y a route et observation.
args_hash est un hash des arguments après normalisation des strings (trim + collapse spaces), donc la loop detection capte plus robustement les répétitions sémantiquement identiques.
stop_reason typiques
success— route choisie, worker exécuté, réponse finale généréeinvalid_route:*— le route JSON du LLM n’a pas passé la policy validationinvalid_route:non_json— le LLM n’a pas renvoyé de route JSON valideinvalid_route:missing_ticket— les route args ne contiennent pas leticketobligatoireinvalid_route:route_not_allowed:<target>— route hors allowlist policyinvalid_route:repeat_target_after_reroute— aprèsneeds_reroute, le même target a été choisi de nouveaumax_route_attempts— limite de tentatives de reroute dépasséemax_delegations— limite des appels de delegation atteintemax_seconds— time budget du run dépasséllm_timeout— le LLM n’a pas répondu dansOPENAI_TIMEOUT_SECONDSllm_empty— le LLM a renvoyé une réponse finale vide au stadefinalizeroute_denied:<target>— target bloqué par execution allowlistroute_missing:<target>— target absent deROUTE_REGISTRYroute_bad_args:<target>— la route contient des arguments invalidesroute_bad_observation— le worker a renvoyé une observation hors contrat (le résultat contientexpected_statuses,received_status,bad_observation)loop_detected— exact repeat (target + args_hash)
Dans les runs arrêtés, phase est aussi renvoyé pour voir rapidement où l’arrêt s’est produit.
Ce qui N’est PAS montré ici
- Pas d’auth/PII ni de contrôles d’accès production aux données personnelles.
- Pas de politiques retry/backoff pour le LLM et l’execution layer.
- Pas de budgets token/coût (cost guardrails).
- Les workers ici sont des mocks déterministes pour l’apprentissage, pas de vrais systèmes externes.
Que tester ensuite
- Retire
billing_specialistdeALLOWED_ROUTE_TARGETS_POLICYet vérifieinvalid_route:route_not_allowed:*. - Retire
billing_specialistseulement deALLOWED_ROUTE_TARGETS_EXECUTIONet vérifieroute_denied:*. - Ajoute un target inexistant dans route JSON et vérifie
route_missing:*. - Change
GOALen incident technique et vérifie le routage verstechnical_specialist. - Essaie une route sans
ticketdansargset vérifieinvalid_route:missing_ticket.
Code complet sur GitHub
Le dépôt contient la version runnable complète de cet exemple : route decision, policy boundary, delegation, fallback de reroute et stop reasons.
Voir le code complet sur GitHub ↗