Skip to content

fix(teams): add agent loop protection — rate limiter + chain depth cap#224

Open
jcenters wants to merge 1 commit intoTinyAGI:mainfrom
jcenters:fix/agent-loop-protection
Open

fix(teams): add agent loop protection — rate limiter + chain depth cap#224
jcenters wants to merge 1 commit intoTinyAGI:mainfrom
jcenters:fix/agent-loop-protection

Conversation

@jcenters
Copy link

Problem

Agents in a team can trigger runaway feedback loops that exhaust your API budget in minutes. Two failure modes:

  1. Escaped conversation tracking — chatroom fan-out (fixed in fix(teams): remove chatroom fan-out to prevent agent feedback loops #220) and other agent-originated messages can bypass the maxMessages guard by not carrying a conversationId. The guard only fires for in-conversation @mention chains.
  2. Default chain depth of 50 — even within a tracked conversation, 50 agent exchanges is enough to burn through a 5-hour API limit before anything stops.

Two layered fixes

1. Rate limiter in enqueueMessage (packages/core/src/queues.ts)

Any message with fromAgent set was generated by an agent, not a human. Before inserting, count how many agent-to-agent messages the target agent already has queued in the last 60 seconds. If at or above the limit, drop and log a [LoopGuard] warning.

This catches loops that escape the conversation system entirely — chatroom messages, new conversations spawned by agents, anything without a conversationId.

Default: 10 messages/minute/agent. Configurable:

{ "protection": { "max_agent_messages_per_minute": 10 } }

2. Lower default chain depth (packages/teams/src/conversation.ts)

DEFAULT_MAX_CONVERSATION_MESSAGES: 50 → 10. Read from settings.json at conversation creation time:

{ "protection": { "max_chain_depth": 10 } }

Both limits are independent — the rate limiter is a hard floor for anything that escapes conversation tracking; the chain depth cap limits depth within a tracked conversation.

Test plan

  • Agent A @mentions Agent B, B @mentions A — loop stops at max_chain_depth (default 10)
  • Agent repeatedly enqueues messages to another agent — drops at 10/min with [LoopGuard] in logs
  • Normal 2-3 agent collaboration completes without hitting either limit
  • settings.json overrides respected: max_agent_messages_per_minute: 20 raises the rate limit
  • Human → agent messages (no fromAgent) are never rate-limited

🤖 Generated with Claude Code

Agents in a team could trigger runaway feedback loops by sending each
other messages indefinitely. Two mechanisms failed to prevent this:

1. The chatroom fan-out (fixed separately in TinyAGI#220) escaped the
   conversation tracking system entirely, so totalMessages never
   incremented and the maxMessages guard never fired.
2. Agent-to-agent @mentions via sendInternalMessage had a maxMessages
   guard, but the default was 50 — enough for a 5-hour API limit burn
   before anything stopped.

This PR adds two independent, layered defenses:

**Rate limiter in enqueueMessage (queues.ts)**
Any message where fromAgent is set is agent-generated. Before inserting,
count how many agent-to-agent messages the target agent already has
queued in the last 60 seconds. If at or above the limit, drop the
message and log a [LoopGuard] warning instead of enqueuing.
Default: 10 messages/minute/agent. Configurable via settings.json:

  "protection": { "max_agent_messages_per_minute": 10 }

**Conversation chain depth cap (conversation.ts)**
Lower DEFAULT_MAX_CONVERSATION_MESSAGES from 50 to 10. Read the
effective value from settings.json at conversation creation time so
operators can tune it without a code change:

  "protection": { "max_chain_depth": 10 }

Both limits are independent — the rate limiter catches loops that
escape the conversation system (e.g. chatroom messages, new
conversations spawned by agents), while the chain depth cap limits
depth within a single tracked conversation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link

greptile-apps bot commented Mar 16, 2026

Greptile Summary

This PR introduces two layered protections against runaway agent feedback loops in team conversations: a per-agent rate limiter in the message queue (capping agent-to-agent messages at 10/minute by default) and a reduced default conversation chain depth (50 → 10), both configurable via settings.json. The changes are well-scoped and address two distinct failure modes — escaped conversation tracking (chatroom fan-out, no conversationId) and excessive chain depth within a tracked conversation.

Key findings:

  • Critical logic gap in the rate limiter: The SQL query filters status IN ('pending','processing'), so messages that have already been processed (completed) are excluded from the count. In a fast loop where each LLM call finishes before the next message check, the pending/processing count stays near zero and the rate limiter is bypassed entirely. Since pruneCompletedMessages retains rows for 24 hours, removing the status filter would give accurate 60-second rate counts across all message states.
  • Performance nit: getSettings() is called on every agent message enqueue; this could be a hot-path concern during chatroom fan-out bursts.
  • The MAX_CONVERSATION_MESSAGESDEFAULT_MAX_CONVERSATION_MESSAGES rename is safe — no other file imports the old exported name.
  • The types.ts addition is clean and well-documented.

Confidence Score: 2/5

  • Not safe to merge without addressing the rate limiter status-filter bug, which can allow fast agent loops to bypass the primary protection for escaped-conversation scenarios.
  • The chain-depth cap in conversation.ts works correctly and is a solid fix for tracked conversations. However, the rate limiter — which is the only guard for the more dangerous "escaped" loop case (chatroom messages, no conversationId) — contains a logic bug where completed messages are excluded from the count. A sufficiently fast loop will always see a near-zero pending count and never be throttled.
  • packages/core/src/queues.ts — the rate limiter SQL query at lines 96-100 needs the status IN ('pending','processing') filter removed to count all messages (including completed ones) within the 60-second window.

Important Files Changed

Filename Overview
packages/core/src/queues.ts Adds an agent-to-agent rate limiter in enqueueMessage. The SQL query filtering only pending/processing statuses means fast loops that process messages before the next enqueue check can fully bypass the rate limiter — a critical gap for the chatroom fan-out failure mode this PR targets.
packages/core/src/types.ts Adds protection block to the Settings interface with max_agent_messages_per_minute and max_chain_depth. Clean addition, well-documented with inline comments.
packages/teams/src/conversation.ts Renames MAX_CONVERSATION_MESSAGESDEFAULT_MAX_CONVERSATION_MESSAGES and lowers the default from 50 to 10, reading the live setting at conversation-creation time. No other files reference the old export name so the rename is safe.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Agent enqueues message\nvia enqueueMessage] --> B{data.fromAgent set?}
    B -- No: human message --> F[Insert into DB\nno rate check]
    B -- Yes: agent-generated --> C[Query DB:\nCOUNT pending+processing messages\nfor target agent in last 60s]
    C --> D{cnt >= maxPerMinute\ndefault 10}
    D -- Yes --> E["[LoopGuard] Drop + log WARN\nreturn null"]
    D -- No --> F
    F --> G[Message in queue]
    G --> H[Agent processes message\nvia handleTeamResponse]
    H --> I{conversationId present\nand tracked?}
    I -- Yes --> J{totalMessages < maxMessages\ndefault 10}
    J -- Yes --> K[Extract @mentions\nenqueueInternalMessage]
    K --> G
    J -- No --> L["Log WARN: hit max messages\ndo not enqueue further mentions"]
    L --> M[Complete conversation]
    I -- No: chatroom / escaped --> N[Only rate limiter\nprovides protection]
    N --> C
    style E fill:#f66,color:#fff
    style L fill:#f90,color:#fff
    style N fill:#f90,color:#fff
Loading

Last reviewed commit: d3f2d53

Comment on lines +96 to +100
const recent = getDb().prepare(
`SELECT COUNT(*) as cnt FROM messages
WHERE agent=? AND from_agent IS NOT NULL
AND created_at > ? AND status IN ('pending','processing')`
).get(targetAgent, now - RATE_WINDOW_MS) as { cnt: number };
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate limiter misses already-completed messages

The status IN ('pending','processing') filter means that if an agent processes messages quickly (each LLM call finishes before the next message is checked), those completed rows are excluded from the count. In a fast chatroom loop — the primary scenario this guard is designed to catch — messages could be enqueued, processed, and completed well within the 60-second window, keeping the pending/processing count at 0 or 1 and allowing the loop to bypass the limiter entirely.

Since pruneCompletedMessages defaults to a 24-hour retention window, completed messages within the last 60 seconds are still available in the table. Removing the status filter gives an accurate picture of the actual send rate regardless of processing speed:

Suggested change
const recent = getDb().prepare(
`SELECT COUNT(*) as cnt FROM messages
WHERE agent=? AND from_agent IS NOT NULL
AND created_at > ? AND status IN ('pending','processing')`
).get(targetAgent, now - RATE_WINDOW_MS) as { cnt: number };
const recent = getDb().prepare(
`SELECT COUNT(*) as cnt FROM messages
WHERE agent=? AND from_agent IS NOT NULL
AND created_at > ?`
).get(targetAgent, now - RATE_WINDOW_MS) as { cnt: number };

Comment on lines +93 to +95
const settings = getSettings();
const maxPerMinute = settings.protection?.max_agent_messages_per_minute ?? DEFAULT_MAX_AGENT_MESSAGES_PER_MINUTE;
const targetAgent = data.agent ?? 'default';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getSettings() called on every agent message enqueue

getSettings() is invoked for every agent-originated message. Depending on the implementation in ./config, this could involve a file read or JSON parse on each call. In a burst scenario (e.g., chatroom fan-out to many teammates), this gets called for every single enqueue. Consider caching or passing the settings in from the call site to avoid repeated I/O on the hot path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant