The High Cost of Unconstrained Agents in Regulated Finance
In digital banking, customer onboarding is a high-stakes workflow. A new user trying to open an account expects a seamless experience, but they also trigger strict KYC (Know Your Customer) and AML (Anti-Money Laundering) checks. When multi-agent RAG systems are deployed here without rigorous constraints, three failure modes become regulatory liabilities:
Infinite Loops: An identity verification agent rejects a document due to glare, asks the user to retake it, receives the same image, rejects it again, and enters a 20-turn loop that violates fair lending response-time SLAs.
Dead Ends: A compliance check fails silently because the conditional edge lacks a fallback path, leaving the applicant staring at a spinner while their session times out—creating a compliance gap where no decision was ever recorded.
Unnecessary Chatter: Three agents negotiate over whether a utility bill satisfies address proof, adding 8 seconds of latency and $0.04 of token cost per onboarding attempt, directly impacting conversion rates.
These aren’t engineering annoyances; they are conversion killers and audit failures. This article demonstrates battle-tested patterns for preventing all three, implemented in a production-grade LangGraph multi-agent RAG system for digital banking customer onboarding with full state management and persistent memory.
Real-Time Use Case: NeoBank Instant Account Opening
The Scenario
A popular digital bank (“NovaBank”) offers <5-minute account opening. The AI onboarding assistant must handle:
Document collection and validation (ID, selfie, proof of address)
KYC/AML screening against sanctions lists and PEP databases
Risk tier classification (low/medium/high) based on jurisdiction, occupation, and transaction intent
Regulatory disclosure delivery and e-signature capture
Human escalation for edge cases
The system integrates five live services: OCR/document validation API, sanctions screening API, core banking ledger, CRM, and compliance policy engine. Every interaction is audited for regulatory examination.
Why Naive Multi-Agent Fails in Onboarding
| Failure Mode | Business Impact | Root Cause |
|---|
| Loop: Doc reject → re-upload → same reject | Applicant abandons; 12% drop-off rate | No max-attempt counter; no alternative path |
| Dead End: Sanctions API timeout → no response | Application stuck; SLA breach; no audit record | Missing error-handling edges |
| Chatter: KYC ↔ Compliance ↔ Docs agents debating sufficiency | 45s avg latency vs. 15s target | Over-decomposition; no routing pre-filter |
| State Drift: Agent uses expired risk tier from prior step | Wrong disclosures shown; regulatory violation | No state freshness validation |
| Repeated Questions: Agent asks for SSN already in state | User frustration; trust erosion | No state-read-before-ask guard |
Architecture: Constrained Onboarding Graph with Guardrails
![395]()
Implementation
Step 1: State Design with Anti-Pattern Prevention Built In
State is your primary control surface. Every field prevents a specific failure mode.
from typing import Annotated, List, Dict, Any, Optional, Literal
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from datetime import datetime, timedelta
import operator
class DocumentAttempt(TypedDict):
"""Tracks each document submission attempt for loop prevention."""
doc_type: str # "government_id", "selfie", "proof_of_address"
attempt_number: int
status: Literal["accepted", "rejected", "pending"]
rejection_reason: Optional[str]
timestamp: datetime
class OnboardingState(TypedDict):
"""
State with built-in guardrails against loops, dead ends, and chatter.
Every field serves a specific anti-pattern prevention purpose.
"""
# Conversation history
messages: Annotated[list, add_messages]
# Immutable applicant context (set once, never overwritten)
applicant_context: Dict[str, Any]
# {"application_id": ..., "email": ..., "jurisdiction": ..., "product_type": ...}
# Classified current step (set by router, NEVER re-classified mid-flow)
current_step: Optional[Literal[
"doc_collection", "kyc_screening", "risk_classification",
"disclosure_delivery", "e_signature", "complete", "escalated"
]]
# Document tracking with attempt counters (LOOP PREVENTION)
document_attempts: Dict[str, DocumentAttempt]
max_doc_attempts: int # Hard ceiling per document type
# Execution metadata
execution_trace: Annotated[List[Dict[str, Any]], operator.add]
max_global_iterations: int
current_iteration: int
# Data freshness tracking (STALENESS PREVENTION)
data_freshness: Dict[str, datetime]
max_data_age_seconds: int
# Collected verified data (typed, not free-form)
verified_documents: Dict[str, Any] # {"government_id": {...}, "selfie": {...}}
kyc_result: Optional[Dict[str, Any]]
risk_tier: Optional[Literal["low", "medium", "high"]]
disclosures_accepted: bool
signature_captured: bool
# Error recovery state (DEAD END PREVENTION)
error_count: int
last_error: Optional[str]
recovery_attempts: int
max_recovery_attempts: int
# Final output
onboarding_status: Optional[Literal["approved", "rejected", "pending_review", "abandoned"]]
status_reason: Optional[str]
# Audit trail (REGULATORY REQUIREMENT)
audit_trail: Annotated[List[Dict[str, Any]], operator.add]
# Session metadata
session_id: str
application_id: str
processing_start_time: datetime
🔑 Key Insight: current_step is set once by the router and never re-evaluated during execution. This single design decision eliminates the most common source of infinite loops: agents disagreeing about what step the user is on and re-routing endlessly.
Step 2: Intent Router — Eliminate Chatter at Entry
The router classifies the user’s current need deterministically before any specialist agent runs.
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
class StepClassification(BaseModel):
step: Literal[
"doc_collection", "kyc_screening", "risk_classification",
"disclosure_delivery", "e_signature", "clarification_needed"
]
confidence: float = Field(ge=0.0, le=1.0)
extracted_data: Dict[str, Any] = Field(default_factory=dict)
reasoning: str
router_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0).with_structured_output(StepClassification)
ROUTER_PROMPT = """You are NovaBank's onboarding intent classifier. Determine the CURRENT onboarding step.
Available steps:
- doc_collection: User needs to submit/re-submit ID, selfie, or proof of address
- kyc_screening: Documents accepted; ready for sanctions/PEP screening
- risk_classification: KYC passed; assign risk tier based on profile
- disclosure_delivery: Risk tier assigned; deliver required regulatory disclosures
- e_signature: Disclosures accepted; capture electronic signature
- clarification_needed: User's message is ambiguous or missing required info
RULES:
1. Check completed_steps in context FIRST. Never route to a completed step.
2. If documents are pending, ALWAYS route to doc_collection regardless of user question.
3. Be aggressive about doc_collection routing—incomplete docs block everything else.
4. Only classify as clarification_needed if truly ambiguous."""
async def router_node(state: OnboardingState) -> dict:
"""
Single-pass step classification. Sets current_step ONCE.
Prevents chatter from agents re-negotiating workflow position.
"""
applicant = state.get("applicant_context", {})
completed = {
"docs_complete": len(state.get("verified_documents", {})) >= 3,
"kyc_complete": state.get("kyc_result") is not None,
"risk_assigned": state.get("risk_tier") is not None,
"disclosures_accepted": state.get("disclosures_accepted", False),
"signature_captured": state.get("signature_captured", False)
}
# DETERMINISTIC PRE-FILTER: Skip LLM when next step is obvious
if not completed["docs_complete"]:
return _build_router_result("doc_collection", 1.0, "Documents incomplete", {}, state)
if not completed["kyc_complete"]:
return _build_router_result("kyc_screening", 1.0, "KYC pending", {}, state)
if not completed["risk_assigned"]:
return _build_router_result("risk_classification", 1.0, "Risk tier unassigned", {}, state)
if not completed["disclosures_accepted"]:
return _build_router_result("disclosure_delivery", 1.0, "Disclosures pending", {}, state)
if not completed["signature_captured"]:
return _build_router_result("e_signature", 1.0, "Signature pending", {}, state)
# All steps complete
return _build_router_result("complete", 1.0, "Onboarding complete", {}, state)
def _build_router_result(step, confidence, reasoning, extracted, state):
return {
"current_step": step,
"execution_trace": [{
"node": "router",
"step": step,
"confidence": confidence,
"reasoning": reasoning,
"timestamp": datetime.utcnow().isoformat()
}],
"current_iteration": 0,
"max_global_iterations": 10,
"max_doc_attempts": 3,
"max_recovery_attempts": 2,
"max_data_age_seconds": 300,
"error_count": 0,
"recovery_attempts": 0
}
Step 3: Bounded Document Collection Node — Loop Prevention
This is where most onboarding systems fail. We enforce hard limits and provide alternative paths.
async def doc_collection_node(state: OnboardingState) -> dict:
"""
Handles document upload/validation with STRICT loop prevention.
- Max attempts per document type
- Alternative verification paths after failures
- Structured rejection responses (not generic errors)
"""
attempts = state.get("document_attempts", {})
max_attempts = state.get("max_doc_attempts", 3)
iteration = state.get("current_iteration", 0)
# GLOBAL ITERATION CEILING
if iteration >= state.get("max_global_iterations", 10):
return {
"onboarding_status": "pending_review",
"status_reason": "Maximum processing iterations reached. Manual review required.",
"execution_trace": [{"node": "doc_collection", "status": "MAX_ITERATIONS", "iteration": iteration}],
"audit_trail": [{"event": "max_iterations_reached", "step": "doc_collection", "timestamp": datetime.utcnow().isoformat()}]
}
# Determine which document is needed
verified = state.get("verified_documents", {})
needed_docs = []
for doc_type in ["government_id", "selfie", "proof_of_address"]:
if doc_type not in verified:
needed_docs.append(doc_type)
if not needed_docs:
return {
"current_step": "kyc_screening",
"execution_trace": [{"node": "doc_collection", "status": "ALL_DOCS_COMPLETE"}]
}
current_doc = needed_docs[0]
current_attempt = attempts.get(current_doc, {}).get("attempt_number", 0)
# PER-DOCUMENT ATTEMPT CEILING
if current_attempt >= max_attempts:
# ESCALATE instead of looping
return {
"current_step": "escalated",
"onboarding_status": "pending_review",
"status_reason": f"{current_doc} failed validation {max_attempts} times. Manual verification required.",
"document_attempts": {
current_doc: {
"doc_type": current_doc,
"attempt_number": current_attempt,
"status": "rejected",
"rejection_reason": "max_attempts_exceeded",
"timestamp": datetime.utcnow()
}
},
"execution_trace": [{"node": "doc_collection", "status": "DOC_MAX_ATTEMPTS", "doc": current_doc}],
"audit_trail": [{
"event": "document_max_attempts",
"doc_type": current_doc,
"attempts": current_attempt,
"timestamp": datetime.utcnow().isoformat()
}]
}
# Request document from user (structured prompt, not open-ended)
doc_requirements = {
"government_id": "Clear photo of front and back of government-issued ID. No glare, all corners visible.",
"selfie": "Front-facing selfie matching your ID photo. Neutral background, no sunglasses.",
"proof_of_address": "Utility bill or bank statement dated within 90 days. Full document visible."
}
return {
"messages": [{
"role": "assistant",
"content": f"Please upload your {current_doc.replace('_', ' ')}. "
f"{doc_requirements[current_doc]} "
f"(Attempt {current_attempt + 1} of {max_attempts})"
}],
"current_iteration": iteration + 1,
"execution_trace": [{
"node": "doc_collection",
"status": "awaiting_upload",
"doc_type": current_doc,
"attempt": current_attempt + 1,
"timestamp": datetime.utcnow().isoformat()
}]
}
async def process_document_upload(state: OnboardingState, uploaded_doc: Dict) -> dict:
"""
Validates uploaded document via OCR API.
Returns structured acceptance/rejection with specific feedback.
NEVER returns generic errors that cause retry loops.
"""
doc_type = uploaded_doc["doc_type"]
attempts = state.get("document_attempts", {})
current_attempt = attempts.get(doc_type, {}).get("attempt_number", 0) + 1
# Call OCR/validation API
validation = await validate_document_api(uploaded_doc)
if validation["status"] == "accepted":
return {
"verified_documents": {doc_type: validation["extracted_data"]},
"document_attempts": {
doc_type: {
"doc_type": doc_type,
"attempt_number": current_attempt,
"status": "accepted",
"rejection_reason": None,
"timestamp": datetime.utcnow()
}
},
"data_freshness": {doc_type: datetime.utcnow()},
"execution_trace": [{"node": "process_upload", "status": "accepted", "doc": doc_type}],
"audit_trail": [{
"event": "document_accepted",
"doc_type": doc_type,
"attempt": current_attempt,
"timestamp": datetime.utcnow().isoformat()
}]
}
else:
# SPECIFIC rejection reason prevents vague retry loops
return {
"document_attempts": {
doc_type: {
"doc_type": doc_type,
"attempt_number": current_attempt,
"status": "rejected",
"rejection_reason": validation["rejection_reason"],
"timestamp": datetime.utcnow()
}
},
"messages": [{
"role": "assistant",
"content": f"Document rejected: {validation['rejection_reason']}. "
f"Please correct this specific issue and re-upload."
}],
"execution_trace": [{"node": "process_upload", "status": "rejected", "reason": validation["rejection_reason"]}],
"audit_trail": [{
"event": "document_rejected",
"doc_type": doc_type,
"attempt": current_attempt,
"reason": validation["rejection_reason"],
"timestamp": datetime.utcnow().isoformat()
}]
}
Step 4: KYC Screening Node — Dead End Prevention
Every external API call has explicit error handling with fallback paths.
async def kyc_screening_node(state: OnboardingState) -> dict:
"""
Runs sanctions/PEP screening with DEAD END PREVENTION.
- Timeout handling
- API failure fallback
- Guaranteed audit record even on failure
"""
verified_docs = state.get("verified_documents", {})
iteration = state.get("current_iteration", 0)
try:
# Call sanctions screening API with timeout
screening_result = await screen_applicant_api(
name=verified_docs["government_id"]["full_name"],
dob=verified_docs["government_id"]["date_of_birth"],
country=state["applicant_context"]["jurisdiction"],
timeout_seconds=10
)
return {
"kyc_result": screening_result,
"data_freshness": {"kyc_screening": datetime.utcnow()},
"current_step": "risk_classification",
"current_iteration": iteration + 1,
"execution_trace": [{"node": "kyc_screening", "status": "success", "match": screening_result.get("match_found")}],
"audit_trail": [{
"event": "kyc_screening_complete",
"match_found": screening_result.get("match_found", False),
"timestamp": datetime.utcnow().isoformat()
}]
}
except TimeoutError:
# DEAD END PREVENTION: Never hang; escalate with audit record
return {
"current_step": "escalated",
"onboarding_status": "pending_review",
"status_reason": "KYC screening API timed out. Manual screening required.",
"error_count": state.get("error_count", 0) + 1,
"last_error": "kyc_api_timeout",
"execution_trace": [{"node": "kyc_screening", "status": "timeout"}],
"audit_trail": [{
"event": "kyc_screening_timeout",
"action": "escalated_to_manual",
"timestamp": datetime.utcnow().isoformat()
}]
}
except Exception as e:
# CATCH-ALL: Every failure path produces an audit record
error_count = state.get("error_count", 0) + 1
if error_count >= state.get("max_recovery_attempts", 2):
return {
"current_step": "escalated",
"onboarding_status": "pending_review",
"status_reason": f"KYC screening failed after {error_count} attempts. Manual review required.",
"error_count": error_count,
"last_error": str(e),
"execution_trace": [{"node": "kyc_screening", "status": "failed", "error": str(e)}],
"audit_trail": [{
"event": "kyc_screening_failure",
"error": str(e),
"attempts": error_count,
"action": "escalated",
"timestamp": datetime.utcnow().isoformat()
}]
}
# Retry once before escalating
return {
"error_count": error_count,
"last_error": str(e),
"recovery_attempts": state.get("recovery_attempts", 0) + 1,
"current_iteration": iteration + 1,
"execution_trace": [{"node": "kyc_screening", "status": "retry", "error": str(e)}],
"audit_trail": [{
"event": "kyc_screening_retry",
"error": str(e),
"attempt": error_count,
"timestamp": datetime.utcnow().isoformat()
}]
}
Step 5: State Validator — Catch Failures Before Users See Them
Every path terminates at the validator. It ensures completeness, freshness, and detects loops.
async def state_validator_node(state: OnboardingState) -> dict:
"""
Final gatekeeper. Validates state completeness and detects anti-patterns.
Routes to recovery or escalation instead of returning broken states.
"""
step = state.get("current_step")
iteration = state.get("current_iteration", 0)
max_iter = state.get("max_global_iterations", 10)
# LOOP DETECTION: Check for repeated node sequences in trace
trace_nodes = [t["node"] for t in state.get("execution_trace", [])]
if len(trace_nodes) >= 6:
for window in range(2, len(trace_nodes) // 2 + 1):
for i in range(len(trace_nodes) - window * 2):
seq = tuple(trace_nodes[i:i+window])
next_seq = tuple(trace_nodes[i+window:i+window*2])
if seq == next_seq:
return {
"current_step": "escalated",
"onboarding_status": "pending_review",
"status_reason": "Processing loop detected. Manual review required.",
"execution_trace": [{"node": "validator", "status": "LOOP_DETECTED", "pattern": list(seq)}],
"audit_trail": [{"event": "loop_detected", "pattern": list(seq), "timestamp": datetime.utcnow().isoformat()}]
}
# GLOBAL ITERATION CEILING
if iteration >= max_iter:
return {
"current_step": "escalated",
"onboarding_status": "pending_review",
"status_reason": "Maximum iterations reached. Manual review required.",
"execution_trace": [{"node": "validator", "status": "MAX_ITERATIONS"}],
"audit_trail": [{"event": "max_iterations_validator", "iterations": iteration, "timestamp": datetime.utcnow().isoformat()}]
}
# STALENESS CHECK
freshness = state.get("data_freshness", {})
max_age = state.get("max_data_age_seconds", 300)
stale_sources = [
src for src, ts in freshness.items()
if (datetime.utcnow() - ts).total_seconds() > max_age
]
if stale_sources and step not in ("escalated", "complete"):
return {
"execution_trace": [{"node": "validator", "status": "STALE_DATA", "sources": stale_sources}],
"audit_trail": [{"event": "stale_data_detected", "sources": stale_sources, "timestamp": datetime.utcnow().isoformat()}]
# Don't fail; just log. Freshness is informational for onboarding.
}
# STEP COMPLETENESS CHECK
if step == "kyc_screening" and not state.get("verified_documents"):
return {
"current_step": "doc_collection",
"execution_trace": [{"node": "validator", "status": "REDIRECT_TO_DOCS"}],
"audit_trail": [{"event": "redirect_missing_docs", "timestamp": datetime.utcnow().isoformat()}]
}
# State is valid
return {
"execution_trace": [{"node": "validator", "status": "PASSED", "step": step}]
}
def route_after_validation(state: OnboardingState) -> str:
"""Deterministic routing. No LLM involved."""
step = state.get("current_step")
if step == "escalated":
return "human_escalation"
if step == "complete":
return "finalize_approved"
if step == "doc_collection":
return "doc_collection"
if step == "kyc_screening":
return "kyc_screening"
if step == "risk_classification":
return "risk_classification"
if step == "disclosure_delivery":
return "disclosure_delivery"
if step == "e_signature":
return "e_signature"
# Safety fallback: never orphan
return "human_escalation"
Step 6: Assemble the Constrained Graph
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres import PostgresSaver
workflow = StateGraph(OnboardingState)
# Add nodes
workflow.add_node("router", router_node)
workflow.add_node("doc_collection", doc_collection_node)
workflow.add_node("kyc_screening", kyc_screening_node)
workflow.add_node("risk_classification", risk_classification_node) # Similar bounded pattern
workflow.add_node("disclosure_delivery", disclosure_node)
workflow.add_node("e_signature", signature_node)
workflow.add_node("state_validator", state_validator_node)
# Terminal nodes
workflow.add_node("finalize_approved", lambda s: {
"onboarding_status": "approved",
"execution_trace": [{"node": "finalize", "status": "approved"}],
"audit_trail": [{"event": "onboarding_approved", "timestamp": datetime.utcnow().isoformat()}]
})
workflow.add_node("human_escalation", lambda s: {
"onboarding_status": "pending_review",
"execution_trace": [{"node": "escalation", "status": "queued"}],
"audit_trail": [{"event": "human_escalation", "reason": s.get("status_reason"), "timestamp": datetime.utcnow().isoformat()}]
})
# Edges
workflow.add_edge(START, "router")
# Router → deterministic step routing
workflow.add_conditional_edges("router", lambda s: s.get("current_step", "escalated"), {
"doc_collection": "doc_collection",
"kyc_screening": "kyc_screening",
"risk_classification": "risk_classification",
"disclosure_delivery": "disclosure_delivery",
"e_signature": "e_signature",
"complete": "state_validator",
"escalated": "human_escalation",
"clarification_needed": "doc_collection" # Default safe fallback
})
# All operational nodes → validator
workflow.add_edge("doc_collection", "state_validator")
workflow.add_edge("kyc_screening", "state_validator")
workflow.add_edge("risk_classification", "state_validator")
workflow.add_edge("disclosure_delivery", "state_validator")
workflow.add_edge("e_signature", "state_validator")
# Validator → deterministic routing
workflow.add_conditional_edges("state_validator", route_after_validation, {
"doc_collection": "doc_collection",
"kyc_screening": "kyc_screening",
"risk_classification": "risk_classification",
"disclosure_delivery": "disclosure_delivery",
"e_signature": "e_signature",
"finalize_approved": "finalize_approved",
"human_escalation": "human_escalation"
})
# Terminal edges
workflow.add_edge("finalize_approved", END)
workflow.add_edge("human_escalation", END)
# Compile with PostgreSQL checkpointing for regulatory audit
checkpointer = PostgresSaver.from_conn_string("postgresql://novabank-onboarding-db")
app = workflow.compile(checkpointer=checkpointer)
Step 7: Execute with Full Observability
config = {"configurable": {"thread_id": "nova-app-NB-2024-884721"}}
result = await app.ainvoke({
"applicant_context": {
"application_id": "NB-2024-884721",
"email": "[email protected]",
"jurisdiction": "US",
"product_type": "checking_account"
},
"messages": [{"role": "user", "content": "I want to open a checking account"}],
"document_attempts": {},
"verified_documents": {},
"kyc_result": None,
"risk_tier": None,
"disclosures_accepted": False,
"signature_captured": False,
"data_freshness": {},
"execution_trace": [],
"audit_trail": [],
"session_id": "sess-nb-20240805-143022",
"application_id": "NB-2024-884721",
"processing_start_time": datetime.utcnow(),
"onboarding_status": None,
"status_reason": None,
"current_step": None,
"current_iteration": 0,
"error_count": 0,
"recovery_attempts": 0
}, config=config)
print(f"Status: {result['onboarding_status']}")
print(f"Current Step: {result['current_step']}")
print(f"Iterations: {result['current_iteration']}")
print(f"Doc Attempts: {result['document_attempts']}")
print(f"Trace: {[t['node'] + ':' + t.get('status','') for t in result['execution_trace']]}")
print(f"Audit Events: {[a['event'] for a in result['audit_trail']]}")
Anti-Pattern Prevention Cheat Sheet
| Failure Mode | Prevention Pattern | Where Enforced |
|---|
| Infinite Doc Loop | max_doc_attempts per document + escalation path | doc_collection_node |
| Global Loop | max_global_iterations ceiling + sequence detection | Validator + every node |
| Re-Routing Loop | current_step set ONCE at router; never re-evaluated | Router immutability |
| Dead End (API Fail) | Try/except with escalation + guaranteed audit record | kyc_screening_node |
| Silent Failure | Validator checks completeness; redirects or escalates | state_validator_node |
| Unnecessary Chatter | Deterministic pre-filter in router; skip LLM when obvious | router_node |
| LLM Routing Indecision | All conditional edges are Python functions | Edge routing functions |
| Stale Data Usage | data_freshness timestamps + validator staleness check | State + Validator |
| Repeated Questions | State-read-before-ask guard in doc collection | doc_collection_node |
| Missing Audit Record | Every code path writes to audit_trail | All nodes |
Production Metrics for Digital Banking Onboarding
metrics = {
"avg_iterations_per_onboarding": 4.2, # Target: ≤6
"loop_detection_rate": 0.05, # Target: <0.1%
"doc_rejection_rate_attempt_1": 0.18, # Industry benchmark: 15-25%
"doc_rejection_rate_attempt_3": 0.03, # Should drop significantly
"escalation_rate": 0.08, # Target: <10%
"dead_end_rate": 0.001, # Target: <0.1%
"p95_latency_seconds": 12, # Target: <15s
"audit_completeness": 1.0, # MUST be 100%
"conversion_rate": 0.72 # Target: >70%
}
Conclusion
Preventing loops, dead ends, and chatter in digital banking onboarding isn't about smarter agents—it's about dumber, more constrained graphs. The patterns demonstrated here share a common philosophy:
Classify once, execute deterministically. Never let agents renegotiate workflow position.
Bound everything. Document attempts, global iterations, recovery retries—all have hard ceilings with escalation paths.
Validate at every exit. Every path ends at a validator that catches failures before users see them.
Route with code, not LLMs. Conditional edges are Python functions, not model calls.
Audit everything, always. Every code path—success, failure, escalation—writes an immutable audit record.
In digital banking, a loop isn't wasted tokens—it's a violated fair lending SLA. A dead end isn't a UX bug—it's a compliance gap. Unnecessary chatter isn't latency—it's conversion revenue lost. The constraints aren't engineering overhead; they are regulatory requirements encoded in graph topology.