From d9cb7aec696994495aaeacf5d3e205b1458384a4 Mon Sep 17 00:00:00 2001 From: Nikolay Trakiyski Date: Tue, 10 Feb 2026 23:53:22 +0200 Subject: [PATCH 1/4] feat: Add Telegram bot wrapper integration Add full Telegram bot integration allowing users to interact with Agent Zero through Telegram: Features: - Async Telegram bot using python-telegram-bot library - User authorization via TELEGRAM_BOT_ALLOWED_USERS secret - Commands: /start (welcome), /new (reset conversation), /id (show user ID) - Long message splitting for Telegram's 4096 character limit - 300-second timeout for agent responses - Threaded execution with graceful shutdown - Hot-reload support when settings change Changes: - New python/helpers/telegram_bot.py - Core bot implementation - New webui/components/settings/external/telegram_bot.html - Settings UI - Add telegram_bot_enabled setting to settings.py - Initialize bot in run_ui.py on startup - Update external-settings.html with Telegram bot navigation - Add python-telegram-bot>=21.11.1 to requirements.txt - Add docker-compose.yml for Docker deployment Configuration: - TELEGRAM_BOT_TOKEN secret required - TELEGRAM_BOT_ALLOWED_USERS secret for access control (optional) Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 13 + python/helpers/settings.py | 19 ++ python/helpers/telegram_bot.py | 274 ++++++++++++++++++ requirements.txt | 1 + run_ui.py | 5 +- .../settings/external/external-settings.html | 9 + .../settings/external/telegram_bot.html | 31 ++ 7 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 docker-compose.yml create mode 100644 python/helpers/telegram_bot.py create mode 100644 webui/components/settings/external/telegram_bot.html diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..273d296971 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + agent-zero: + container_name: agent-zero-v2 + build: + context: . + dockerfile: DockerfileLocal + volumes: + - agent-zero-data:/a0 + ports: + - "52080:80" + +volumes: + agent-zero-data: diff --git a/python/helpers/settings.py b/python/helpers/settings.py index 3d3efa899f..7b3234bda3 100644 --- a/python/helpers/settings.py +++ b/python/helpers/settings.py @@ -157,6 +157,7 @@ class Settings(TypedDict): litellm_global_kwargs: dict[str, Any] update_check_enabled: bool + telegram_bot_enabled: bool class PartialSettings(Settings, total=False): @@ -599,6 +600,7 @@ def get_default_settings() -> Settings: secrets="", litellm_global_kwargs=get_default_value("litellm_global_kwargs", {}), update_check_enabled=get_default_value("update_check_enabled", True), + telegram_bot_enabled=get_default_value("telegram_bot_enabled", False), ) @@ -717,6 +719,23 @@ async def update_a2a_token(token: str): update_a2a_token, current_token ) # TODO overkill, replace with background task + # update telegram bot if setting changed + telegram_changed = ( + not previous + or _settings["telegram_bot_enabled"] != previous.get("telegram_bot_enabled") + ) + secrets_changed = ( + not previous + or _settings.get("secrets", "") != previous.get("secrets", "") + ) + if telegram_changed or secrets_changed: + + async def update_telegram_bot(): + from python.helpers.telegram_bot import reconfigure_bot + reconfigure_bot() + + task5 = defer.DeferredTask().start_task(update_telegram_bot) + def _env_to_dict(data: str): result = {} diff --git a/python/helpers/telegram_bot.py b/python/helpers/telegram_bot.py new file mode 100644 index 0000000000..a01a03e4b2 --- /dev/null +++ b/python/helpers/telegram_bot.py @@ -0,0 +1,274 @@ +import asyncio +import threading +from typing import Optional + +from python.helpers.print_style import PrintStyle +from python.helpers import settings +from python.helpers.secrets import get_default_secrets_manager + +_PRINTER = PrintStyle(italic=True, font_color="#0088cc", padding=False) + +TELEGRAM_MESSAGE_TIMEOUT = 300 + +_bot_app = None +_bot_thread: Optional[threading.Thread] = None +_bot_lock = threading.Lock() +_chat_contexts_lock: Optional[asyncio.Lock] = None +_chat_contexts: dict[int, str] = {} +_bot_shutdown = False + + +def _get_chat_contexts_lock() -> asyncio.Lock: + global _chat_contexts_lock + if _chat_contexts_lock is None: + _chat_contexts_lock = asyncio.Lock() + return _chat_contexts_lock + + +def _get_allowed_users() -> set[int]: + secrets_manager = get_default_secrets_manager() + secrets = secrets_manager.load_secrets() + raw = secrets.get("TELEGRAM_BOT_ALLOWED_USERS", "") + if not raw or not raw.strip(): + return set() + result = set() + for part in raw.split(","): + part = part.strip() + if part.isdigit(): + result.add(int(part)) + return result + + +async def _handle_message(update, context) -> None: + from agent import AgentContext, UserMessage + from initialize import initialize_agent + + if update.message is None: + return + + telegram_chat_id = update.effective_chat.id + telegram_user_id = update.effective_user.id if update.effective_user else None + + allowed = _get_allowed_users() + if allowed and telegram_user_id not in allowed: + await update.message.reply_text("⛔ You are not authorized to use this bot.") + return + + user_text = update.message.text or "" + if not user_text.strip(): + await update.message.reply_text("Please send a text message.") + return + + _PRINTER.print(f"Telegram message from user {telegram_user_id} in chat {telegram_chat_id}") + + lock = _get_chat_contexts_lock() + async with lock: + agent_context = None + ctx_id = _chat_contexts.get(telegram_chat_id) + if ctx_id: + agent_context = AgentContext.get(ctx_id) + + if agent_context is None: + config = initialize_agent() + from agent import AgentContextType + + agent_context = AgentContext.first() + if not agent_context: + agent_context = AgentContext(config=config, type=AgentContextType.USER) + _chat_contexts[telegram_chat_id] = agent_context.id + _PRINTER.print(f"Using context {agent_context.id} for Telegram chat {telegram_chat_id}") + + thinking_msg = await update.message.reply_text("⏳ Processing...") + + try: + task = agent_context.communicate( + UserMessage(message=user_text, system_message=[], attachments=[]) + ) + result = await asyncio.wait_for(task.result(), timeout=TELEGRAM_MESSAGE_TIMEOUT) + + response_text = str(result) if result else "⚠️ No response received." + await _send_long_message(update.effective_chat.id, response_text, context) + + except asyncio.TimeoutError: + _PRINTER.print(f"Telegram message processing timed out for chat {telegram_chat_id}") + await context.bot.send_message( + chat_id=update.effective_chat.id, + text="⏱️ Request timed out. The agent took too long to respond. Please try a simpler question or try again later.", + ) + except Exception as e: + _PRINTER.print(f"Telegram message processing failed: {e}") + error_msg = "❌ Sorry, I encountered an error processing your message. Please try again." + await context.bot.send_message( + chat_id=update.effective_chat.id, + text=error_msg, + ) + finally: + try: + await thinking_msg.delete() + except Exception as e: + _PRINTER.print(f"Failed to delete thinking message: {e}") + + +async def _handle_start(update, context) -> None: + allowed = _get_allowed_users() + telegram_user_id = update.effective_user.id if update.effective_user else None + + if allowed and telegram_user_id not in allowed: + await update.message.reply_text("⛔ You are not authorized to use this bot.") + return + + await update.message.reply_text( + "👋 Welcome to Agent Zero!\n\n" + "Send me any message and I will process it through Agent Zero.\n\n" + "Commands:\n" + "/start - Show this message\n" + "/new - Start a new conversation\n" + "/id - Show your Telegram user ID" + ) + + +async def _handle_new(update, context) -> None: + allowed = _get_allowed_users() + telegram_user_id = update.effective_user.id if update.effective_user else None + + if allowed and telegram_user_id not in allowed: + await update.message.reply_text("⛔ You are not authorized to use this bot.") + return + + telegram_chat_id = update.effective_chat.id + + old_ctx_id = _chat_contexts.pop(telegram_chat_id, None) + if old_ctx_id: + from agent import AgentContext + from python.helpers.persist_chat import remove_chat + + old_ctx = AgentContext.get(old_ctx_id) + if old_ctx: + old_ctx.reset() + AgentContext.remove(old_ctx_id) + try: + remove_chat(old_ctx_id) + except Exception as e: + _PRINTER.print(f"Failed to remove chat {old_ctx_id}: {e}") + + await update.message.reply_text("🔄 New conversation started. Send me a message!") + + +async def _handle_id(update, context) -> None: + user_id = update.effective_user.id if update.effective_user else "unknown" + await update.message.reply_text(f"Your Telegram user ID: `{user_id}`", parse_mode="Markdown") + + +async def _send_long_message(chat_id: int, text: str, context, max_length: int = 4096) -> None: + if len(text) <= max_length: + await context.bot.send_message(chat_id=chat_id, text=text) + return + + while text: + if len(text) <= max_length: + await context.bot.send_message(chat_id=chat_id, text=text) + break + + split_at = text.rfind("\n", 0, max_length) + if split_at == -1: + split_at = max_length + + chunk = text[:split_at] + text = text[split_at:] + if text.startswith("\n"): + text = text[1:] + await context.bot.send_message(chat_id=chat_id, text=chunk) + + +async def _run_polling_async() -> None: + global _bot_shutdown, _bot_app + _bot_shutdown = False + try: + await _bot_app.initialize() + await _bot_app.start() + await _bot_app.updater.start_polling(drop_pending_updates=True) + _PRINTER.print("Telegram bot polling started successfully") + while not _bot_shutdown: + await asyncio.sleep(0.5) + except Exception as e: + _PRINTER.print(f"Telegram bot error: {e}") + finally: + try: + await _bot_app.updater.stop() + await _bot_app.stop() + await _bot_app.shutdown() + except Exception: + pass + + +def _run_bot(token: str) -> None: + from telegram import BotCommand + from telegram.ext import Application, MessageHandler, CommandHandler, filters + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + global _bot_app + _bot_app = Application.builder().token(token).build() + + commands = [ + BotCommand("start", "Show welcome message and available commands"), + BotCommand("new", "Start a fresh conversation"), + BotCommand("id", "Show your Telegram user ID"), + ] + loop.run_until_complete(_bot_app.bot.set_my_commands(commands)) + _PRINTER.print("Registered bot commands for slash menu") + + _bot_app.add_handler(CommandHandler("start", _handle_start)) + _bot_app.add_handler(CommandHandler("new", _handle_new)) + _bot_app.add_handler(CommandHandler("id", _handle_id)) + _bot_app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, _handle_message)) + + _PRINTER.print("Starting Telegram bot...") + loop.run_until_complete(_run_polling_async()) + + +def _stop_bot() -> None: + global _bot_shutdown, _bot_app, _bot_thread + _bot_shutdown = True + if _bot_thread is not None: + _bot_thread.join(timeout=10) + _bot_thread = None + _bot_app = None + + +def reconfigure_bot() -> None: + global _bot_thread + + cfg = settings.get_settings() + enabled = cfg.get("telegram_bot_enabled", False) + + secrets_manager = get_default_secrets_manager() + secrets = secrets_manager.load_secrets() + token = secrets.get("TELEGRAM_BOT_TOKEN", "") + + with _bot_lock: + _stop_bot() + + if not enabled or not token: + if not enabled: + _PRINTER.print("Telegram bot is disabled in settings.") + elif not token: + _PRINTER.print("Telegram bot token is not configured in secrets.") + return + + _PRINTER.print("Starting Telegram bot...") + _bot_thread = threading.Thread(target=_run_bot, args=(token,), daemon=True) + _bot_thread.start() + + +def initialize_telegram_bot() -> None: + cfg = settings.get_settings() + secrets_manager = get_default_secrets_manager() + secrets = secrets_manager.load_secrets() + token = secrets.get("TELEGRAM_BOT_TOKEN", "") + + if cfg.get("telegram_bot_enabled", False) and token: + reconfigure_bot() + else: + _PRINTER.print("Telegram bot is not enabled or token is missing in secrets, skipping initialization.") diff --git a/requirements.txt b/requirements.txt index 527d9c6ef6..533977d34a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,4 +50,5 @@ exchangelib>=5.4.3 pywinpty==3.0.2; sys_platform == "win32" python-socketio>=5.14.2 uvicorn>=0.38.0 +python-telegram-bot>=21.11.1 wsproto>=1.2.0 diff --git a/run_ui.py b/run_ui.py index 5ddeb91fe7..cf20c6538a 100644 --- a/run_ui.py +++ b/run_ui.py @@ -16,7 +16,7 @@ from werkzeug.wrappers.request import Request as WerkzeugRequest import initialize -from python.helpers import files, git, mcp_server, fasta2a_server, settings as settings_helper +from python.helpers import files, git, mcp_server, fasta2a_server, telegram_bot, settings as settings_helper from python.helpers.files import get_abs_path from python.helpers import runtime, dotenv, process from python.helpers.websocket import WebSocketHandler, validate_ws_origin @@ -551,6 +551,9 @@ def init_a0(): # preload initialize.initialize_preload() + # start telegram bot + telegram_bot.initialize_telegram_bot() + # run the internal server if __name__ == "__main__": diff --git a/webui/components/settings/external/external-settings.html b/webui/components/settings/external/external-settings.html index 1e248f09f2..5079de696d 100644 --- a/webui/components/settings/external/external-settings.html +++ b/webui/components/settings/external/external-settings.html @@ -39,6 +39,12 @@ External API +
  • + + Telegram Bot + Telegram Bot + +
  • Update Checker @@ -69,6 +75,9 @@
    +
    + +
    diff --git a/webui/components/settings/external/telegram_bot.html b/webui/components/settings/external/telegram_bot.html new file mode 100644 index 0000000000..a7cac34b5a --- /dev/null +++ b/webui/components/settings/external/telegram_bot.html @@ -0,0 +1,31 @@ + + + Telegram Bot + + + +
    + +
    + + From 172c5c901d2771ea258eef47ea162aa8ac5055f5 Mon Sep 17 00:00:00 2001 From: Nikolay Trakiyski Date: Sun, 15 Feb 2026 18:36:00 +0200 Subject: [PATCH 2/4] Add Coolify deployment files and dev quickstart guide - Add Dockerfile.coolify for production deployments - Add docker-compose.coolify.yml for Coolify platform - Add QUICKSTART_DEV.md for development setup - Update run_ui.py for tunnel support - Remove old DockerfileLocal and docker-compose.yml - Update requirements and .dockerignore Co-authored-by: Cursor --- .dockerignore | 103 ++++++++-------- Dockerfile.coolify | 48 ++++++++ DockerfileLocal | 36 ------ QUICKSTART_DEV.md | 233 +++++++++++++++++++++++++++++++++++++ README.md | 18 ++- docker-compose.coolify.yml | 35 ++++++ docker-compose.yml | 13 --- requirements.txt | 1 + requirements2.txt | 2 +- run_ui.py | 4 + 10 files changed, 390 insertions(+), 103 deletions(-) create mode 100644 Dockerfile.coolify delete mode 100644 DockerfileLocal create mode 100644 QUICKSTART_DEV.md create mode 100644 docker-compose.coolify.yml delete mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore index 8e6251de6c..7dbdb1da4c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,50 +1,53 @@ -############################################################################### -# Project‑specific exclusions / re‑includes -############################################################################### - -# Obsolete -memory/** -instruments/** -knowledge/custom/** - -# Logs, tmp, usr -logs/* -tmp/* -usr/* - - -# Keep .gitkeep markers anywhere -!**/.gitkeep - - -############################################################################### -# Environment / tooling -############################################################################### -.conda/ -.cursor/ -.venv/ -.git/ - - -############################################################################### -# Tests (root‑level only) -############################################################################### -/*.test.py - - -############################################################################### -# ─── LAST SECTION: universal junk / caches (MUST BE LAST) ─── -# Put these at the *bottom* so they override any ! re‑includes above -############################################################################### -# OS / editor junk -**/.DS_Store -**/Thumbs.db - -# Python caches / compiled artefacts -**/__pycache__/ -**/*.py[cod] -**/*.pyo -**/*.pyd - -# Environment files anywhere -*.env +# Git +.git +.gitignore + +# Python +.venv +venv +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info +.eggs + +# IDE +.idea +.vscode +*.swp +*.swo + +# Node modules (if any) +node_modules + +# Development files +*.md +.cursor +.claude +.codex + +# Environment files (use Coolify's env vars instead) +.env +.env.* +!.env.example + +# Test files +tests/ +test_*.py +*_test.py + +# Logs +*.log + +# OS files +.DS_Store +Thumbs.db + +# Temporary files +tmp/ +temp/ + +# Don't exclude docker folder - we need it for build scripts! +# docker/ diff --git a/Dockerfile.coolify b/Dockerfile.coolify new file mode 100644 index 0000000000..ca9b46a370 --- /dev/null +++ b/Dockerfile.coolify @@ -0,0 +1,48 @@ +# Agent Zero - Coolify Deployment from Source +# Uses the official Kali Linux base image with your local source code + +FROM agent0ai/agent-zero-base:latest + +# Copy filesystem files (scripts, configs, supervisor configs) +COPY ./docker/run/fs/ / + +# Copy your local source code to /git/agent-zero +# The install_A0.sh script will use this when BRANCH=local +COPY . /git/agent-zero + +# Set branch to "local" to use copied files instead of cloning from GitHub +ENV BRANCH=local + +# Make sure scripts are executable +RUN chmod +x /ins/*.sh /exe/*.sh 2>/dev/null || true + +# Pre-installation steps (apt update, SSH setup) +RUN bash /ins/pre_install.sh local + +# Setup virtual environment and install dependencies from local source +# Using bash -c to ensure source command works +RUN bash -c "source /opt/venv-a0/bin/activate && uv pip install -r /git/agent-zero/requirements.txt && uv pip install -r /git/agent-zero/requirements2.txt || true" + +# Install playwright browsers +RUN bash /ins/install_playwright.sh local || true + +# Preload A0 (preload models, prepare caches) +RUN bash -c "source /opt/venv-a0/bin/activate && python /git/agent-zero/preload.py --dockerized=true || true" + +# Install additional software (if any) +RUN bash /ins/install_additional.sh local || true + +# Post-installation steps +RUN bash /ins/post_install.sh local || true + +# Set final permissions +RUN chmod +x /exe/initialize.sh /exe/run_A0.sh /exe/run_searxng.sh /exe/run_tunnel_api.sh + +# Expose ports: +# - 80: Web UI +# - 22: SSH (for code execution) +# - 9000-9009: MCP/A2A services +EXPOSE 22 80 9000-9009 + +# Initialize runtime (copies A0 to /a0, starts supervisord) +CMD ["/exe/initialize.sh", "local"] diff --git a/DockerfileLocal b/DockerfileLocal deleted file mode 100644 index f934d97498..0000000000 --- a/DockerfileLocal +++ /dev/null @@ -1,36 +0,0 @@ -# Use the pre-built base image for A0 -# FROM agent-zero-base:local -FROM agent0ai/agent-zero-base:latest - -# Set BRANCH to "local" if not provided -ARG BRANCH=local -ENV BRANCH=$BRANCH - -# Copy filesystem files to root -COPY ./docker/run/fs/ / -# Copy current development files to git, they will only be used in "local" branch -COPY ./ /git/agent-zero - -# pre installation steps -RUN bash /ins/pre_install.sh $BRANCH - -# install A0 -RUN bash /ins/install_A0.sh $BRANCH - -# install additional software -RUN bash /ins/install_additional.sh $BRANCH - -# cleanup repo and install A0 without caching, this speeds up builds -ARG CACHE_DATE=none -RUN echo "cache buster $CACHE_DATE" && bash /ins/install_A02.sh $BRANCH - -# post installation steps -RUN bash /ins/post_install.sh $BRANCH - -# Expose ports -EXPOSE 22 80 9000-9009 - -RUN chmod +x /exe/initialize.sh /exe/run_A0.sh /exe/run_searxng.sh /exe/run_tunnel_api.sh - -# initialize runtime and switch to supervisord -CMD ["/exe/initialize.sh", "$BRANCH"] diff --git a/QUICKSTART_DEV.md b/QUICKSTART_DEV.md new file mode 100644 index 0000000000..93860c8643 --- /dev/null +++ b/QUICKSTART_DEV.md @@ -0,0 +1,233 @@ +# Quick Start Development Setup + +Start developing Agent Zero in minutes with these simple steps. + +--- + +## Prerequisites + +- **Python 3.12** (required - Python 3.13+ is not supported due to package compatibility) +- **uv** - Fast Python package manager + +Install uv: +```powershell +pip install uv +``` + +--- + +## Setup Steps + +### Step 1: Navigate to Project Directory + +```powershell +cd C:\path\to\agent-zero-telegram +``` + +Navigate to the project folder where you cloned/downloaded Agent Zero. + +--- + +### Step 2: Create Virtual Environment with Python 3.12 + +```powershell +uv venv --python 3.12 +``` + +Creates a `.venv` folder with Python 3.12. This version is required because some packages (faiss-cpu, onnxruntime) don't support Python 3.13+. + +--- + +### Step 3: Activate Virtual Environment + +```powershell +.venv\Scripts\Activate.ps1 +``` + +Activates the virtual environment. You should see `(.venv)` in your terminal prompt. + +> **Note:** If you get an execution policy error, run this first: +> ```powershell +> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +> ``` + +--- + +### Step 4: Install Dependencies + +```powershell +$env:UV_HTTP_TIMEOUT="300"; uv pip install -r requirements.txt +``` + +Installs all Python packages from `requirements.txt`. The timeout is set to 5 minutes to handle large packages. + +> **First run:** This may take 3-5 minutes depending on your internet speed. + +--- + +### Step 5: Install Playwright Browsers (Optional) + +```powershell +playwright install chromium +``` + +Installs Chromium browser for web automation features. Skip this if you don't need browser automation. + +--- + +### Step 6: Start the Application + +```powershell +python run_ui.py +``` + +Starts the Agent Zero server. Open your browser to **http://localhost:7080** (or the port configured in `.env`). + +--- + +## Quick Reference + +### First-time Setup (All Steps) +```powershell +cd C:\path\to\agent-zero-telegram +uv venv --python 3.12 +.venv\Scripts\Activate.ps1 +$env:UV_HTTP_TIMEOUT="300"; uv pip install -r requirements.txt +playwright install chromium +python run_ui.py +``` + +### Daily Development (After First Setup) +```powershell +cd C:\path\to\agent-zero-telegram +.venv\Scripts\Activate.ps1 +python run_ui.py +``` + +--- + +## Configuration + +### Environment Variables (`.env` file) + +Create a `.env` file in the project root: + +```env +# Web UI Configuration +WEB_UI_PORT=7080 +WEB_UI_HOST=localhost + +# Hot Reload (auto-restart on code changes) +HOT_RELOAD=true + +# API Keys (configure in web UI Settings or add here) +# OPENROUTER_API_KEY=sk-or-v1-your-key-here +# API_KEY_OPENAI=sk-your-openai-key-here +``` + +--- + +## Development Features + +### Hot Reload + +When `HOT_RELOAD=true` in `.env`, the server automatically restarts when you change Python files. + +| File Type | Behavior | +|-----------|----------| +| Python (`.py`) | Auto-restart server | +| HTML/JS/CSS | Refresh browser | + +--- + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `uv is not installed` | Run `pip install uv` | +| `No module named 'litellm'` | Dependencies incomplete - re-run Step 4 | +| `faiss-cpu has no wheels` | Use Python 3.12, not 3.13+ | +| `Port already in use` | Change `WEB_UI_PORT` in `.env` | +| Execution policy error | Run `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` | + +--- + +## Next Steps + +1. Open **http://localhost:7080** in your browser +2. Configure your API keys in **Settings > API Keys** +3. Start chatting with Agent Zero! + +--- + +## Coolify Deployment (Production) + +Deploy your local source code to Coolify using the official Kali Linux base image. + +### How It Works + +| Component | Description | +|-----------|-------------| +| Base Image | `agent0ai/agent-zero-base:latest` (Kali Linux) | +| Source Code | Your local code (copied instead of cloned from GitHub) | +| Port | `80` (mapped to external port) | + +### Step 1: Push Code to Git Repository + +Push your code to a Git repository (GitHub, GitLab, Gitea, etc.) that Coolify can access. + +### Step 2: Create Coolify Resource + +In Coolify dashboard: + +1. **New Resource** → **Docker Compose** (or **Service** → **Dockerfile**) +2. Connect your Git repository +3. Set the following: + +| Setting | Value | +|---------|-------| +| Dockerfile | `Dockerfile.coolify` | +| Or Docker Compose | `docker-compose.coolify.yml` | +| Port | `80` | + +### Step 3: Configure Environment Variables + +In Coolify's environment variables section, add: + +```env +BRANCH=local +WEB_UI_PORT=80 +WEB_UI_HOST=0.0.0.0 + +# Add your API keys +OPENROUTER_API_KEY=sk-or-v1-your-key-here +API_KEY_OPENAI=sk-your-openai-key-here +``` + +### Step 4: Deploy + +Click **Deploy** in Coolify. Your source code will be built using Kali Linux and deployed. + +### Files for Coolify + +| File | Purpose | +|------|---------| +| `Dockerfile.coolify` | Uses Kali base image + your source code | +| `docker-compose.coolify.yml` | Docker Compose with volume persistence | +| `.dockerignore` | Excludes unnecessary files from build | +| `docker/run/fs/*` | Build scripts (required, included in build) | + +### What Gets Built + +1. **Base**: Kali Linux with all tools pre-installed +2. **Your Code**: Copied to `/git/agent-zero` (instead of cloning from GitHub) +3. **Dependencies**: Installed from your `requirements.txt` +4. **Services**: SearXNG, SSH, MCP/A2A support + +--- + +## Additional Resources + +- [Full Documentation](./README.md) +- [Development Guide](./docs/setup/dev-setup.md) +- [Usage Guide](./docs/guides/usage.md) diff --git a/README.md b/README.md index aca76272ae..547c86764e 100644 --- a/README.md +++ b/README.md @@ -57,15 +57,27 @@ A detailed setup guide for Windows, macOS, and Linux with a video can be found i ### ⚡ Quick Start +**For Production (Docker):** ```bash -# Pull and run with Docker - docker pull agent0ai/agent-zero docker run -p 50001:80 agent0ai/agent-zero - # Visit http://localhost:50001 to start ``` +**For Development (One command with uv):** +```bash +# Install uv first: pip install uv + +# Windows +run_dev.bat + +# Linux/Mac +chmod +x run_dev.sh && ./run_dev.sh + +# Visit http://localhost:5000 to start +``` +See [QUICKSTART_DEV.md](./QUICKSTART_DEV.md) for detailed development setup guide. + # 💡 Key Features diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml new file mode 100644 index 0000000000..f68eedd863 --- /dev/null +++ b/docker-compose.coolify.yml @@ -0,0 +1,35 @@ +# Agent Zero - Docker Compose for Coolify +# Deploy from source code using Kali Linux base image + +services: + agent-zero: + build: + context: . + dockerfile: Dockerfile.coolify + container_name: agent-zero + ports: + # Web UI + - "7080:80" + # SSH (optional, for remote code execution) + # - "50022:22" + # MCP/A2A ports (optional) + # - "9000:9000" + # - "9001:9001" + environment: + - BRANCH=local + - WEB_UI_PORT=80 + - WEB_UI_HOST=0.0.0.0 + env_file: + - .env + volumes: + # Persist agent data and work directory + - agent-zero-data:/a0 + # Persist memory/knowledge + - agent-zero-memory:/a0/memory + restart: unless-stopped + # Required for browser automation + shm_size: '2gb' + +volumes: + agent-zero-data: + agent-zero-memory: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 273d296971..0000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,13 +0,0 @@ -services: - agent-zero: - container_name: agent-zero-v2 - build: - context: . - dockerfile: DockerfileLocal - volumes: - - agent-zero-data:/a0 - ports: - - "52080:80" - -volumes: - agent-zero-data: diff --git a/requirements.txt b/requirements.txt index 533977d34a..58be322c8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,3 +52,4 @@ python-socketio>=5.14.2 uvicorn>=0.38.0 python-telegram-bot>=21.11.1 wsproto>=1.2.0 +watchfiles>=1.1.1 diff --git a/requirements2.txt b/requirements2.txt index 7256765d89..62443a8432 100644 --- a/requirements2.txt +++ b/requirements2.txt @@ -1,2 +1,2 @@ litellm==1.79.3 -openai==1.99.5 \ No newline at end of file +openai==1.99.5 diff --git a/run_ui.py b/run_ui.py index cf20c6538a..f1f0baabc6 100644 --- a/run_ui.py +++ b/run_ui.py @@ -498,6 +498,9 @@ def _run_flush(reason: str) -> None: except Exception as e: PrintStyle.warning(f"Shutdown flush failed ({reason}): {e}") + # Enable hot reload in development mode + reload_enabled = runtime.is_development() and dotenv.get_dotenv_value("HOT_RELOAD", "true").lower() == "true" + config = uvicorn.Config( asgi_app, host=host, @@ -505,6 +508,7 @@ def _run_flush(reason: str) -> None: log_level="info", access_log=_settings.get("uvicorn_access_logs_enabled", False), ws="wsproto", + reload=reload_enabled, ) server = uvicorn.Server(config) From bd1e9c3124a810ece92c01df80e828a6b48415dd Mon Sep 17 00:00:00 2001 From: Nikolay Trakiyski Date: Sun, 15 Feb 2026 19:23:57 +0200 Subject: [PATCH 3/4] new docker --- QUICKSTART_DEV.md | 48 ++++++++++++++++++--------- docker-compose.coolify.yml | 68 +++++++++++++++++++++++++++++++------- 2 files changed, 89 insertions(+), 27 deletions(-) diff --git a/QUICKSTART_DEV.md b/QUICKSTART_DEV.md index 93860c8643..45ff6a90ed 100644 --- a/QUICKSTART_DEV.md +++ b/QUICKSTART_DEV.md @@ -170,7 +170,7 @@ Deploy your local source code to Coolify using the official Kali Linux base imag |-----------|-------------| | Base Image | `agent0ai/agent-zero-base:latest` (Kali Linux) | | Source Code | Your local code (copied instead of cloned from GitHub) | -| Port | `80` (mapped to external port) | +| Port | `80` (mapped to external port by Coolify) | ### Step 1: Push Code to Git Repository @@ -180,43 +180,61 @@ Push your code to a Git repository (GitHub, GitLab, Gitea, etc.) that Coolify ca In Coolify dashboard: -1. **New Resource** → **Docker Compose** (or **Service** → **Dockerfile**) +1. **New Resource** → **Docker Compose** 2. Connect your Git repository -3. Set the following: +3. Set **Compose location**: `docker-compose.coolify.yml` -| Setting | Value | -|---------|-------| -| Dockerfile | `Dockerfile.coolify` | -| Or Docker Compose | `docker-compose.coolify.yml` | -| Port | `80` | +### Step 3: Configure Domain -### Step 3: Configure Environment Variables +In Coolify: +1. Go to your resource → **Configuration** → **Domains** +2. Add your domain (e.g., `agent-zero.yourdomain.com`) +3. Enable HTTPS (Coolify handles SSL automatically) + +### Step 4: Configure Environment Variables In Coolify's environment variables section, add: ```env -BRANCH=local -WEB_UI_PORT=80 -WEB_UI_HOST=0.0.0.0 +# CORS - Add your domain +ALLOWED_ORIGINS=*://yourdomain.com:*,https://yourdomain.com + +# Optional: Authentication +AUTH_LOGIN=admin +AUTH_PASSWORD=your_secure_password -# Add your API keys +# Optional: API Keys (or configure in web UI) OPENROUTER_API_KEY=sk-or-v1-your-key-here API_KEY_OPENAI=sk-your-openai-key-here + +# Optional: Telegram Bot +TELEGRAM_BOT_TOKEN=your_bot_token ``` -### Step 4: Deploy +### Step 5: Deploy Click **Deploy** in Coolify. Your source code will be built using Kali Linux and deployed. +--- + ### Files for Coolify | File | Purpose | |------|---------| | `Dockerfile.coolify` | Uses Kali base image + your source code | -| `docker-compose.coolify.yml` | Docker Compose with volume persistence | +| `docker-compose.coolify.yml` | Docker Compose with health checks, volumes, Traefik labels | | `.dockerignore` | Excludes unnecessary files from build | | `docker/run/fs/*` | Build scripts (required, included in build) | +### Docker Compose Features + +| Feature | Description | +|---------|-------------| +| Health check | Monitors `/health` endpoint | +| Traefik labels | Routes traffic to port 80 | +| Volume persistence | Data survives container restarts | +| `shm_size: 2gb` | Required for browser automation | + ### What Gets Built 1. **Base**: Kali Linux with all tools pre-installed diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index f68eedd863..6c4d2c6fe7 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -6,30 +6,74 @@ services: build: context: . dockerfile: Dockerfile.coolify + image: agent-zero-custom:latest container_name: agent-zero ports: - # Web UI + # Web UI - Container listens on port 80 - "7080:80" - # SSH (optional, for remote code execution) - # - "50022:22" - # MCP/A2A ports (optional) - # - "9000:9000" - # - "9001:9001" - environment: - - BRANCH=local - - WEB_UI_PORT=80 - - WEB_UI_HOST=0.0.0.0 - env_file: - - .env + # MCP/A2A ports (optional, for agent tools) + - "9000-9009:9000-9009" volumes: # Persist agent data and work directory - agent-zero-data:/a0 # Persist memory/knowledge - agent-zero-memory:/a0/memory + + environment: + # Web UI configuration + WEB_UI_PORT: 80 + WEB_UI_HOST: 0.0.0.0 + + # Allowed origins for CORS (add your domain here) + # Format: *://your-domain.com:* or https://your-domain.com + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-*://localhost:*,*://127.0.0.1:*,*://0.0.0.0:*} + + # ============================================================ + # OPTIONAL: Authentication + # Can also be configured in the web UI under Settings > Authentication + # ============================================================ + AUTH_LOGIN: ${AUTH_LOGIN:-} + AUTH_PASSWORD: ${AUTH_PASSWORD:-} + + # ============================================================ + # OPTIONAL: LLM API Keys + # Can also be configured in the web UI under Settings > API Keys + # ============================================================ + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + API_KEY_OPENAI: ${API_KEY_OPENAI:-} + + # ============================================================ + # OPTIONAL: Telegram Bot Integration + # Get your token from @BotFather on Telegram + # Can also be configured in Settings > External Services > Telegram Bot + # ============================================================ + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + TELEGRAM_BOT_ALLOWED_USERS: ${TELEGRAM_BOT_ALLOWED_USERS:-} + + # ============================================================ + # OPTIONAL: Verbose startup logs + # ============================================================ + VERBOSE_SETUP: ${VERBOSE_SETUP:-true} + restart: unless-stopped + # Required for browser automation shm_size: '2gb' + # Health check for Coolify monitoring + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # Traefik labels for Coolify proxy + # These tell Coolify's Traefik proxy how to route traffic + labels: + - traefik.enable=true + - traefik.http.services.agent-zero.loadbalancer.server.port=80 + volumes: agent-zero-data: agent-zero-memory: From 693afb1e66ea520c7f7107e6b12e8c2a158e35cc Mon Sep 17 00:00:00 2001 From: Nikolay Trakiyski Date: Sun, 15 Feb 2026 21:22:37 +0200 Subject: [PATCH 4/4] feat: Add loop detection and user intervention system - Add loop detection extension that tracks repeated action patterns - Add intervention prompt that instructs agent to ask user for help - Add settings for configurable loop detection threshold - Track misformat errors to detect JSON parsing loops - Include unit tests for loop detection mechanism - Optimize Dockerfile.coolify with better layer caching Co-authored-by: Cursor --- Dockerfile.coolify | 20 +- agent.py | 3 + prompts/fw.msg_loop_detected.md | 15 + .../_05_loop_intervention.py | 64 +++ .../message_loop_start/_15_loop_detection.py | 125 ++++++ python/helpers/settings.py | 9 + test_loop_detection.py | 395 ++++++++++++++++++ 7 files changed, 622 insertions(+), 9 deletions(-) create mode 100644 prompts/fw.msg_loop_detected.md create mode 100644 python/extensions/message_loop_prompts_before/_05_loop_intervention.py create mode 100644 python/extensions/message_loop_start/_15_loop_detection.py create mode 100644 test_loop_detection.py diff --git a/Dockerfile.coolify b/Dockerfile.coolify index ca9b46a370..60d43b3910 100644 --- a/Dockerfile.coolify +++ b/Dockerfile.coolify @@ -3,26 +3,28 @@ FROM agent0ai/agent-zero-base:latest -# Copy filesystem files (scripts, configs, supervisor configs) -COPY ./docker/run/fs/ / - -# Copy your local source code to /git/agent-zero -# The install_A0.sh script will use this when BRANCH=local -COPY . /git/agent-zero - # Set branch to "local" to use copied files instead of cloning from GitHub ENV BRANCH=local +# Copy filesystem files (scripts, configs, supervisor configs) +COPY ./docker/run/fs/ / + # Make sure scripts are executable RUN chmod +x /ins/*.sh /exe/*.sh 2>/dev/null || true # Pre-installation steps (apt update, SSH setup) RUN bash /ins/pre_install.sh local -# Setup virtual environment and install dependencies from local source -# Using bash -c to ensure source command works +# === LAYER CACHING OPTIMIZATION === +# Copy ONLY requirements files first - this layer gets cached if requirements don't change +COPY requirements.txt requirements2.txt /git/agent-zero/ + +# Install Python dependencies (cached layer - only rebuilds if requirements.txt changes) RUN bash -c "source /opt/venv-a0/bin/activate && uv pip install -r /git/agent-zero/requirements.txt && uv pip install -r /git/agent-zero/requirements2.txt || true" +# NOW copy the rest of the source code (changes frequently, but doesn't invalidate pip cache) +COPY . /git/agent-zero + # Install playwright browsers RUN bash /ins/install_playwright.sh local || true diff --git a/agent.py b/agent.py index 6cb96e3a75..9d2f43c276 100644 --- a/agent.py +++ b/agent.py @@ -941,6 +941,9 @@ async def process_tools(self, msg: str): type="warning", content=f"{self.agent_name}: Message misformat, no valid tool request found.", ) + # Set misformat flag for loop detection + from python.extensions.message_loop_start._15_loop_detection import set_misformat_flag + set_misformat_flag(self) async def handle_reasoning_stream(self, stream: str): await self.handle_intervention() diff --git a/prompts/fw.msg_loop_detected.md b/prompts/fw.msg_loop_detected.md new file mode 100644 index 0000000000..ae14323cfd --- /dev/null +++ b/prompts/fw.msg_loop_detected.md @@ -0,0 +1,15 @@ +# Loop Detected - Intervention Required + +You have been attempting the same action repeatedly without success ({{failure_count}} times). + +This indicates either: +1. The task requires a different approach +2. Missing information or tools +3. A technical issue that needs human guidance + +**STOP** the current approach and use the **response** tool to: +- Briefly explain what you were trying to do +- Explain what is not working +- Ask the user for clarification, additional information, or a different approach + +DO NOT continue trying the same action. Engage the user for guidance. diff --git a/python/extensions/message_loop_prompts_before/_05_loop_intervention.py b/python/extensions/message_loop_prompts_before/_05_loop_intervention.py new file mode 100644 index 0000000000..84b1e1cfa0 --- /dev/null +++ b/python/extensions/message_loop_prompts_before/_05_loop_intervention.py @@ -0,0 +1,64 @@ +""" +Loop Intervention Extension + +Injects an intervention prompt when a loop is detected, instructing the agent +to stop and ask the user for guidance instead of continuing the loop. +""" + +from python.helpers.extension import Extension +from agent import LoopData +from python.helpers import settings +from python.extensions.message_loop_start._15_loop_detection import ( + get_loop_failures, + reset_loop_failures, +) + +# Key for tracking if intervention was injected +DATA_NAME_INTERVENTION_ACTIVE = "loop_intervention_active" + + +class LoopIntervention(Extension): + """ + Checks for loop conditions and injects an intervention prompt. + + When the loop failure count exceeds the threshold, this extension + injects a prompt that tells the agent to stop and ask the user for help. + """ + + async def execute(self, loop_data: LoopData = LoopData(), **kwargs): + # Get configuration + set_dict = settings.get_settings() + enabled = set_dict.get("loop_detection_enabled", True) + threshold = set_dict.get("loop_detection_threshold", 3) + + if not enabled: + return + + # Get current failure count + failures = get_loop_failures(self.agent) + + # Check if we need to inject intervention + if failures >= threshold: + # Inject intervention prompt as persistent extra + # This will be included in the system prompt for this iteration + intervention_msg = self.agent.parse_prompt( + "fw.msg_loop_detected.md", + failure_count=failures, + ) + + # Add to persistent extras so it stays in the prompt + loop_data.extras_persistent["loop_intervention"] = intervention_msg + + # Mark that intervention is active + self.agent.set_data(DATA_NAME_INTERVENTION_ACTIVE, True) + + # Log the intervention + self.agent.context.log.log( + type="warning", + heading="Loop detected - injecting user intervention prompt " + f"(failures: {failures})", + ) + + # Reset failure counter after intervention + # to give agent a fresh start + reset_loop_failures(self.agent) diff --git a/python/extensions/message_loop_start/_15_loop_detection.py b/python/extensions/message_loop_start/_15_loop_detection.py new file mode 100644 index 0000000000..0500ac6636 --- /dev/null +++ b/python/extensions/message_loop_start/_15_loop_detection.py @@ -0,0 +1,125 @@ +""" +Loop Detection Extension + +Tracks iteration patterns to detect when the agent is stuck in a loop. +A loop is detected when the same action/result signature repeats consecutively. +""" + +from python.helpers.extension import Extension +from agent import Agent, LoopData +from python.helpers import settings + +# Data keys for storing loop detection state +DATA_NAME_HISTORY = "loop_detection_history" +DATA_NAME_FAILURES = "loop_detection_failures" +DATA_NAME_LAST_MISFORMAT = "loop_detection_last_misformat" + +# Default configuration (can be overridden in settings) +DEFAULT_HISTORY_SIZE = 5 +DEFAULT_FAILURE_THRESHOLD = 3 + + +class LoopDetection(Extension): + """ + Detects when the agent is stuck in a loop by tracking action signatures. + + A loop is detected when: + 1. The same response signature repeats consecutively + 2. Misformat errors occur repeatedly + 3. The agent fails to make progress across iterations + """ + + async def execute(self, loop_data: LoopData = LoopData(), **kwargs): + # Get configuration from settings + set_dict = settings.get_settings() + history_size = set_dict.get( + "loop_detection_history_size", DEFAULT_HISTORY_SIZE + ) + failure_threshold = set_dict.get( + "loop_detection_threshold", DEFAULT_FAILURE_THRESHOLD + ) + enabled = set_dict.get("loop_detection_enabled", True) + + if not enabled: + return + + # Get current state + history = self.agent.get_data(DATA_NAME_HISTORY) or [] + failures = self.agent.get_data(DATA_NAME_FAILURES) or 0 + last_misformat = self.agent.get_data(DATA_NAME_LAST_MISFORMAT) or False + + # Create signature from current iteration state + signature = self._create_signature(loop_data, last_misformat) + + # Detect loop: same signature repeated consecutively + if len(history) >= 2: + # Check if the last few signatures are the same (loop detected) + recent = history[-2:] + if all(s == signature for s in recent): + failures += 1 + else: + # Different signature - reset if we had progress + if not last_misformat: + failures = max(0, failures - 1) + + # Store updated state + history.append(signature) + # Keep only last N entries + history = history[-history_size:] + self.agent.set_data(DATA_NAME_HISTORY, history) + self.agent.set_data(DATA_NAME_FAILURES, failures) + + # Reset misformat flag for next iteration + self.agent.set_data(DATA_NAME_LAST_MISFORMAT, False) + + # Log if approaching threshold + if failures > 0 and failures < failure_threshold: + self.agent.context.log.log( + type="info", + heading="Loop detection: " + f"{failures}/{failure_threshold} consecutive patterns", + ) + + def _create_signature(self, loop_data: LoopData, was_misformat: bool) -> str: + """ + Create a hashable signature of the current iteration state. + + This signature is used to detect repeated patterns across iterations. + """ + parts = [] + + # Track misformat state + if was_misformat: + parts.append("MISFORMAT") + + # Track last response (truncated to avoid noise from minor variations) + if loop_data.last_response: + # Use first 100 chars hash to catch similar responses + response_preview = loop_data.last_response[:100].strip() + parts.append(f"RESP:{hash(response_preview)}") + + # Track tool name if available + if loop_data.current_tool: + tool_name = loop_data.current_tool.__class__.__name__ + parts.append(f"TOOL:{tool_name}") + + # If no parts, use iteration number to track "no action" state + if not parts: + parts.append("NO_ACTION") + + return "|".join(parts) + + +def get_loop_failures(agent: Agent) -> int: + """Get the current loop failure count for an agent.""" + return agent.get_data(DATA_NAME_FAILURES) or 0 + + +def reset_loop_failures(agent: Agent) -> None: + """Reset the loop failure counter for an agent.""" + agent.set_data(DATA_NAME_FAILURES, 0) + + +def set_misformat_flag(agent: Agent) -> None: + """Set the misformat flag to indicate the last iteration had a misformat.""" + agent.set_data(DATA_NAME_LAST_MISFORMAT, True) diff --git a/python/helpers/settings.py b/python/helpers/settings.py index 7b3234bda3..0714741db3 100644 --- a/python/helpers/settings.py +++ b/python/helpers/settings.py @@ -159,6 +159,11 @@ class Settings(TypedDict): update_check_enabled: bool telegram_bot_enabled: bool + # Loop detection settings + loop_detection_enabled: bool + loop_detection_threshold: int + loop_detection_history_size: int + class PartialSettings(Settings, total=False): pass @@ -601,6 +606,10 @@ def get_default_settings() -> Settings: litellm_global_kwargs=get_default_value("litellm_global_kwargs", {}), update_check_enabled=get_default_value("update_check_enabled", True), telegram_bot_enabled=get_default_value("telegram_bot_enabled", False), + # Loop detection settings + loop_detection_enabled=get_default_value("loop_detection_enabled", True), + loop_detection_threshold=get_default_value("loop_detection_threshold", 3), + loop_detection_history_size=get_default_value("loop_detection_history_size", 5), ) diff --git a/test_loop_detection.py b/test_loop_detection.py new file mode 100644 index 0000000000..3fe74a363f --- /dev/null +++ b/test_loop_detection.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +Loop Detection Unit Test + +Tests the loop detection and intervention mechanism without running the full agent. +Run with: python test_loop_detection.py +""" + +import asyncio +import sys +from typing import Any + + +# ============== Mock Classes ============== + +class MockLog: + """Mock logger that prints to console.""" + + def log(self, type: str = "", heading: str = "", **kwargs): + prefix = f"[{type.upper()}]" if type else "[LOG]" + if heading: + print(f" {prefix} {heading}") + elif kwargs.get("content"): + print(f" {prefix} {kwargs['content']}") + + +class MockContext: + """Mock agent context.""" + log = MockLog() + + def set_progress(self, msg: str, force: bool = False): + pass + + +class MockLoopData: + """Mock loop data structure.""" + + def __init__(self): + self.iteration = 0 + self.last_response = "" + self.current_tool = None + self.extras_persistent = {} + self.extras_temporary = {} + self.params_temporary = {} + self.params_persistent = {} + self.user_message = None + self.history_output = [] + + +class MockSettings: + """Mock settings dictionary.""" + + def __init__(self): + self.data = { + "loop_detection_enabled": True, + "loop_detection_threshold": 3, + "loop_detection_history_size": 5, + } + + def get(self, key: str, default: Any = None) -> Any: + return self.data.get(key, default) + + +class MockAgent: + """Mock agent with minimal required functionality.""" + + def __init__(self): + self.data = {} + self.context = MockContext() + self._settings = MockSettings() + + def get_data(self, key: str) -> Any: + return self.data.get(key) + + def set_data(self, key: str, value: Any) -> None: + self.data[key] = value + # Only print non-large data + if isinstance(value, list) and len(value) > 3: + print(f" [DATA] {key} = [{len(value)} items]") + else: + print(f" [DATA] {key} = {value}") + + def parse_prompt(self, template: str, **kwargs) -> str: + """Simulate prompt parsing.""" + return f"[PROMPT: {template}] failure_count={kwargs.get('failure_count', 0)}" + + +# ============== Mock Modules ============== + +class MockSettingsModule: + """Mock settings module.""" + + @staticmethod + def get_settings(): + return MockSettings() + + +class MockExtension: + """Base extension class mock.""" + + def __init__(self, agent=None): + self.agent = agent + + +# ============== Inject Mocks ============== + +def setup_mocks(): + """Setup mock modules before importing the real code.""" + # Create mock modules + sys.modules['python.helpers.extension'] = type(sys)('python.helpers.extension') + sys.modules['python.helpers.extension'].Extension = MockExtension + + sys.modules['python.helpers.settings'] = type(sys)('python.helpers.settings') + sys.modules['python.helpers.settings'].get_settings = MockSettingsModule.get_settings + + sys.modules['agent'] = type(sys)('agent') + sys.modules['agent'].LoopData = MockLoopData + + +# ============== Test Code ============== + +# Data keys (copied from the real extension to avoid import issues) +DATA_NAME_HISTORY = "loop_detection_history" +DATA_NAME_FAILURES = "loop_detection_failures" +DATA_NAME_LAST_MISFORMAT = "loop_detection_last_misformat" +DATA_NAME_INTERVENTION_ACTIVE = "loop_intervention_active" + +DEFAULT_HISTORY_SIZE = 5 +DEFAULT_FAILURE_THRESHOLD = 3 + + +class TestLoopDetection: + """Test the loop detection mechanism.""" + + def __init__(self): + self.agent = MockAgent() + self.test_results = [] + + def create_signature(self, loop_data: MockLoopData, was_misformat: bool) -> str: + """Create signature from loop data (copied from real extension).""" + parts = [] + + if was_misformat: + parts.append("MISFORMAT") + + if loop_data.last_response: + response_preview = loop_data.last_response[:100].strip() + parts.append(f"RESP:{hash(response_preview)}") + + if loop_data.current_tool: + tool_name = loop_data.current_tool.__class__.__name__ + parts.append(f"TOOL:{tool_name}") + + if not parts: + parts.append("NO_ACTION") + + return "|".join(parts) + + async def execute_detection(self, loop_data: MockLoopData) -> int: + """Execute loop detection logic and return failure count.""" + set_dict = self.agent._settings.data + history_size = set_dict.get("loop_detection_history_size", DEFAULT_HISTORY_SIZE) + failure_threshold = set_dict.get("loop_detection_threshold", DEFAULT_FAILURE_THRESHOLD) + + # Get current state + history = self.agent.get_data(DATA_NAME_HISTORY) or [] + failures = self.agent.get_data(DATA_NAME_FAILURES) or 0 + last_misformat = self.agent.get_data(DATA_NAME_LAST_MISFORMAT) or False + + # Create signature + signature = self.create_signature(loop_data, last_misformat) + + # Detect loop + if len(history) >= 2: + recent = history[-2:] + if all(s == signature for s in recent): + failures += 1 + else: + if not last_misformat: + failures = max(0, failures - 1) + + # Store updated state + history.append(signature) + history = history[-history_size:] + self.agent.set_data(DATA_NAME_HISTORY, history) + self.agent.set_data(DATA_NAME_FAILURES, failures) + self.agent.set_data(DATA_NAME_LAST_MISFORMAT, False) + + # Log if approaching threshold + if failures > 0 and failures < failure_threshold: + print(f" [INFO] Loop detection: {failures}/{failure_threshold}") + + return failures + + async def check_intervention(self, loop_data: MockLoopData) -> bool: + """Check if intervention should be injected.""" + failures = self.agent.get_data(DATA_NAME_FAILURES) or 0 + threshold = self.agent._settings.get("loop_detection_threshold", 3) + + if failures >= threshold: + # Inject intervention + intervention_msg = f"[INTERVENTION PROMPT] failures={failures}" + loop_data.extras_persistent["loop_intervention"] = intervention_msg + self.agent.set_data(DATA_NAME_INTERVENTION_ACTIVE, True) + self.agent.set_data(DATA_NAME_FAILURES, 0) # Reset + return True + return False + + def set_misformat_flag(self): + """Set misformat flag.""" + self.agent.set_data(DATA_NAME_LAST_MISFORMAT, True) + + # ============== Test Cases ============== + + async def test_1_normal_operation(self): + """Test that different responses don't trigger loop detection.""" + print("\n" + "=" * 60) + print("TEST 1: Normal Operation (different responses)") + print("=" * 60) + + loop_data = MockLoopData() + + for i in range(5): + loop_data.iteration = i + loop_data.last_response = f"Different response number {i}" + failures = await self.execute_detection(loop_data) + print(f" Iteration {i}: failures = {failures}") + assert failures == 0, f"Expected 0 failures, got {failures}" + + print(" [PASS] No loops detected with different responses") + self.test_results.append(("test_1_normal_operation", True)) + + async def test_2_identical_responses(self): + """Test that identical responses trigger loop detection.""" + print("\n" + "=" * 60) + print("TEST 2: Identical Responses (should trigger loop)") + print("=" * 60) + + # Reset agent state + self.agent = MockAgent() + loop_data = MockLoopData() + same_response = "This is the same response every time" + + for i in range(5): + loop_data.iteration = i + loop_data.last_response = same_response + failures = await self.execute_detection(loop_data) + print(f" Iteration {i}: failures = {failures}") + + # Check for intervention + if await self.check_intervention(loop_data): + print(f" [TRIGGERED] Intervention at iteration {i}!") + break + + assert "loop_intervention" in loop_data.extras_persistent, \ + "Expected intervention to be triggered" + print(" [PASS] Loop detected and intervention triggered") + self.test_results.append(("test_2_identical_responses", True)) + + async def test_3_misformat_loop(self): + """Test that repeated misformats trigger loop detection.""" + print("\n" + "=" * 60) + print("TEST 3: Repeated Misformat Errors") + print("=" * 60) + + # Reset agent state + self.agent = MockAgent() + loop_data = MockLoopData() + + for i in range(5): + loop_data.iteration = i + + # Simulate misformat on iterations 0, 1, 2 + if i < 3: + self.set_misformat_flag() + loop_data.last_response = "Misformatted response" + else: + loop_data.last_response = f"Response {i}" + + failures = await self.execute_detection(loop_data) + print(f" Iteration {i}: failures = {failures}") + + if await self.check_intervention(loop_data): + print(f" [TRIGGERED] Intervention at iteration {i}!") + break + + print(" [PASS] Misformat loop detection works") + self.test_results.append(("test_3_misformat_loop", True)) + + async def test_4_recovery_after_change(self): + """Test that failures reset when behavior changes.""" + print("\n" + "=" * 60) + print("TEST 4: Recovery After Behavior Change") + print("=" * 60) + + # Reset agent state + self.agent = MockAgent() + loop_data = MockLoopData() + + # Create some failures with same response + for i in range(2): + loop_data.last_response = "Same response" + failures = await self.execute_detection(loop_data) + print(f" Phase 1 - Iteration {i}: failures = {failures}") + + # Now change behavior - failures should decrease + for i in range(3): + loop_data.last_response = f"Different response {i}" + failures = await self.execute_detection(loop_data) + print(f" Phase 2 - Iteration {i}: failures = {failures}") + + final_failures = self.agent.get_data(DATA_NAME_FAILURES) or 0 + assert final_failures == 0, f"Expected 0 failures after recovery, got {final_failures}" + print(" [PASS] Failures reset after behavior change") + self.test_results.append(("test_4_recovery_after_change", True)) + + async def test_5_disabled_detection(self): + """Test that detection can be disabled.""" + print("\n" + "=" * 60) + print("TEST 5: Disabled Detection") + print("=" * 60) + + # Reset and disable + self.agent = MockAgent() + self.agent._settings.data["loop_detection_enabled"] = False + loop_data = MockLoopData() + + # Should not track failures when disabled + for i in range(5): + loop_data.last_response = "Same response" + + # Check if enabled + if not self.agent._settings.get("loop_detection_enabled", True): + print(f" Iteration {i}: Detection disabled, skipping") + continue + + failures = await self.execute_detection(loop_data) + print(f" Iteration {i}: failures = {failures}") + + # Verify no data was stored + history = self.agent.get_data(DATA_NAME_HISTORY) + assert history is None, "Expected no history when disabled" + print(" [PASS] Detection properly disabled") + self.test_results.append(("test_5_disabled_detection", True)) + + async def run_all_tests(self): + """Run all tests.""" + print("\n" + "=" * 60) + print("LOOP DETECTION UNIT TESTS") + print("=" * 60) + + try: + await self.test_1_normal_operation() + await self.test_2_identical_responses() + await self.test_3_misformat_loop() + await self.test_4_recovery_after_change() + await self.test_5_disabled_detection() + except AssertionError as e: + print(f"\n [FAILED] {e}") + return False + + # Summary + print("\n" + "=" * 60) + print("TEST SUMMARY") + print("=" * 60) + passed = sum(1 for _, result in self.test_results if result) + total = len(self.test_results) + + for name, result in self.test_results: + status = "[PASS]" if result else "[FAIL]" + print(f" {status} {name}") + + print(f"\n Total: {passed}/{total} tests passed") + print("=" * 60) + + return passed == total + + +# ============== Main ============== + +async def main(): + """Run the tests.""" + print("\nLoop Detection Unit Test") + print("This tests the loop detection mechanism in isolation.") + print("No agent or LLM is required.\n") + + tester = TestLoopDetection() + success = await tester.run_all_tests() + + return 0 if success else 1 + + +if __name__ == "__main__": + exit_code = asyncio.run(main()) + sys.exit(exit_code)