Skip to content

feat(agent): add tool call cycle detection to prevent infinite loops#2271

Open
flobo3 wants to merge 42 commits intoHKUDS:nightlyfrom
flobo3:feat/cycle-detection
Open

feat(agent): add tool call cycle detection to prevent infinite loops#2271
flobo3 wants to merge 42 commits intoHKUDS:nightlyfrom
flobo3:feat/cycle-detection

Conversation

@flobo3
Copy link
Copy Markdown
Contributor

@flobo3 flobo3 commented Mar 19, 2026

Summary

Adds a CycleDetector class that detects when the agent is stuck in a loop calling the same tools repeatedly. This prevents wasted tokens and time when an LLM gets into a repetitive pattern.

Two detection strategies:

  • Same call repetition: identical (tool_name, args) called more than N times (default: 3)
  • Pattern repetition: a sequence of 2+ tool calls repeats 2+ times

Changes

  • New: nanobot/agent/cycle_detector.py — CycleDetector class with configurable parameters
  • New: CycleDetectionConfig in nanobot/config/schema.py with tunable settings:
    • enabled: bool = True
    • window_size: int = 20 — number of recent calls to track
    • max_same_calls: int = 3 — max repetitions before triggering
    • pattern_min_length: int = 2 — minimum pattern length to detect
    • pattern_min_repeats: int = 2 — minimum pattern repetitions
  • Modified: nanobot/agent/loop.py — integration into _run_agent_loop()
  • Modified: nanobot/agent/subagent.py — integration into subagent execution
  • Modified: nanobot/agent/context.py — added "Loop Prevention" hints in system prompt
  • Modified: nanobot/cli/commands.py — pass config through CLI

Behavior

When a cycle is detected:

  1. Agent logs a warning with the reason
  2. Returns an informative message: "I detected I'm stuck in a loop (...). Let me try a different approach or ask for clarification."
  3. Breaks out of the tool call loop, allowing the agent to pivot strategies

Example

# Agent keeps calling the same tool:
read_file("config.json")
read_file("config.json")
read_file("config.json")
read_file("config.json")  # <- Cycle detected! Breaks loop.

# Or a repeating pattern:
read_file("a.txt") -> exec("ls") -> read_file("a.txt") -> exec("ls") -> read_file("a.txt") -> exec("ls")
# Pattern of 2 tools repeated 3 times - cycle detected!

Configuration

Users can tune detection via nanobot.yaml:

agents:
  defaults:
    cycle_detection:
      enabled: true
      max_same_calls: 3
      window_size: 20

Test Plan

  • Verify basic syntax with python -c "import nanobot.agent.cycle_detector"
  • Test cycle detection with repeated calls
  • Test pattern detection with alternating tool calls
  • Verify reset works between conversation turns

🤖 Generated with Claude Code

flobo3 and others added 26 commits March 22, 2026 20:45
Register Mistral as a first-class provider with LiteLLM routing,
MISTRAL_API_KEY env var, and https://api.mistral.ai/v1 default base.

Includes schema field, registry entry, and tests.
Implement file upload and sending for QQ C2C messages

Reference: https://github.com/tencent-connect/botpy/blob/master/examples/demo_c2c_reply_file.py

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
- Fix _read_media_bytes treating local paths as URLs: local file
  handling code was dead code placed after an early return inside the
  HTTP try/except block. Restructure to check for local paths (plain
  path or file:// URI) before URL validation, so files like
  /home/.../.nanobot/workspace/generated_image.svg can be read and
  sent correctly.
- Add .svg to _IMAGE_EXTS so SVG files are uploaded as file_type=1
  (image) instead of file_type=4 (file).
- Add tests for local path, file:// URI, and missing file cases.

Fixes: HKUDS#1667 (comment)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: guanka001 <guanka001@ke.com>
Tool calls from a single LLM response are independent by design —
the model batches them precisely because they can run concurrently.
Replace the serial for-loop with asyncio.gather so N tools complete
in max(time_i) instead of sum(time_i).

Made-with: Cursor
Without this flag, a BaseException (e.g. CancelledError from /stop)
in one tool would propagate immediately and discard results from the
other concurrent tools, corrupting the OpenAI message format.

With return_exceptions=True, all tool results are collected; any
exception is converted to an error string for the LLM.

Made-with: Cursor
The tz parameter was previously only allowed with cron_expr. When users
specified tz with at for one-time tasks, it returned an error. Now tz
works with both cron_expr and at — naive ISO datetimes are interpreted
in the given timezone via ZoneInfo.

- Relax validation: allow tz with cron_expr or at
- Apply ZoneInfo to naive datetimes in the at branch
- Update SKILL.md with at+tz examples
- Add automated tests for tz+at combinations

Co-authored-by: weitongtong <tongtong.wei@nodeskai.com>
Made-with: Cursor
- Restore enable attribute to ExecToolConfig
- Remove deprecated memory_window field (was removed in f44c4f9 but brought back by cherry-pick)
- Restore exclude=True on openai_codex and github_copilot oauth providers
…conflict resolution

The build_messages() method was missing the current_role parameter that
loop.py calls with, causing a TypeError at runtime. This restores the
parameter with its default value of "user" to match the original PR HKUDS#2104.
Add a new WeChat (微信) channel that connects to personal WeChat using
the ilinkai.weixin.qq.com HTTP long-poll API. Protocol reverse-engineered
from @tencent-weixin/openclaw-weixin v1.0.2.

Features:
- QR code login flow (nanobot weixin login)
- HTTP long-poll message receiving (getupdates)
- Text message sending with proper WeixinMessage format
- Media download with AES-128-ECB decryption (image/voice/file/video)
- Voice-to-text from WeChat + Groq Whisper fallback
- Quoted message (ref_msg) support
- Session expiry detection and auto-pause
- Server-suggested poll timeout adaptation
- Context token caching for replies
- Auto-discovery via channel registry

No WebSocket, no Node.js bridge, no local WeChat client needed — pure
HTTP with a bot token obtained via QR code scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cherry-picked from PR HKUDS#2355 (ad128a7) — only agent/context.py and agent/tools/message.py.

Co-Authored-By: qulllee <qullkui@tencent.com>
During testing, we discovered that when a user requests the agent to
send a file (e.g., "send me IMG_1115.png"), the agent would call
read_file to view the content and then reply with text claiming
"file sent" — but never actually deliver the file to the user.

Root cause: The system prompt stated "Reply directly with text for
conversations. Only use the 'message' tool to send to a specific
chat channel", which led the LLM to believe text replies were
sufficient for all responses, including file delivery.

Fix: Add an explicit IMPORTANT instruction in the system prompt
telling the LLM it MUST use the 'message' tool with the 'media'
parameter to send files, and that read_file only reads content
for its own analysis.

Co-Authored-By: qulllee <qullkui@tencent.com>
Previously the WeChat channel's send() method only handled text messages,
completely ignoring msg.media. When the agent called message(media=[...]),
the file was never delivered to the user.

Implement the full WeChat CDN upload protocol following the reference
@tencent-weixin/openclaw-weixin v1.0.2:
  1. Generate a client-side AES-128 key (16 random bytes)
  2. Call getuploadurl with file metadata + hex-encoded AES key
  3. AES-128-ECB encrypt the file and POST to CDN with filekey param
  4. Read x-encrypted-param from CDN response header as download param
  5. Send message with the media item (image/video/file) referencing
     the CDN upload

Also adds:
- _encrypt_aes_ecb() for AES-128-ECB encryption (reverse of existing
  _decrypt_aes_ecb)
- Media type detection from file extension (image/video/file)
- Graceful error handling: failed media sends notify the user via text
  without blocking subsequent text delivery

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ands

Move channel-specific login logic from CLI into each channel class via a
new `login(force=False)` method on BaseChannel. The `channels login <name>`
command now dynamically loads the channel and calls its login() method.

- WeixinChannel.login(): calls existing _qr_login(), with force to clear saved token
- WhatsAppChannel.login(): sets up bridge and spawns npm process for QR login
- CLI no longer contains duplicate login logic per channel
- Update CHANNEL_PLUGIN_GUIDE to document the login() hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resolve telegram.py conflict by keeping main's streaming implementation
(_StreamBuf + send_delta with edit_message_text approach) over nightly's
_send_with_streaming (draft-based approach).
urlparse on Windows puts the path in netloc, not path. Use
(parsed.path or parsed.netloc) to get the correct raw path.
95256155o and others added 15 commits March 23, 2026 18:13
* feat: add contextBudgetTokens config field for tool-loop trimming

* feat: implement _trim_history_for_budget for tool-loop cost reduction

* feat: thread contextBudgetTokens into AgentLoop constructor

* feat: wire context budget trimming into agent loop

* refactor: move trim_history_for_budget to helpers and add docs

- Extract trim_history_for_budget() as a pure function in helpers.py
- AgentLoop._trim_history_for_budget becomes a thin wrapper
- Add docs/CONTEXT_BUDGET.md with usage guide and trade-off notes
- Replace wrapper tests with direct helper unit tests

---------

Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
* feat(feishu): add streaming support via CardKit PATCH API

Implement send_delta() for Feishu channel using interactive card
progressive editing:
- First delta creates a card with markdown content and typing cursor
- Subsequent deltas throttled at 0.5s to respect 5 QPS PATCH limit
- stream_end finalizes with full formatted card (tables, rich markdown)

Also refactors _send_message_sync to return message_id (str | None)
and adds _patch_card_sync for card updates.

Includes 17 new unit tests covering streaming lifecycle, config,
card building, and edge cases.

Made-with: Cursor

* feat(feishu): close CardKit streaming_mode on stream end

Call cardkit card.settings after final content update so chat preview
leaves default [生成中...] summary (Feishu streaming docs).

Made-with: Cursor

* style: polish Feishu streaming (PEP8 spacing, drop unused test imports)

Made-with: Cursor

* docs(feishu): document cardkit:card:write for streaming

- README: permissions, upgrade note for existing apps, streaming toggle
- CHANNEL_PLUGIN_GUIDE: Feishu CardKit scope and when to disable streaming

Made-with: Cursor

* docs: address PR 2382 review (test path, plugin guide, README, English docstrings)

- Move Feishu streaming tests to tests/channels/
- Remove Feishu CardKit scope from CHANNEL_PLUGIN_GUIDE (plugin-dev doc only)
- README Feishu permissions: consistent English
- feishu.py: replace Chinese in streaming docstrings/comments

Made-with: Cursor
- Add send_max_retries config option (default: 3, range: 0-10)
- Implement _send_with_retry in ChannelManager with 1s/2s/4s backoff
- Propagate CancelledError for graceful shutdown
- Fix telegram send_delta to raise exceptions for Manager retry
- Add comprehensive tests for retry logic
- Document channel settings in README
Make channel delivery failures raise consistently so retry policy lives in ChannelManager rather than being split across individual channels. Tighten Telegram stream finalization, clarify sendMaxRetries semantics, and align the docs with the behavior the system actually guarantees.
When LLM generates faster than channel can process, asyncio.Queue
accumulates multiple _stream_delta messages. Each delta triggers a
separate API call (~700ms each), causing visible delay after LLM
finishes.

Solution: In _dispatch_outbound, drain all queued deltas for the same
(channel, chat_id) before sending, combining them into a single API
call. Non-matching messages are preserved in a pending buffer for
subsequent processing.

This reduces N API calls to 1 when queue has N accumulated deltas.
Co-authored-by: Pares Mathur <paresh.2047@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* Added streaming message support with incremental updates for Matrix channel

* Improve Matrix message handling and add tests

* Adjust Matrix streaming edit interval to 2 seconds

---------

Co-authored-by: natan <natan@podbielski>
…clusive (HKUDS#2491)

* fix(providers): make max_tokens and max_completion_tokens mutually exclusive

* docs: document supports_max_completion_tokens ProviderSpec option
…jection (HKUDS#2488)

* feat(command): add /skill slash command for user-activated skill injection

* test(command): add tests for /skill slash command

* refactor(command): switch skill activation from /skill prefix to $-reference interceptor
Add CycleDetector class that detects when the agent is stuck calling
the same tools repeatedly. Two detection strategies:
- Same call repetition: identical (tool_name, args) called > N times
- Pattern repetition: sequence of calls repeats 2+ times

Changes:
- New CycleDetector class in nanobot/agent/cycle_detector.py
- CycleDetectionConfig in config/schema.py with tunable parameters
- Integration into AgentLoop._run_agent_loop() and SubagentManager
- Loop prevention hints added to system prompt in context.py
- Pass cycle_detection config through CLI commands

When a cycle is detected, the agent breaks out of the loop with an
informative message, allowing it to try a different approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@flobo3 flobo3 force-pushed the feat/cycle-detection branch from 7885258 to 60e65dd Compare March 27, 2026 21:19
@chengyongru chengyongru force-pushed the nightly branch 2 times, most recently from 5915287 to 5479a44 Compare April 5, 2026 11:40
@Re-bin Re-bin deleted the branch HKUDS:nightly April 19, 2026 16:21
@Re-bin Re-bin closed this Apr 19, 2026
@chengyongru chengyongru reopened this Apr 19, 2026
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.