-
Notifications
You must be signed in to change notification settings - Fork 488
fix(teams): add agent loop protection — rate limiter + chain depth cap #224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,12 +6,17 @@ | |||||||||||||||||||||
| import Database from 'better-sqlite3'; | ||||||||||||||||||||||
| import path from 'path'; | ||||||||||||||||||||||
| import { EventEmitter } from 'events'; | ||||||||||||||||||||||
| import { TINYCLAW_HOME } from './config'; | ||||||||||||||||||||||
| import { TINYCLAW_HOME, getSettings } from './config'; | ||||||||||||||||||||||
| import { log } from './logging'; | ||||||||||||||||||||||
| import { MessageJobData, ResponseJobData } from './types'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const QUEUE_DB_PATH = path.join(TINYCLAW_HOME, 'tinyclaw.db'); | ||||||||||||||||||||||
| const MAX_RETRIES = 5; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Agent loop protection defaults — configurable via settings.json protection block | ||||||||||||||||||||||
| const DEFAULT_MAX_AGENT_MESSAGES_PER_MINUTE = 10; | ||||||||||||||||||||||
| const RATE_WINDOW_MS = 60_000; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| let db: Database.Database | null = null; | ||||||||||||||||||||||
| export const queueEvents = new EventEmitter(); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -78,6 +83,28 @@ function getDb(): Database.Database { | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| export function enqueueMessage(data: MessageJobData): number | null { | ||||||||||||||||||||||
| const now = Date.now(); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Agent loop protection: rate-limit agent-to-agent messages. | ||||||||||||||||||||||
| // When fromAgent is set, this message was generated by an agent (not a human). | ||||||||||||||||||||||
| // Check how many such messages the target agent already has queued in the last | ||||||||||||||||||||||
| // minute. If over the limit, drop the message and log a warning instead of | ||||||||||||||||||||||
| // letting an agent feedback loop exhaust the API budget. | ||||||||||||||||||||||
| if (data.fromAgent) { | ||||||||||||||||||||||
| const settings = getSettings(); | ||||||||||||||||||||||
| const maxPerMinute = settings.protection?.max_agent_messages_per_minute ?? DEFAULT_MAX_AGENT_MESSAGES_PER_MINUTE; | ||||||||||||||||||||||
| const targetAgent = data.agent ?? 'default'; | ||||||||||||||||||||||
| 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 }; | ||||||||||||||||||||||
|
Comment on lines
+96
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rate limiter misses already-completed messages The Since
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (recent.cnt >= maxPerMinute) { | ||||||||||||||||||||||
| log('WARN', `[LoopGuard] Dropped agent-to-agent message: @${data.fromAgent} → @${targetAgent} (${recent.cnt} messages in last 60s, limit ${maxPerMinute}). Possible agent feedback loop.`); | ||||||||||||||||||||||
| return null; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| const r = getDb().prepare( | ||||||||||||||||||||||
| `INSERT INTO messages (message_id,channel,sender,sender_id,message,agent,files,conversation_id,from_agent,status,created_at,updated_at) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
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 enqueuegetSettings()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.