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:

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:

  1. Invocation-Scoped Retry Budget (RetryBudgetTracker): Tracks consecutive tool feedback refusals within the active invocation turn using RunnableConfig["configurable"]["sec:retry_count"]. When denials exceed max_feedback_violations (default: 2), the budget is exhausted. The next denial triggers an immediate hard abort and logs EVENT_POLICY_BRUTE_FORCE_DETECTED.
  2. 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-severity EVENT_POLICY_BRUTE_FORCE_SCANNING SIEM alert.
  3. Structured Resolution Dispatch (ToolViolationHandler): Evaluates policy decisions against both trackers, returning a structured ViolationResolution object that dictates whether execution should continue with synthetic feedback or terminate immediately.

Configuring Retry Budgets and Abuse Throttles in the Security Library

SECURITY LIBRARY SNIPPET (INFORMATIVE) langchain_security_extension/security_adapter_langchain/violation_handler.py
PYTHON
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

DEMO APP (DIFF) app/config.py
DIFF
@@ -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

DEMO APP (DIFF) app/agents/remediation_agent.py
DIFF
@@ -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

DEMO APP (DIFF) app/agents/remediation_agent.py
DIFF
@@ -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

DEMO APP (DIFF) app/agents/remediation_agent.py
DIFF
@@ -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

DEMO APP (DIFF) app/agents/remediation_agent.py
DIFF
@@ -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

DEMO APP (DIFF) app/agents/guardrail_tests_telemetry_helper.py
DIFF
@@ -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: