Chapter 8: Defending Against Agent Looping: Setting Retry Budgets and Abuse Throttles
When an autonomous agent in your enterprise application encounters a policy denial on an informational or read-only tool (such as parameter rejections triggered by CEL rules at tool PEP boundaries, as detailed in Chapter 7: Guarding Tools and Enterprise APIs), modern LLMs frequently attempt to recover by modifying arguments, retrying alternative tools, or probing boundary values. Without active defenses, this behavior can degenerate into a Recursive Retry Loop (an execution cycle where an agent repeatedly retries failed actions), resulting in rapid token exhaustion, elevated API costs, or adversarial parameter fuzzing. Defending against agent looping requires bounding retries at two distinct operational scopes: an invocation-scoped retry tracker to cap consecutive denials within a single turn, and a sliding-window abuse throttle to detect distributed parameter scanning across multiple conversation turns.
In this chapter, we use the incident response assistant (langchain-enterprise-incident-assistant) as a concrete demonstrator to show how to configure intelligent retry budgets and sliding-window abuse throttles in your enterprise LLM applications using langchain-security-extension. We examine how to configure tracking components, wrap agent workflows with SecureRunnable error boundaries, enforce zero-token cross-turn throttles, and wire declarative fallback builders that safely catch policy aborts.
The Anatomy of an Agent Denial Loop
Unbounded tool retry cycles present two severe risks to enterprise infrastructure:
- Token and Cost Exhaustion: When an agent receives synthetic denial feedback, it often regenerates tool calls with minor prompt permutations until hitting framework-level iteration limits, burning model context windows and incurring unnecessary inference latency.
- Policy Brute-Force Scanning: Malicious or jailbroken prompts direct an agent to systematically scan parameter spaces (e.g., iterating server names from
srv-billing-01tosrv-billing-20) across turns to discover which enterprise assets lack strict authorization rules.
Framework Iteration Limits vs. Semantic PEP Retry Budgets: Unlike standard framework iteration limits (such as LangChain's max_iterations=15 or LangGraph's recursion_limit), which blindly cycle and burn tokens until an arbitrary execution ceiling, the Semantic PEP Retry Budget acts as an intelligent security circuit-breaker that halts execution immediately after consecutive policy denials or unauthorized mutative attempts.
To mitigate both threats, the langchain-security-extension library introduces a dual-layer tracking architecture managed by ToolViolationHandler within the RunnableConfig lifecycle.
Dual-Layer Anti-Loop Architecture
The defense model decouples turn-level retry accounting from caller-level abuse detection:
- Invocation-Scoped Retry Budget (
RetryBudgetTracker): Tracks consecutive tool feedback refusals within the active invocation turn usingRunnableConfig["configurable"]["sec:retry_count"]. When denials exceedmax_feedback_violations(default:2), the budget is exhausted. The next denial triggers an immediate hard abort and logsEVENT_POLICY_BRUTE_FORCE_DETECTED. - Cross-Turn Sliding-Window Abuse Throttling (
AbuseThrottleTracker): Tracks timestamped policy denial history per subject ID across a configurable rolling window (e.g.,window_seconds = 60.0,max_denials = 3). If a caller repeatedly triggers policy denials across distinct conversation turns, the tracker marks the subject as throttled, aborts subsequent requests, and emits a high-severityEVENT_POLICY_BRUTE_FORCE_SCANNINGSIEM alert. - Structured Resolution Dispatch (
ToolViolationHandler): Evaluates policy decisions against both trackers, returning a structuredViolationResolutionobject that dictates whether execution should continue with synthetic feedback or terminate immediately.
Configuring Retry Budgets and Abuse Throttles in the Security Library
from langchain_security_extension.security_adapter_langchain.violation_handler import (
ToolViolationHandler,
RetryBudgetTracker,
AbuseThrottleTracker,
)
# Option 1: Standard defaults (2 feedback retries per turn, 3 denials / 60s sliding abuse window)
violation_handler = ToolViolationHandler.default_handler(pdp_engine)
# Option 2: Config-driven initialization with application properties
retry_tracker = RetryBudgetTracker.create(max_feedback_violations=config.retry_max_violations)
abuse_tracker = AbuseThrottleTracker.create(
window_seconds=config.abuse_window_seconds,
max_denials=config.abuse_max_denials,
)
violation_handler = ToolViolationHandler.create(
pdp_engine=pdp_engine,
retry_tracker=retry_tracker,
abuse_tracker=abuse_tracker,
)
Explanation: The library allows applications to construct ToolViolationHandler either using out-of-the-box defaults or customized with application configuration. The handler coordinates both trackers during each tool violation evaluation.
Parameter Privilege Escalation Detection
In addition to tallying raw attempts, the interceptor inspects tool arguments across consecutive denials. By snapshotting invocation arguments in RunnableConfig["configurable"]["sec:prev_args"], the guard detects parameter mutations (such as an agent scanning alternate server identifiers, user IDs, or departments). When arguments change between rejected attempts, the interceptor immediately publishes a high-severity EVENT_PARAMETER_PRIVILEGE_ESCALATION security alert to enterprise SIEM telemetry.
Per-User Request Isolation in RunnableConfig
Crucially, retry budgets are isolated strictly per user request invocation. Because retry state is stored in-band within the active request's RunnableConfig["configurable"]["sec:retry_count"] rather than in a shared or static global variable, concurrent requests from different users maintain independent retry budgets. User Alice receiving two synthetic denials on her ticket run will not deplete or impact the retry quota of User Bob submitting requests concurrently, ensuring each user receives their fair, unpolluted recovery attempts.
Furthermore, because retry state is stored within RunnableConfig, invoking SecureRunnable.handoff(self.secured_workflow, context, run_config) ensures that accumulated violation counts and retry budgets persist across multi-agent handoffs (e.g., from a Triage Agent to a Remediation Agent) throughout the entire user request pipeline unless explicitly reset.
PDP Cache Invalidation Callback
When enterprise security policies or role assignments update dynamically in an external Policy Decision Point (such as Open Policy Agent), cached decisions and abuse throttles must not penalize newly authorized subjects. ToolViolationHandler automatically hooks into the PDP engine lifecycle via pdp_engine.register_invalidation_callback(self.reset_abuse). Whenever the PDP flushes or invalidates policy bundles, abuse trackers reset state, ensuring legitimate operations proceed without stale throttling.
Escalation Thresholds and Threat Classifications
The policy enforcement lifecycle evaluates every tool violation against strict thresholds, mapping execution anomalies to standardized threat classifications:
| Violation Pattern | Threshold Trigger | Mitigation Action | Audit Threat Classification |
|---|---|---|---|
| Isolated Informational Denial | Feedback count ≤ max_feedback_violations (2) |
Returns synthetic refusal envelope to model context; allows recovery attempt | TOOL_FEEDBACK_VIOLATION (Notice) |
| Mutative Tool Denial | Any unauthorized write/action operation | Immediate hard abort; agent execution terminated without retries | TOOL_HARD_ABORT_TRIGGERED (Security Alert) |
| Turn-Level Retry Budget Breach | Consecutive feedback count > max_feedback_violations (2) |
Hard Abort: Escalates to hard stop, terminating agent loop immediately | EVENT_POLICY_BRUTE_FORCE_DETECTED (Critical Alert) |
| Parameter Fuzzing / Mutation | Arguments mutated between consecutive denials (prev_args != bound_args) |
Emits alert; continues tracking towards turn cap or hard abort | EVENT_PARAMETER_PRIVILEGE_ESCALATION (Security Alert) |
| Sliding-Window Denial Flood | Denial count ≥ max_denials (3) within 60-second window |
Abuse Throttling: Immediately terminates turn and blocks cross-turn scans | EVENT_POLICY_BRUTE_FORCE_SCANNING (Critical Alert) |
Code Hardening: Orchestrating Anti-Loop Defenses in Your Enterprise Agents
Once tools in your enterprise application declare their mutability contracts and CEL parameter constraints with @secured_tool (as detailed in Chapter 7: Guarding Tools and Enterprise APIs), informational denials return soft refusals to the model. To prevent the model from cycling indefinitely or brute-forcing parameters, your application's agent orchestrators must configure violation handlers, track retry budgets, and wire fallback error boundaries. In the reference assistant, this defense is orchestrated across six focused steps:
Step 1: Externalizing Retry Budgets and Abuse Windows in Configuration
In your enterprise application, avoid hardcoding security thresholds. Bind configurable properties to allow operational adjustments across deployment environments:
app/config.py
@@ -21,6 +21,10 @@ DEFAULT_CACHE_L2_MAX_ENTRIES = 1000
DEFAULT_INGESTION_MODE = "STRICT_FAIL_CLOSED"
+DEFAULT_RETRY_MAX_VIOLATIONS = 3
+DEFAULT_ABUSE_WINDOW_SECONDS = 60.0
+DEFAULT_ABUSE_MAX_DENIALS = 3
+
DEFAULT_SPLUNK_HEC_URL = ""
@@ -190,9 +194,18 @@ class AppConfig:
def ingestion_mode(self) -> str:
return self.get_property("security.ingestion.mode", DEFAULT_INGESTION_MODE)
+ @property
+ def retry_max_violations(self) -> int:
+ return int(self.get_property("security.retry.maxViolations", str(DEFAULT_RETRY_MAX_VIOLATIONS)))
+
+ @property
+ def abuse_window_seconds(self) -> float:
+ return float(self.get_property("security.abuse.windowSeconds", str(DEFAULT_ABUSE_WINDOW_SECONDS)))
+
+ @property
+ def abuse_max_denials(self) -> int:
+ return int(self.get_property("security.abuse.maxDenials", str(DEFAULT_ABUSE_MAX_DENIALS)))
Explanation: The application exposes single-turn retry limits (default: 3 feedback retries before abort) and cross-turn sliding windows (default: max 3 denials in 60 seconds). Operators can tune these settings via properties files or environment variables without code redeployment.
Step 2: Initializing Trackers and ToolViolationHandler in Agent Setup
During agent initialization, instantiate the retry and abuse trackers and bind them into a ToolViolationHandler:
app/agents/remediation_agent.py
@@ -53,6 +53,18 @@ class RemediationAgent:
self.model_class,
self.api_key
)
+ retry_tracker = RetryBudgetTracker.create(max_feedback_violations=config.retry_max_violations)
+ abuse_tracker = AbuseThrottleTracker.create(
+ window_seconds=config.abuse_window_seconds,
+ max_denials=config.abuse_max_denials,
+ )
+ self.violation_handler = ToolViolationHandler.create(
+ pdp_engine=get_pdp_engine(),
+ retry_tracker=retry_tracker,
+ abuse_tracker=abuse_tracker,
+ )
Explanation: The agent constructs an invocation-scoped RetryBudgetTracker and a cross-turn AbuseThrottleTracker. Passing both to ToolViolationHandler.create() registers the PDP cache invalidation callback and unifies policy violation accounting.
Step 3: Encapsulating the Workflow with with_violation_handler()
Wrap the agent's core execution method with SecureRunnable.wrap(), binding the violation handler and a declarative fallback builder:
app/agents/remediation_agent.py
@@ -65,6 +77,16 @@ class RemediationAgent:
self.violation_handler = ToolViolationHandler.create(
pdp_engine=get_pdp_engine(),
retry_tracker=retry_tracker,
abuse_tracker=abuse_tracker,
)
+ self.secured_workflow = SecureRunnable.wrap(
+ runnable=self._execute_remediation,
+ pdp_engine=get_pdp_engine(),
+ agent_name="remediation_agent",
+ required_roles=TRIAGE_REQUIRED_ROLES,
+ min_clearance=TRIAGE_MIN_CLEARANCE,
+ target_tenant=ALLOWED_TENANTS,
+ ).with_violation_handler(
+ violation_handler=self.violation_handler,
+ fallback_builder=build_violation_fallback_report,
+ )
Explanation: .with_violation_handler() attaches an LCEL error boundary around agent execution. When mutative tools or retry cap exhaustion raise ToolExecutionDeniedError, SecureRunnable intercepts the exception, records sec:last_violation, and delegates to build_violation_fallback_report.
Step 4: Propagating In-Band Config via Secure Handoff
At the public entry point, ensure callers supply or establish a RunnableConfig carrying the subject's identity before invoking the workflow via SecureRunnable.handoff():
app/agents/remediation_agent.py
@@ -133,13 +123,13 @@ class RemediationAgent:
RemediationReport: Report detailing remediation actions, root cause, and status.
"""
logger.info(f"[RemediationAgent] Processing incident ticketId: {context.ticketId} via LLM ({self.model_name})")
- config: Optional[Dict[str, Any]] = args[0] if len(args) > 0 else kwargs.get("config")
- cfg_dict: Dict[str, Any] = config.get("configurable", {}) if isinstance(config, dict) else {}
- actions: List[str] = cfg_dict.setdefault("sec:actions", [])
- try:
- return self._execute_remediation(context, config=config)
- except ToolEntityValidationError as exc:
- return self._handle_tool_entity_validation_error(context, actions, exc)
+
+ run_config: Optional[Dict[str, Any]] = args[0] if len(args) > 0 else kwargs.get("config")
+ if run_config is None:
+ sec_identity = security_identity_from_context(context.securityContext)
+ run_config = IdentityBridge.create_runnable_config(sec_identity)
+
+ return SecureRunnable.handoff(self.secured_workflow, context, run_config)
Explanation: SecureRunnable.handoff() replaces imperative error catching with structured LCEL execution. Storing sec:retry_count and sec:prev_args in-band ensures per-user isolation while carrying retry history across multi-agent handoffs.
Step 5: Enforcing Cross-Turn Abuse Checks at the Execution Boundary (Step 0 Gate)
Before rendering prompts, consulting vector stores, or initiating tool calls, check whether the caller is actively throttled by the sliding-window tracker:
app/agents/remediation_agent.py
@@ -157,6 +147,20 @@ class RemediationAgent:
actions: List[str] = cfg_dict.setdefault("sec:actions", [])
raw_text_lower = context.rawTicketText.lower()
+ # Step 0: Check Cross-Turn Abuse Throttling State
+ caller_identity = IdentityBridge.extract_from_config(config)
+ now_ts = time.time()
+ if (
+ caller_identity is not None
+ and self.violation_handler is not None
+ and self.violation_handler.abuse_tracker.is_throttled(caller_identity.subject_id, now_ts)
+ ):
+ logger.warning(
+ f"[RemediationAgent] Caller '{caller_identity.subject_id}' is actively throttled by sliding-window abuse tracker."
+ )
+ throttle_resolution = ViolationResolution.create(..., should_abort=True)
+ return build_violation_fallback_report(context, throttle_resolution, config)
+
# Step 1: Consult Knowledge Base Runbooks
Explanation: If a caller exceeds abuse_max_denials within the sliding window, Step 0 halts execution immediately. Zero model tokens are consumed, vector searches are bypassed, and the request is safely rejected.
Step 6: Constructing Deterministic Fallback Reports
Implement a declarative fallback builder to translate library ViolationResolution objects into your enterprise application's standard response schema:
app/agents/guardrail_tests_telemetry_helper.py
@@ -71,11 +86,43 @@ def build_remediation_report(
)
+def build_violation_fallback_report(
+ context: Any,
+ resolution: Any,
+ run_config: Optional[Dict[str, Any]],
+) -> RemediationReport:
+ """Declarative LCEL fallback builder converting a library ViolationResolution into a RemediationReport."""
+ cfg_dict = run_config.get("configurable", {}) if isinstance(run_config, dict) else {}
+ actions = list(cfg_dict.get("sec:actions", []))
+ actions.append(resolution.action_entry)
+
+ root_cause = resolution.root_cause
+ user_msg = resolution.user_message
+ remed_sec = resolution.remediation_security
+ remed_exec = resolution.remediation_execution
+ status_val = resolution.status
+
+ vh = cfg_dict.get("sec:violation_handler")
+ caller_id = IdentityBridge.extract_from_config(run_config)
+ tool_label = getattr(resolution, "tool_name", "tool")
+ if (
+ vh is not None
+ and hasattr(vh, "abuse_tracker")
+ and caller_id is not None
+ and vh.abuse_tracker.is_throttled(caller_id.subject_id, time.time())
+ ):
+ root_cause = ROOT_CAUSE_POLICY_BRUTE_FORCE
+ user_msg = f"Execution halted: Abuse throttle threshold exceeded (POLICY_BRUTE_FORCE_SCANNING on {tool_label})"
+ actions.append(f"Abuse throttle triggered: POLICY_BRUTE_FORCE_SCANNING on {tool_label}")
+ remed_sec = STATUS_POLICY_BLOCKED
+ remed_exec = STATUS_EXECUTION_ABORTED
+ status_val = STATUS_BLOCKED_BY_POLICY
+
+ actions, user_msg, rag_clearance_status = finalize_telemetry(actions, user_msg)
+ return build_remediation_report(context, root_cause, actions, status_val, user_msg, remed_sec=remed_sec, remed_exec=remed_exec, rag_clearance=rag_clearance_status)
Explanation: The fallback builder converts security violations into valid application responses (e.g., RemediationReport with remediation_security: BLOCKED_POLICY and remediation_execution: ABORTED). This prevents unhandled 500 exceptions, shields internal error traces, and provides clean telemetry for client applications.
Verification Tests (Guardrail Tests)
Validate retry budget termination and brute-force loop halting using the project's standardized guardrail suite:
-
Test 10: Policy Brute-Force & Anti-Looping Protection (
10-policy-brute-force-retry-loop-halted.sh)
Verifies that when a caller prompts multi-target brute-force service restarts across unauthorized servers (e.g.,finance-db-01,db-prod-01,app-prod-02), the retry tracker halts execution, returningremediation_sec=BLOCKED_POLICYandremediation_exec=ABORTED.BASH./internal-security-guardrail-tests/10-policy-brute-force-retry-loop-halted.sh -
Test 29: Anti-Loop Denied Tool Persistent Retry Cap Termination (
29-anti-loop-retry-cap-terminated.sh)
Injects a persistent retry scenario on a denied tool and asserts that the runner halts upon reaching the configured retry cap (max_feedback_violations = 2, terminating on attempt 3) and records anEVENT_POLICY_BRUTE_FORCE_DETECTEDaudit alert inlogs/security-audit.json.BASH./internal-security-guardrail-tests/29-anti-loop-retry-cap-terminated.sh -
Test 31: Sliding-Window Cross-Turn Abuse Throttling (
31-sliding-window-abuse-throttling.sh)
Simulates an attacker issuing repeated unauthorized requests across multiple distinct tickets/turns (e.g., scanningweb-prod-01,hr-app-01,db-prod-01). Asserts that upon reaching the denial threshold (max_denials = 3), the abuse tracker emits aPOLICY_BRUTE_FORCE_SCANNINGcritical SIEM alert on turn 3, immediately blocks execution on turn 4, and properly prunes expired denials once the sliding window (window_seconds = 60.0) elapses.BASH./internal-security-guardrail-tests/31-sliding-window-abuse-throttling.sh