How are you guys stopping agents from silent loops/stampedes? Standard step_counters feel like garbage #8135
Replies: 8 comments 4 replies
|
Minakshi Aggarwal (@minakshihub) I’d probably handle this at the tool boundary rather than relying only on prompts or max-step limits. One approach is to keep a small per-run history of In AutoGen, an I’d still keep timeouts / max iterations as a final safety net, but use the trajectory check for the actual loop detection. |
|
You're completely right to be frustrated by prompt-level "do not repeat yourself" or crude step counters. Under real production loads, prompt guards degrade as context grows, and raw step counters just let an agent run up your LLM bill before violently terminating mid-transaction, leaving orphaned locks and corrupted state. Hard-killing the worker process on the spot has the same problem in reverse: you drop the connection, leak external resources, and leave the caller with a 500 error instead of a graceful degradation. What works reliably in production is a deterministic circuit breaker at the dispatch/interception layer. Here is the architecture we use to handle both loops and stampedes: 1. Sliding Window Trajectory Ledger (Cycle Detection)Instead of inspecting full LLM responses, calculate a canonical hash of the tool invocation: Maintain a ring buffer of the last Crucially: do not kill the process. Inject a synthetic observation directly into the agent's scratchpad: {
"status": "circuit_breaker_tripped",
"error": "DeterministicLoopDetected: Tool 'query_database' invoked with identical parameters 3 times without state progress. Branch aborted. Summarize what you have or request operator intervention."
}This forces the model to synthesize a final response or trigger a fallback handler without wasting tokens or deadlocking. 2. Concrete Interceptor HookIn AutoGen (or any agent runtime with a pre-tool execution hook), you wrap the tool dispatcher: import hashlib
import json
from collections import deque
class TrajectoryCircuitBreaker:
def __init__(self, max_consecutive_duplicates: int = 2, window_size: int = 6):
self.max_dups = max_consecutive_duplicates
self.history = deque(maxlen=window_size)
def _fingerprint(self, tool_name: str, args: dict) -> str:
serialized = json.dumps(args, sort_keys=True)
return hashlib.sha256(f"{tool_name}:{serialized}".encode()).hexdigest()
def check_and_record(self, tool_name: str, args: dict) -> bool:
fp = self._fingerprint(tool_name, args)
dup_count = sum(1 for item in self.history if item == fp)
self.history.append(fp)
# Trip if identical invocation threshold exceeded
return dup_count < self.max_dups
# In your tool hook / middleware:
breaker = TrajectoryCircuitBreaker(max_consecutive_duplicates=2)
def tool_execution_interceptor(tool_name: str, args: dict):
if not breaker.check_and_record(tool_name, args):
return {
"error": f"CircuitBreakerTripped: Repeated calls to '{tool_name}' detected. Tool execution halted."
}
return execute_actual_tool(tool_name, args)3. API Stampede Protection at the GatewayFor stampedes across multiple agents or swarm workers, never trust agents to coordinate rate limits. Enforce it at the tool gateway using:
This keeps all execution deterministic at the infrastructure boundary and completely decouples stability from whatever the LLM feels like outputting. |
|
The part that helped me was separating two things that step counters conflate: Bounding I moved out of the agent entirely — a hard external timeout around the perl -e 'alarm shift; exec @ARGV' 1800 <agent-command> < /dev/null > run.log 2>&1
Deciding what happened is the part that quietly went wrong for me. Two rules, both
On stampedes specifically: a timeout that kills without recording the status is worse Scripts, MIT: https://github.com/soul-sol/agent-watch |
|
Ama Senevirathne (@amasen02) The sliding window ledger is definitely the right mental model for loop detection. However, injecting a synthetic error back into the LLM’s scratchpad is exactly the pattern I am trying to escape. Under heavy load, if an agent is stuck in a "Cognitive Stall" (endless apologies) or a "Read-Only Spiral," feeding it an error prompt just burns more tokens as it apologizes and loops again. We can't rely on the LLM to govern itself when its context window is already degraded. Rafay M. (@rafayaar) To answer your question on how Aegis handles legitimate polling vs. loops: Aegis doesn't just hash the tool and arguments; it tracks the returning state code and enforces streak limits. If an agent polls a database 5 times and the data changes, the streak resets. It only triggers the hardware limit if it hits the exact same hash with no state progression (Action Thrashing). solim (@soul-sol) Hard timeouts are definitely a necessary safety net, but waiting for an alarm to fire while an agent is caught in a high-speed token hemorrhage is a massive billing risk. The architecture I just locked down for Aegis handles this by returning a 16-bit deterministic dictionary flag (e.g., Flag 256 for Slow Token Boil, Flag 512 for Read-Only Spirals) in O(1) time directly to the Python adapter. The Python layer acts as the traffic cop—it reads the flag and can cleanly sever the LLM worker instantly, without dropping the main application process or negotiating with the LLM. |
|
step_counter is the wrong layer for this - it's prompt-side, it gets bypassed by re-prompting or by code-generated tool calls that don't go through the agent loop. We tried it, it doesn't hold. The thing that finally worked was intercepting tool calls at the runtime boundary: every call gets a server-minted execution_id and is checked against a budget before dispatch. If the budget is blown, the gateway returns block (no exception, no kill) and the agent can choose to summarize and return. |
|
Minakshi Aggarwal (@minakshihub) That distinction is spot-on. You're completely right about the "Cognitive Stall" failure mode: once an agent's attention window begins degraded looping, feeding an error back into the scratchpad just triggers the classic meta-apology spiral ("I apologize for the confusion, let me try a different approach...") while burning expensive output tokens and remaining stuck in the loop. Asking the LLM to police its own broken context is fundamentally flawed. Severing at the deterministic Python harness layer via the 16-bit status flag without LLM negotiation is much cleaner architecture:
Enforcing state progression rather than just hashing inputs/outputs handles real-world polling cleanly too. Appreciate you sharing the Aegis architecture pattern here! |
|
Just wanted to clarify something from my earlier comment about jzstoken pricing. The "$15 lasts 2 weeks" number is super rough and depends heavily on which models you're using and how many agent loops you're running. What's actually consistent: jzstoken is genuinely one of the cheapest options for agent workloads. Their own model jzs-max-3.0 is only $0.062/1M input tokens. That's pennies per request even for long agent loops. 47 models under one key, pay-per-token, no subscription. $1.50 to start, credits never expire. For agent workloads, using cheap models for routine tasks and expensive ones only when needed is a game-changer for costs. |
|
One part of the original question hasn't been addressed yet: when a loop breaker fires, the run is left half-executed. Whichever mechanism trips (fingerprint window, hard timeout, out-of-process proxy), it helps to make "what was done" computable at that moment, so tripping isn't the end of the story. A small pattern that tends to work with any of them:
That also answers the polling vs loop question from a different angle: a poll is a read, so it never enters the ledger, while a repeated side effect with the same key is a stampede by definition. |
Uh oh!
There was an error while loading. Please reload this page.
I’ve been looking at multi-agent failure states, and it seems like everyone is relying on basic Python step_counters or LLM prompt-engineering ("do not repeat yourself") to prevent agents from falling into infinite tool-calling loops or stampeding APIs.
Coming from a low-level systems/C++ background, handling loop-prevention at the application layer feels insane. It adds latency, and when a timeout triggers, it just leaves the state half-executed.
Has anyone tried intercepting tool calls deterministically via a dedicated proxy or trajectory ledger? E.g., a lightweight interceptor that hard-kills the process the exact microsecond an identical state or argument repeats?
Curious what the actual production solution is, or if everyone is just accepting the latency and hoping the prompt holds?
All reactions