diff --git a/docs/res/usage/webui/project-active-chat.png b/docs/res/usage/webui/project-active-chat.png
index 91b3f27ffe80c8cce0f052cdcfe797bbaee603bc..e6bba3e839f4a6edcb3add21f049c0a00140bbdd 100644
Binary files a/docs/res/usage/webui/project-active-chat.png and b/docs/res/usage/webui/project-active-chat.png differ
diff --git a/docs/res/usage/webui/project-create-filled.png b/docs/res/usage/webui/project-create-filled.png
index 4e927401205235983bcaa3cbef82ba615689a390..a6848bea9c0089bb4cdd1681a39457cedea13fee 100644
Binary files a/docs/res/usage/webui/project-create-filled.png and b/docs/res/usage/webui/project-create-filled.png differ
diff --git a/docs/res/usage/webui/project-instructions-filled.png b/docs/res/usage/webui/project-instructions-filled.png
index 5a6c035e5e8f5b22d6be6fca2b3de9587d733049..3a0677284a4f47510232d70946f5aef2538a280a 100644
Binary files a/docs/res/usage/webui/project-instructions-filled.png and b/docs/res/usage/webui/project-instructions-filled.png differ
diff --git a/docs/res/usage/webui/project-picker.png b/docs/res/usage/webui/project-picker.png
index 969799eef577e548dd9d0a1fa6b2a6663dee2845..cea753bfeda7e1cd3e40003f688582be4fab418e 100644
Binary files a/docs/res/usage/webui/project-picker.png and b/docs/res/usage/webui/project-picker.png differ
diff --git a/docs/res/usage/webui/projects-empty.png b/docs/res/usage/webui/projects-empty.png
index 57546dee9c5d6fb81ef2eecbde7e9d632afed42d..94ab70c26ffcb72b67ecd1cfe2f7e77e37464f9f 100644
Binary files a/docs/res/usage/webui/projects-empty.png and b/docs/res/usage/webui/projects-empty.png differ
diff --git a/docs/res/usage/webui/projects-list-created.png b/docs/res/usage/webui/projects-list-created.png
index 4ea2009741c14340d18751f24e0244ae8894a463..f75e64bfde61d20666a8eabcf43878e19f911c31 100644
Binary files a/docs/res/usage/webui/projects-list-created.png and b/docs/res/usage/webui/projects-list-created.png differ
diff --git a/docs/res/usage/webui/skills-selector-checked.png b/docs/res/usage/webui/skills-selector-checked.png
index b19aa9c29b9692d02717614ab099026015842d1c..fb30458d2a369a2fcdd0240032a9e783b6be14de 100644
Binary files a/docs/res/usage/webui/skills-selector-checked.png and b/docs/res/usage/webui/skills-selector-checked.png differ
diff --git a/docs/res/usage/webui/skills-selector.png b/docs/res/usage/webui/skills-selector.png
index 5bbfa73f17731262a0f589df75c01af37a016c37..5063638685361fee94459c895ba544691bd020a0 100644
Binary files a/docs/res/usage/webui/skills-selector.png and b/docs/res/usage/webui/skills-selector.png differ
diff --git a/docs/res/usage/webui/unread-dot-chat-list.png b/docs/res/usage/webui/unread-dot-chat-list.png
index 0d089aba26c1b9587b6302a2c00acf43d8527e11..1b0622cde689a9a01ab3d3c359d0b5d6d9a984b7 100644
Binary files a/docs/res/usage/webui/unread-dot-chat-list.png and b/docs/res/usage/webui/unread-dot-chat-list.png differ
diff --git a/docs/setup/dev-setup.md b/docs/setup/dev-setup.md
index 9cec423d7ca9190c3c86d619efc869f0d70e16b3..d1390d0de75c17ad56bae225b2b852f12d050943 100644
--- a/docs/setup/dev-setup.md
+++ b/docs/setup/dev-setup.md
@@ -1,3 +1,190 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:71d5be20f1035b01e37236efe2d1baedaf829baf99e575cf841b9db2d47f96b6
-size 12391
+# Development manual for Agent Zero
+This guide will show you how to setup a local development environment for Agent Zero in a VS Code compatible IDE, including proper debugger.
+
+
+[](https://www.youtube.com/watch?v=KE39P4qBjDk)
+
+
+
+> [!WARNING]
+> This guide is for developers and contributors. It assumes you have a basic understanding of how to use Git/GitHub, Docker, IDEs and Python.
+
+> [!NOTE]
+> - Agent Zero runs in a Docker container, this simplifies installation and ensures unified environment and behavior across systems.
+> - Developing and debugging in a container would be complicated though, therefore we use a hybrid approach where the python framework runs on your machine (in VS Code for example) and only connects to a Dockerized instance when it needs to execute code or use other pre-installed functionality like the built-in search engine.
+
+
+## To follow this guide you will need:
+1. VS Code compatible IDE (VS Code, Cursor, Windsurf...)
+2. Python environment (Conda, venv, uv...)
+3. Docker (Docker Desktop, docker-ce...)
+4. (optional) Git/GitHub
+
+> [!NOTE]
+> I will be using clean VS Code, Conda and Docker Desktop in this example on MacOS.
+
+
+## Step 0: Install required software
+- See the list above and install the software required if you don't already have it.
+- You can choose your own variants, but Python, Docker and a VS Code compatible IDE are required.
+- For Python you can choose your environment manager - base Python venv, Conda, uv...
+
+## Step 1: Clone or download the repository
+- Agent Zero is available on GitHub [github.com/agent0ai/agent-zero](https://github.com/agent0ai/agent-zero).
+- You can download the files using a browser and extract or run `git clone https://github.com/agent0ai/agent-zero` in your desired directory.
+
+> [!NOTE]
+> In my case, I used `cd ~/Desktop` and `git clone https://github.com/agent0ai/agent-zero`, so my project folder is `~/Desktop/agent-zero`.
+
+## Step 2: Open project folder in your IDE
+- I will be using plain and clean VS Code for this example to make sure I don't skip any setup part, you can use any of it's variants like Cursor, Windsurf etc.
+- Agent Zero comes with `.vscode` folder that contains basic setup, recommended extensions, and debugger profiles. These will help us a lot.
+
+1. Open your IDE and open the project folder using `File > Open Folder` and select your folder, in my case `~/Desktop/agent-zero`.
+2. You will probably be prompted to trust the directory, confirm that.
+3. You should now have the project open in your IDE
+
+
+# Step 3: Prepare your IDE:
+1. Notice the prompt in lower right corner of the screenshot above to install recommended extensions, this comes from the `.vscode/extensions.json` file. It contains Python language support, debugger and error helper, install them by confirming the popup or manually in Extensions tab of your IDE. These are the extensions mentioned:
+```
+usernamehw.errorlens
+ms-python.debugpy
+ms-python.python
+```
+
+Now when you select one of the python files in the project, you should see proper Python syntax highlighting and error detection. It should immediately show some errors, because we did not yet install dependencies.
+
+
+2. Prepare the python environment to run Agent Zero in. This step assumes you have some Python runtime installed. By clicking the python version in lower right corner (3.13.1 in my example), you should get a list of available environments. You can click the `+ Create Virtual Environment` button. You might be prompted to select the environment manager if you have multiple installed. I have venv and Conda, I will select Conda here. I'm also prompted for desired python version, I will select 3.12, that is known to work well.
+
+
+
+- Your new environment should be automatically activated. If not, select it in the lower right corner. You might need to open a new terminal in VS Code to reflect the changes with `Terminal > New Terminal` or clicking the `+` button in the terminal tab. Your terminal prompt should now start with your environment name/path, in my case `(/Users/frdel/Desktop/agent-zero/.conda)` This shows the environment is active in the terminal.
+
+
+
+3. Install dependencies. Run these commands from the project root:
+
+```bash
+pip install -r requirements.txt
+PLAYWRIGHT_BROWSERS_PATH=./tmp/playwright patchright install chromium --no-shell
+```
+
+The first command installs Python dependencies.
+
+The second command installs full Patchright Chromium into `./tmp/playwright`,
+relative to the project root. Docker images use the absolute path
+`/a0/tmp/playwright` and ship Chromium preinstalled.
+
+If you skip the second command, local development can still download Chromium on
+first Browser use through `ensure_playwright_binary()` in
+`plugins/_browser/helpers/playwright.py`. Pre-installing avoids that wait.
+
+See the [Browser Guide](../guides/browser.md) for the Browser surface,
+screenshots, annotations, and host-browser mode.
+Errors in the code editor caused by missing packages should now be gone. If not, try reloading the window.
+
+
+## Step 4: Run Agent Zero in the IDE
+Great work! Now you should be able to run Agent Zero from your IDE including real-time debugging.
+It will not be able to do code execution and few other features requiring the Docker container just yet, but most of the framework will already work.
+
+1. The project is pre-configured for debugging. Go to Debugging tab, select "run_ui.py" and click the green play button (or press F5 by default). The configuration can be found at `.vscode/launch.json`.
+
+
+
+The framework will run at the default port 5000. If you open `http://localhost:5000` in your browser and see `ERR_EMPTY_RESPONSE`, don't panic, you may need to select another port like I did for some reason. If you need to change the default port, you can add `"--port=5555"` to the args in the `.vscode/launch.json` file or you can create a `.env` file in the root directory and set the `WEB_UI_PORT` variable to the desired port.
+
+You can also set the bind host via `"--host=0.0.0.0"` (or `WEB_UI_HOST=0.0.0.0`).
+
+It may take a while the first time. You should see output like the screenshot below. The RFC error is ok for now as we did not yet connect our local development to another instance in docker.
+
+
+
+After inserting my API key in settings, my Agent Zero instance works. I can send a simple message and get a response.
+Some tools like code execution will not work yet because they need to be connected to a Dockerized instance.
+
+
+
+
+## Debugging
+- You can try out the debugger already by placing a breakpoint somewhere in the python code.
+- Let's open `python/api/message.py` for example and place a breakpoint at the beginning of the `communicate` function by clicking on the left of the row number. A red dot should appear showing a breakpoint is set.
+
+
+
+- Now when I send a message in the UI, the debugger will pause the execution at the breakpoint and allow me to inspect all the runtime variables and run the code step by step, even modify the variables or jump to another locations in the code. No more print statements needed!
+
+
+
+
+## Step 5: Run another instance of Agent Zero in Docker
+- Some parts of A0 require standardized linux environment, additional web services and preinstalled binaries that would be unneccessarily complex to set up in a local environment.
+- To make development easier, we can use existing A0 instance in docker and forward some requests to be executed there using SSH and RFC (Remote Function Call).
+
+1. Pull the docker image `agent0ai/agent-zero` from Docker Hub and run it with a web port (`80`) mapped and SSH port (`22`) mapped.
+If you want, you can also map the `/a0` folder to our local project folder as well, this way we can update our local instance and the docker instance at the same time.
+This is how it looks in my example: port `80` is mapped to `8880` on the host and `22` to `8822`, `/a0` folder mapped to `/Users/frdel/Desktop/agent-zero`:
+
+
+
+
+
+## Step 6: Configure SSH and RFC connection
+- The last step is to configure the local development (VS Code) instance and the dockerized instance to communicate with each other. This is very simple and can be done in the settings in the Web UI of both instances.
+- In my example the dark themed instance is the VS Code one, the light themed one is the dockerized instance.
+
+1. Open the "Settings" page in the Web UI of your dockerized instance and go in the "Development" section.
+2. Set the `RFC Password` field to a new password and save.
+3. Open the "Settings" page in the Web UI of your local instance and go in the "Development" section.
+4. Here set the `RFC Password` field to the same password you used in the dockerized instance. Also set the SSH port and HTTP port the same numbers you used when creating the container - in my case `8822` for SSH and `8880` for HTTP. The `RFC Destination URL` will most probably stay `localhost` as both instances are running on the host machine.
+5. Click save and test by asking your agent to do something in the terminal, like "Get current OS version". It should be able to communicate with the dockerized instance via RFC and SSH and execute the command there, responding with something like "Kali GNU/Linux Rolling".
+
+My Dockerized instance:
+
+
+My VS Code instance:
+
+
+## RFC Notes (Host IDE + Docker Execution)
+Agent Zero runs code inside the container by default. If you are running the framework locally in your IDE but want tools (like code execution) to run in Docker, configure RFC in **Settings -> Development** and point it to a running Agent Zero container. This routes execution through SSH/RFC to the container while keeping the UI and agent loop on your host.
+
+
+# Congratulations!
+
+You have successfully set up a complete Agent Zero development environment! You now have:
+
+- A local development instance running in your IDE with full debugging capabilities
+- A dockerized instance for code execution and system operations
+- RFC and SSH communication between both instances
+- The ability to develop, debug, and test Agent Zero features seamlessly
+
+You're now ready to contribute to Agent Zero, create custom extensions, or modify the framework to suit your needs. Happy coding!
+
+
+## Next steps
+- See [Create a Small Plugin](../guides/create-plugin.md) before building a new plugin.
+- Use [DeepWiki for Agent Zero](https://deepwiki.com/agent0ai/agent-zero) for architecture and source-linked internals.
+- See [Contributing to Agent Zero](../guides/contribution.md) for contribution basics.
+
+## Configuration via Environment Variables
+
+For development and testing, you can override default settings using the `.env` file with `A0_SET_` prefixed variables:
+
+```env
+# Add to your .env file
+A0_SET_chat_model_provider=ollama
+A0_SET_chat_model_name=llama3.2
+A0_SET_chat_model_api_base=http://localhost:11434
+A0_SET_memory_recall_interval=5
+```
+
+These environment variables automatically override the hardcoded defaults in `get_default_settings()` without modifying code. Useful for testing different configurations or multi-environment setups.
+
+## Want to build your docker image?
+- You can use the `DockerfileLocal` to build your docker image.
+- Navigate to your project root in the terminal and run `docker build -f DockerfileLocal -t agent-zero-local --build-arg CACHE_DATE=$(date +%Y-%m-%d:%H:%M:%S) .`
+- The `CACHE_DATE` argument is optional, it is used to cache most of the build process and only rebuild the last steps when the files or dependencies change.
+- See `docker/run/build.txt` for more build command examples.
+- Automated Docker Hub publishing for release tags is handled by `.github/workflows/docker-publish.yml`. The latest eligible `main` tag generates its GitHub release body on the fly from commit subjects and descriptions via OpenRouter.
diff --git a/docs/setup/installation.md b/docs/setup/installation.md
index dfe606e29dc6f6148828ace04e5d3ad0d24fb5cf..b0e826750e4d8143bb7b555a08164d23ecbd0f44 100644
--- a/docs/setup/installation.md
+++ b/docs/setup/installation.md
@@ -1,3 +1,871 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:22679137b4317a0dafc352dca421aa13cd208db65b9b9deb843c6198730cfd6f
-size 35606
+# Installation Guide
+
+## **Goal:** Go from zero to a first working chat with minimal setup.
+
+---
+
+## Quick Start (Recommended)
+
+Agent Zero runs as a Docker container, and you now have two friendly ways to get
+there:
+
+- **A0 Launcher** is the desktop app. It can download Agent Zero, create and
+ manage Instances, and help set up the local container runtime when needed.
+- **A0 Install** is the terminal installer. It is best for SSH sessions,
+ servers, scripted setup, recovery shells, or users who prefer commands.
+
+If Docker is already installed and running, you can also start the container
+directly.
+
+### A0 Launcher
+
+Use **A0 Launcher** when you want the guided desktop path. Download the app for
+your platform, open it, and let it check Docker or set up a runtime before it
+downloads Agent Zero.
+
+#### Downloads
+
+| Architecture | macOS | Linux | Windows |
+| --- | --- | --- | --- |
+| x86 | [Mac Intel](https://github.com/agent0ai/a0-launcher/releases/download/v0.9/a0-launcher-0.9-macos-x64.dmg) | [Linux x86](https://github.com/agent0ai/a0-launcher/releases/download/v0.9/a0-launcher-0.9-linux-x64.AppImage) | [Windows x86](https://github.com/agent0ai/a0-launcher/releases/download/v0.9/a0-launcher-0.9-windows-x64.exe) |
+| ARM64 | [Mac Apple Silicon](https://github.com/agent0ai/a0-launcher/releases/download/v0.9/a0-launcher-0.9-macos-arm64.dmg) | [Linux ARM64](https://github.com/agent0ai/a0-launcher/releases/download/v0.9/a0-launcher-0.9-linux-arm64.AppImage) | [Windows ARM64](https://github.com/agent0ai/a0-launcher/releases/download/v0.9/a0-launcher-0.9-windows-arm64.exe) |
+
+See the [A0 Launcher v0.9 release](https://github.com/agent0ai/a0-launcher/releases/tag/v0.9)
+for release notes and updater metadata. See the
+[Launcher guide](../guides/launcher.md) for the first-run walkthrough.
+
+### A0 Install
+
+Use **A0 Install** when you want the command-line path. The installer creates a
+Dockerized Agent Zero instance, mounts user data to `/a0/usr`, and tries to
+reuse an existing Docker-compatible runtime before setting one up.
+
+#### macOS / Linux
+```bash
+curl -fsSL https://bash.agent-zero.ai | bash
+```
+
+#### Windows PowerShell
+```powershell
+irm https://ps.agent-zero.ai | iex
+```
+
+#### Headless / scripted
+
+For servers and automation, Quick Start mode creates one instance and exits
+without opening menus:
+
+```bash
+curl -fsSL https://bash.agent-zero.ai | bash -s -- --quick-start --name agent-zero --port 5080
+```
+
+```powershell
+& ([scriptblock]::Create((irm https://ps.agent-zero.ai))) -QuickStart -Name agent-zero -Port 5080
+```
+
+Use `--skip-runtime-setup` / `-SkipRuntimeSetup` when Docker must already be
+working and the installer should not try to set up a runtime. See the
+[A0 Install repository](https://github.com/agent0ai/a0-install) for all
+installer flags.
+
+### Docker already installed? Run this directly
+
+```bash
+docker run -p 80:80 -v a0_usr:/a0/usr agent0ai/agent-zero
+```
+
+Once the install completes, open the URL shown in your terminal or Launcher to
+access the Web UI. Complete onboarding, add your model provider or API key, then
+continue to [Step 3: Configure Agent Zero](#step-3-configure-agent-zero).
+
+> [!TIP]
+> Need Agent Zero to reach host-machine files, shell, or a host browser? Install the optional [A0 CLI Connector](../guides/a0-cli-connector.md), then run `a0` to connect your terminal to this Agent Zero instance.
+
+---
+
+## How to Update Agent Zero
+
+### Self Update (Recommended)
+
+Use the built-in updater in the Web UI:
+
+1. Open **Settings UI -> Update** tab
+2. Open **Self Update**
+3. Wait for the update checker to see if you have the latest version or if there's an available update.
+
+You'll also be prompted through the UI when a new A0 version is released. Backups are automatically managed internally during this process.
+
+For technical details of the updater, see [Self Update](../guides/self-update.md).
+
+### Updating from v1.20 to v2.0
+
+Agent Zero v2.0 starts a new major release line. If your instance is on v1.20,
+the in-app Self Update can show the newer v2.x line, but it will not apply that
+jump inside the existing v1 Docker image. The safe path is:
+
+1. Create a backup zip from the old v1.20 instance.
+2. Pull the new `agent0ai/agent-zero:latest` Docker image. For the v2.0 release,
+ `latest` is the v2.0 image.
+3. Start a new container from that image.
+4. Restore the backup zip into the new v2.0 instance.
+
+
+
+#### Without Agent Zero Launcher
+
+Use this path if you manage Agent Zero directly from Docker Desktop or Docker
+CLI.
+
+1. Open your old v1.20 Web UI and create a backup from **Settings -> Check for Updates -> Backup & Restore -> Create Backup**. Keep the downloaded `.zip` file.
+2. Pull the v2.0 image. In **Docker Desktop**, search for `agent0ai/agent-zero:latest` and pull that image. In **Docker CLI**, run:
+ ```bash
+ docker pull agent0ai/agent-zero:latest
+ ```
+3. Start a new v2.0 container on a different host port so the old instance stays available:
+ ```bash
+ docker run -d -p 50081:80 --name agent-zero-v2 -v a0_v2_usr:/a0/usr agent0ai/agent-zero:latest
+ ```
+4. Open the new v2.0 instance, complete any first-run prompts, then restore the downloaded `.zip` from **Settings -> Check for Updates -> Backup & Restore -> Restore Backup**.
+5. Verify chats, projects, memory, settings, and custom plugins before removing the old v1.20 container.
+
+#### With Agent Zero Launcher
+
+Launcher gives you the same backup/restore idea from the **Instances** page.
+
+1. Open **Instances**, choose the old v1.20 Instance, and use **Backup `/a0/usr`**.
+2. Open **Installs**, use the **latest** card, then **Install** or **Run** the image. For the v2.0 release, **latest** is the v2.0 image.
+3. Return to **Instances**, choose the new v2.0 Instance, and use **Restore `/a0/usr`** with the backup zip.
+4. Open the new Instance and verify it before deleting or stopping the old v1.20 container.
+
+Launcher keeps old and new Instances visible separately, which makes it easier
+to compare them before cleanup.
+
+> [!CAUTION]
+> Do not try to solve the v1.20 -> v2.0 jump by bind-mounting the whole old
+> `/a0` directory into a new container. Keep user data under `/a0/usr`, use the
+> backup/restore flow, and let the new image provide the v2.0 system files.
+
+### Updating from Pre-v0.9.8
+
+If you are upgrading from Agent Zero v0.9.8 or earlier to v1.1 or newer, use the migration path below. Older installs were laid out differently, so the in-app Self Update is not the right tool for that jump.
+
+1. **Backup your existing `usr/` directory** (which contains your settings, projects, memory, and custom plugins).
+2. **Run the new install script** to set up the current Docker-based install:
+ - macOS / Linux: `curl -fsSL https://bash.agent-zero.ai | bash`
+ - Windows (PowerShell): `irm https://ps.agent-zero.ai | iex`
+3. **Migrate your data:** After the new installation completes, copy the contents of your backed-up `usr/` directory into the new `/a0/usr/` directory created by the script.
+4. Restart the container for the changes to take effect.
+
+### Manual Update (Advanced)
+
+> Use this only if Self Update is unavailable or you must manage containers yourself (for example, some custom Docker setups).
+
+1. Keep the current container running
+2. `docker pull agent0ai/agent-zero:latest`
+3. Start a **new** container on a different host port, for example: `docker run -d -p 50081:80 --name agent-zero-new agent0ai/agent-zero:latest`
+4. On the **old** instance: **Settings -> Check for Updates -> Backup & Restore -> Create Backup**
+5. On the **new** instance: restore the downloaded backup zip
+6. Verify chats and data, then remove the old container
+
+> [!CAUTION]
+> Do not delete the old container until the new one has your data.
+
+> [!TIP]
+> If the new instance fails to load settings, remove `/a0/usr/settings.json` and restart to regenerate default settings.
+
+---
+
+## Manual Installation (Advanced)
+
+> Users should use [Quick Start (Recommended)](#quick-start-recommended) above. The steps below are for custom Docker setups, air-gapped installs, or when you cannot use the install scripts.
+
+Follow the steps below to install Docker and run the image by hand.
+
+### Step 1: Install Docker Desktop
+
+Docker Desktop provides the runtime environment for Agent Zero, ensuring consistent behavior and security across platforms. The entire framework runs within a Docker container, providing isolation and easy deployment.
+
+**Choose your operating system:**
+
+
+
+---
+
+
+#### Windows Installation
+
+**1.1. Download Docker Desktop**
+
+Go to the [Docker Desktop download page](https://www.docker.com/products/docker-desktop/) and download the Windows version (Intel/AMD is the main download button).
+
+
+
+
+**1.2. Run the Installer**
+
+Run the installer with default settings.
+
+
+
+
+
+**1.3. Launch Docker Desktop**
+
+Once installed, launch Docker Desktop from your Start menu or desktop shortcut.
+
+
+
+**Docker is now installed.**
+
+Continue to [Step 2: Run Agent Zero](#step-2-run-agent-zero)
+
+---
+
+
+#### macOS Installation
+
+**1.1. Download Docker Desktop**
+
+Go to the [Docker Desktop download page](https://www.docker.com/products/docker-desktop/) and download the macOS version (choose Apple Silicon or Intel based on your Mac).
+
+
+
+
+**1.2. Install Docker Desktop**
+
+Drag and drop the Docker application to your Applications folder.
+
+
+
+
+**1.3. Launch Docker Desktop**
+
+Open Docker Desktop from your Applications folder.
+
+
+
+
+**1.4. Configure Docker Socket**
+
+> [!NOTE]
+> **Important macOS Configuration:** In Docker Desktop's preferences (Docker menu) -> Settings -> Advanced, enable "Allow the default Docker socket to be used (requires password)."
+
+
+
+**Docker is now installed.**
+
+Continue to [Step 2: Run Agent Zero](#step-2-run-agent-zero)
+
+---
+
+
+#### Linux Installation
+
+**1.1. Choose Installation Method**
+
+You can install either Docker Desktop or docker-ce (Community Edition).
+
+**Option A: Docker Desktop (Recommended for beginners)**
+
+Follow the instructions for your specific Linux distribution [here](https://docs.docker.com/desktop/install/linux-install/).
+
+**Option B: docker-ce (Lightweight alternative)**
+
+Follow the installation instructions [here](https://docs.docker.com/engine/install/).
+
+**1.2. Post-Installation Steps (docker-ce only)**
+
+If you installed docker-ce, add your user to the `docker` group:
+
+```bash
+sudo usermod -aG docker $USER
+```
+
+Log out and back in, then authenticate:
+
+```bash
+docker login
+```
+
+**1.3. Launch Docker**
+
+If you installed Docker Desktop, launch it from your applications menu.
+
+**Docker is now installed.**
+
+> [!TIP]
+> **Deploying on a VPS/Server?** For production deployments with reverse proxy, SSL, and domain configuration, see the [VPS Deployment Guide](vps-deployment.md).
+
+---
+
+### Step 2: Run Agent Zero
+
+#### 2.1. Pull the Agent Zero Docker Image
+
+**Using Docker Desktop GUI:**
+
+- Search for `agent0ai/agent-zero` in Docker Desktop
+- Click the `Pull` button
+- The image will be downloaded to your machine in a few minutes
+
+
+
+**Using Terminal:**
+
+```bash
+docker pull agent0ai/agent-zero
+```
+
+#### 2.2. (Optional) Map Folders for Persistence
+
+Choose or create a folder on your computer where Agent Zero will save its data.
+
+Setting up persistence is needed only if you want your data and files to remain available even after you delete the container.
+
+You can pick any location you find convenient:
+
+- **Windows:** `C:\agent-zero-data`
+- **macOS/Linux:** `/home/user/agent-zero-data`
+
+You can map just the `/a0/usr` directory (recommended) or individual subfolders of `/a0` to a local directory.
+
+> [!CAUTION]
+> Do **not** map the entire `/a0` directory: it contains the application code and can break upgrades.
+
+> [!TIP]
+> Choose a location that's easy to access and backup. All your Agent Zero data will be directly accessible in this directory.
+
+#### 2.3. Run the Container
+
+**Using Docker Desktop GUI:**
+
+- In Docker Desktop, go to the "Images" tab
+- Click the `Run` button next to the `agent0ai/agent-zero` image
+- Open the "Optional settings" menu
+- **Ensure at least one host port is mapped to container port `80`** (set host port to `0` for automatic assignment)
+- Click the `Run` button
+
+
+
+
+The container will start and show in the "Containers" tab:
+
+
+
+#### 2.4. Access the Web UI
+
+The framework will take a few seconds to initialize. Find the mapped port in Docker Desktop (shown as `:80`) or click the port right under the container ID:
+
+
+
+Open `http://localhost:` in your browser. The Web UI will open - Agent Zero is ready for configuration!
+
+
+
+> [!TIP]
+> You can also access the Web UI by clicking the port link directly under the container ID in Docker Desktop.
+
+> [!NOTE]
+> After starting the container, you'll find all Agent Zero files in your chosen directory. You can access and edit these files directly on your machine, and the changes will be immediately reflected in the running container.
+
+**Running A0 using Terminal?**
+
+```bash
+docker run -p 0:80 -v /path/to/your/work_dir:/a0/usr agent0ai/agent-zero
+```
+
+- Replace `0` with a fixed port if you prefer (e.g., `50080:80`)
+
+---
+
+## Step 3: Configure Agent Zero
+
+The UI opens on the welcome screen. If model setup is missing, send a message
+or use the setup shortcuts to choose Cloud, AI account, or Local access, then
+select your main and utility models. For the screenshot walkthrough, see the
+[First-Run Onboarding guide](../guides/onboarding.md).
+
+### Settings Configuration
+
+Agent Zero provides a comprehensive settings interface to customize various aspects of its functionality. Access the settings by clicking the "Settings" button with a gear icon in the sidebar.
+
+### Agent Configuration
+
+- **Agent Profile:** Select the default profile for new chats, such as `agent0`,
+ `hacker`, or `researcher`.
+- **Memory Subdirectory:** Select the subdirectory for agent memory storage, allowing separation between different instances.
+- **Knowledge Subdirectory:** Specify the location of custom knowledge files to enhance the agent's understanding.
+
+See the [Agent Profiles guide](../guides/agent-profiles.md) for profile
+switching, the Easy and Advanced editors, capability policy, and project
+availability.
+
+> [!NOTE]
+> Since v0.9.7, custom prompts belong inside a specific agent profile rather
+> than a shared `/prompts` folder. Most users should use **Manage agents** and
+> the Agent Editor instead of editing files directly.
+
+> [!NOTE]
+> The Hacker profile is included in the main image. After launch, choose the **hacker** agent profile in Settings to make it the default for new chats, or switch the selected chat from the composer profile selector. The "hacker" branch is deprecated.
+
+
+
+### Chat Model Settings
+
+- **Provider:** Select the chat model provider (e.g., Anthropic)
+- **Model Name:** Choose the specific model (e.g., claude-sonnet-4-5)
+- **Context Length:** Set the maximum token limit for context window
+- **Context Window Space:** Configure how much of the context window is dedicated to chat history
+
+
+
+**Model naming is provider-specific.**
+
+Use `claude-sonnet-4-5` for Anthropic, but use `anthropic/claude-sonnet-4-5` for OpenRouter. If you see "Invalid model ID," verify the provider and naming format on the provider website, or search the web for " model naming".
+
+> [!TIP]
+> **Context window tuning:** Set the total context window size first (for example, 100k), then adjust the chat history portion as a fraction of that total. A large fraction on a very large context window can still be enormous.
+
+> [!TIP]
+> **API URL:** URL of the API endpoint for the chat model - only needed for some providers like Ollama, LM Studio, Azure, etc.
+
+### Utility Model Configuration
+
+- **Provider & Model:** Select a model for utility tasks like memory organization and summarization
+- **Temperature:** Adjust the determinism of utility responses
+
+> [!NOTE]
+> Utility models need to be strong enough to extract and consolidate memory reliably. Very small models (e.g., 4B) often fail at this; 70B-class models or high-quality cloud "flash/mini" models work best.
+
+### Embedding Model Settings [Optional]
+
+- **Provider:** Choose the embedding model provider (e.g., OpenAI)
+- **Model Name:** Select the specific embedding model (e.g., text-embedding-3-small)
+
+> [!NOTE]
+> Agent Zero uses a local embedding model by default (runs on CPU), but you can switch to OpenAI embeddings like `text-embedding-3-small` or `text-embedding-3-large` if preferred.
+
+### Built-in Voice Plugins
+
+- Agent Zero ships Whisper STT as the built-in `_whisper_stt` plugin and Kokoro TTS as the built-in `_kokoro_tts` plugin.
+- Docker/bootstrap remains responsible for installing the required speech dependencies such as `ffmpeg`, Kokoro, Whisper, and `soundfile`.
+- Both plugins can be enabled or disabled independently from the Agent Plugins section in the Web UI.
+- Whisper model size, language, message handling, and silence behavior are configured from the plugin settings screen.
+- If `_kokoro_tts` is disabled, spoken output falls back to the browser's native speech synthesis instead of the container runtime.
+
+### API Keys
+
+Configure API keys for various service providers directly within the Web UI. Click `Save` to confirm your settings.
+
+> [!NOTE]
+> **OpenAI API vs Plus subscription:** A ChatGPT Plus subscription does not include API credits. You must provide a separate API key for OpenAI usage in Agent Zero.
+
+> [!TIP]
+> For OpenAI-compatible providers (e.g., custom gateways or Z.AI/GLM), add the API key under **External Services -> Other OpenAI-compatible API keys**, then select **OpenAI Compatible** as the provider in model settings.
+
+> [!CAUTION]
+> **GitHub Copilot Provider:** When using the GitHub Copilot provider, after selecting the model and entering your first prompt, the OAuth login procedure will begin. You'll find the authentication code and link in the output logs. Complete the authentication process by following the provided link and entering the code, then you may continue using Agent Zero.
+
+### Authentication
+
+- **UI Login:** Set username for web interface access
+- **UI Password:** Configure password for web interface security
+- **Root Password:** Manage Docker container root password for SSH access
+
+
+
+### Development Settings
+
+- **RFC Parameters (local instances only):** Configure URLs and ports for remote function calls between instances
+- **RFC Password:** Configure password for remote function calls
+
+Learn more about Remote Function Calls in the [Development Setup guide](dev-setup.md#step-6-configure-ssh-and-rfc-connection).
+
+> [!IMPORTANT]
+> Always keep your API keys and passwords secure.
+
+> [!NOTE]
+> On Windows host installs (non-Docker), you must use RFC to run shell code on the host system. The Docker runtime handles this automatically.
+
+---
+
+## Choosing Your LLMs
+
+The Settings page is the control center for selecting the Large Language Models (LLMs) that power Agent Zero. You can choose different LLMs for different roles:
+
+| LLM Role | Description |
+| --- | --- |
+| `chat_llm` | This is the primary LLM used for conversations, agent reasoning, and tool use. Vision support controls image understanding. |
+| `utility_llm` | This LLM handles internal tasks like summarizing messages, managing memory, and processing internal prompts. Using a smaller, less expensive model here can improve efficiency. |
+| `embedding_llm` | The embedding model shipped with A0 runs on CPU and is responsible for generating embeddings used for memory retrieval and knowledge base lookups. Changing the `embedding_llm` will re-index all of A0's memory. |
+
+**How to Change:**
+
+1. Open Settings page in the Web UI.
+2. Choose the provider for the LLM for each role (Main Model, Utility Model, Embedding Model) and write the model name.
+3. Click "Save" to apply the changes.
+
+> [!NOTE]
+> The built-in Browser does not have a separate default model slot. The main agent decides when to call the direct `browser` tool. Browser settings can optionally choose a Browser LLM preset for Browser-owned helper operations.
+
+### Important Considerations
+
+#### Model Naming by Provider
+
+Use the naming format required by your selected provider:
+
+| Provider | Model Name Format | Example |
+| --- | --- | --- |
+| OpenAI | Model name only | `claude-sonnet-4-5` |
+| OpenRouter | Provider prefix mostly required | `anthropic/claude-sonnet-4-5` |
+| Ollama | Model name only | `gpt-oss:20b` |
+| oMLX | API-visible model name from `/v1/models` | `Qwen3-0.6B-4bit` |
+| llama.cpp | API-visible model name from `/v1/models` or `--alias` | `local-gguf` |
+| vLLM | Hugging Face model ID or served model alias | `Qwen/Qwen2.5-1.5B-Instruct` |
+
+> [!TIP]
+> If you see "Invalid model ID," verify the provider and naming format on the provider website, or search the web for " model naming".
+
+#### Local Model Server Addresses From Docker
+
+When Agent Zero runs in Docker, `localhost` and `127.0.0.1` inside an API base URL mean the Agent Zero container, not your host machine. For a model server running on the host, use `http://host.docker.internal:` when available, or the Docker host gateway address such as `http://172.17.0.1:` on the default Linux bridge.
+
+If the model server only listens on host loopback, for example `127.0.0.1:`, the container still cannot reach it through the gateway. Configure the local server to listen on a Docker-reachable address such as `0.0.0.0`, and keep that port limited to trusted clients.
+
+#### Context Window & Memory Split
+
+- Set the **total context window** (e.g., 100k) first.
+- Then tune the **chat history portion** as a fraction of that total.
+- Extremely large totals can make even small fractions very large; adjust thoughtfully.
+
+#### Utility Model Guidance
+
+- Utility models handle summarization and memory extraction.
+- Very small models (about 4B) usually fail at reliable context extraction.
+- Aim for ~70B class models or strong cloud "flash/mini" models for better results.
+
+#### Reasoning/Thinking Models
+
+- Reasoning can increase cost and latency. Some models perform better **without** reasoning.
+- If a model supports it, disable reasoning via provider-specific parameters (e.g., Venice `disable_thinking=true`).
+
+---
+
+## Installing and Using oMLX (Apple Silicon Local Models)
+
+oMLX is a local inference server for Apple Silicon Macs. It serves MLX models through an OpenAI-compatible API and supports chat, embeddings, and model listing endpoints.
+
+> [!NOTE]
+> oMLX requires Apple Silicon and macOS 15+. On 16 GB machines, start with small quantized MLX models.
+
+### macOS oMLX Installation
+
+**Using Homebrew:**
+
+```bash
+brew tap jundot/omlx https://github.com/jundot/omlx
+brew install omlx
+omlx start
+```
+
+**Using the macOS App:**
+
+Download the oMLX app from the [official website](https://omlx.ai/) and follow the welcome flow to choose a model directory, start the server, and download or discover models.
+
+By default, oMLX serves its OpenAI-compatible API at `http://localhost:8000/v1`.
+
+To run a foreground server with oMLX's paged SSD cache enabled:
+
+```bash
+omlx serve --model-dir ~/.omlx/models --paged-ssd-cache-dir ~/.omlx/cache
+```
+
+### Configuring oMLX in Agent Zero
+
+1. Start oMLX and make sure at least one model is available in the oMLX dashboard or model directory.
+2. In Agent Zero Settings, choose **oMLX** as the Chat model, Utility model, or Embedding model provider.
+3. Use the model name shown by oMLX's model list or dashboard.
+4. Agent Zero includes Docker-friendly defaults for oMLX on the host at `http://host.docker.internal:8000/v1`. Override the API base URL only if your oMLX server runs somewhere else.
+5. Click `Save` to confirm your settings.
+
+> [!NOTE]
+> If Agent Zero runs in Docker and oMLX runs on the Mac host, ensure port **8000** is reachable from the container. The shipped Docker Compose file maps `host.docker.internal` to the host gateway for Linux Docker. Docker Desktop for macOS provides this hostname automatically.
+
+---
+
+## Installing and Using llama.cpp (GGUF Local Models)
+
+llama.cpp provides `llama-server`, a lightweight OpenAI-compatible HTTP server for GGUF models. Agent Zero talks to it through the same `/v1` API used by OpenAI-compatible clients.
+
+### macOS llama.cpp Installation
+
+**Using Homebrew:**
+
+```bash
+brew install llama.cpp
+```
+
+Start a server with a downloaded GGUF model:
+
+```bash
+llama-server -m ~/models/model.gguf --port 8080 --alias local-gguf
+```
+
+By default, Agent Zero expects llama.cpp at `http://host.docker.internal:8080/v1`. The model name can be the model path returned by `/v1/models`, but using `--alias` gives you a short stable name such as `local-gguf`.
+
+### Configuring llama.cpp in Agent Zero
+
+1. Start `llama-server` and confirm `http://localhost:8080/v1/models` returns your model.
+2. In Agent Zero Settings, choose **llama.cpp** as the Chat model, Utility model, or Embedding model provider.
+3. Use the model ID shown by `/v1/models`, or the alias you passed with `--alias`.
+4. Override the API base URL only if you started `llama-server` on another host or port.
+5. Click `Save` to confirm your settings.
+
+> [!NOTE]
+> If Agent Zero runs in Docker and cannot reach a host-side `llama-server`, start the server on an address Docker can reach, for example `--host 0.0.0.0`, and keep the port firewalled to trusted clients.
+
+---
+
+## Installing and Using vLLM (Local OpenAI-Compatible Serving)
+
+vLLM is a high-throughput local inference server with an OpenAI-compatible API. It is most common on Linux GPU hosts, and can also run on Apple Silicon through the vLLM Apple Silicon path or vLLM-Metal.
+
+For Apple Silicon Macs, install and activate vLLM-Metal:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/vllm-project/vllm-metal/main/install.sh | bash
+source ~/.venv-vllm-metal/bin/activate
+```
+
+Start a basic OpenAI-compatible server:
+
+```bash
+vllm serve Qwen/Qwen2.5-1.5B-Instruct --host 0.0.0.0 --port 8000
+```
+
+By default, Agent Zero expects vLLM at `http://host.docker.internal:8000/v1`, matching vLLM's default HTTP port. If another local provider already uses port 8000, start vLLM on another port and update Agent Zero's API base, for example `http://host.docker.internal:8001/v1`.
+
+### Configuring vLLM in Agent Zero
+
+1. Start vLLM and confirm `http://localhost:8000/v1/models` returns the served model.
+2. In Agent Zero Settings, choose **vLLM** as the Chat model, Utility model, or Embedding model provider.
+3. Use the model ID returned by vLLM's model list endpoint.
+4. If you started vLLM with `--api-key`, enter the same key in the advanced provider settings or environment.
+5. Click `Save` to confirm your settings.
+
+> [!NOTE]
+> vLLM serves one model at a time by default. Use a generation model for Chat and Utility slots, and a separate embedding-capable vLLM server if you want vLLM embeddings.
+
+---
+
+## Installing and Using Ollama (Local Models)
+
+Ollama is a powerful tool that allows you to run various large language models locally.
+
+---
+
+
+### Windows Ollama Installation
+
+Download and install Ollama from the official website:
+
+
+
+Once installed, continue to [Pulling Models](#pulling-models).
+
+---
+
+
+### macOS Ollama Installation
+
+**Using Homebrew:**
+
+```bash
+brew install ollama
+```
+
+**Using Installer:**
+
+Download from the [official website](https://ollama.com/).
+
+Once installed, continue to [Pulling Models](#pulling-models).
+
+---
+
+
+### Linux Ollama Installation
+
+Run the installation script:
+
+```bash
+curl -fsSL https://ollama.com/install.sh | sh
+```
+
+Once installed, continue to [Pulling Models](#pulling-models).
+
+---
+
+### Pulling Models
+
+**Finding Model Names:**
+
+Visit the [Ollama model library](https://ollama.com/library) for a list of available models and their corresponding names. Ollama models are referenced by **model name only** (for example, `llama3.2`).
+
+**Pull a model:**
+
+```bash
+ollama pull
+```
+
+Replace `` with the name of the model you want to use. For example: `ollama pull mistral-large`
+
+### Configuring Ollama in Agent Zero
+
+1. Once you've downloaded your model(s), select it in the Settings page of the GUI.
+2. Within the Chat model, Utility model, or Embedding model section, choose **Ollama** as provider.
+3. Write your model code as expected by Ollama, in the format `llama3.2` or `qwen2.5:7b`
+4. Agent Zero includes Docker-friendly defaults for Ollama on the host at `http://host.docker.internal:11434`. Override the API base URL only if your Ollama server runs somewhere else.
+5. Click `Save` to confirm your settings.
+
+
+
+> [!NOTE]
+> If Agent Zero runs in Docker and Ollama runs on the host, ensure port **11434** is reachable from the container. The shipped Docker Compose file maps `host.docker.internal` to the host gateway for Linux Docker. If both services are in the same Docker network, you can use `http://:11434` instead of `host.docker.internal`.
+
+### Managing Downloaded Models
+
+**Listing downloaded models:**
+
+```bash
+ollama list
+```
+
+**Removing a model:**
+
+```bash
+ollama rm
+```
+
+> [!TIP]
+> Experiment with different model combinations to find the balance of performance and cost that best suits your needs. E.g., faster and lower latency LLMs will help, and you can also use `faiss_gpu` instead of `faiss_cpu` for the memory.
+
+---
+
+## Using Agent Zero on Your Mobile Device
+
+Agent Zero can be accessed from mobile devices and other computers using the built-in **Tunnel feature**.
+
+### Recommended: Using Tunnel (Remote Access)
+
+The Tunnel feature allows secure access to your Agent Zero instance from anywhere:
+
+1. Open Settings in the Web UI
+2. Navigate to the **External Services** tab
+3. Click on **Flare Tunnel** in the navigation menu
+4. Click **Create Tunnel** to generate a secure HTTPS URL
+5. Share this URL to access Agent Zero from any device
+
+> [!IMPORTANT]
+> **Security:** Always set a username and password in Settings -> Authentication before creating a tunnel to secure your instance on the internet.
+
+For complete details on tunnel configuration and security considerations, see the [Remote Access via Tunneling](../guides/usage.md#remote-access-via-tunneling) section in the Usage Guide.
+
+### Alternative: Local Network Access
+
+If you prefer to keep access limited to your local network:
+
+1. Find the mapped port in Docker Desktop (format: `:80`, e.g., `32771:80`)
+2. Access from the same computer: `http://localhost:`
+3. Access from other devices on the network: `http://:`
+
+> [!TIP]
+> Find your computer's IP address with `ipconfig` (Windows) or `ifconfig`/`ip addr` (macOS/Linux). It's usually in the format `192.168.x.x` or `10.0.x.x`.
+
+For developers or users who need to run Agent Zero directly on their system, see the [In-Depth Guide for Full Binaries Installation](dev-setup.md).
+
+---
+
+## Advanced: Automated Configuration via Environment Variables
+
+Agent Zero settings can be automatically configured using environment variables with the `A0_SET_` prefix in your `.env` file. This enables automated deployments without manual configuration.
+
+**Usage:**
+
+Add variables to your `.env` file in the format:
+
+```env
+A0_SET_{setting_name}={value}
+```
+
+**Examples:**
+
+```env
+# Model configuration
+A0_SET_chat_model_provider=anthropic
+A0_SET_chat_model_name=claude-3-5-sonnet-20241022
+A0_SET_chat_model_ctx_length=200000
+
+# Memory settings
+A0_SET_memory_recall_enabled=true
+A0_SET_memory_recall_interval=5
+
+# Agent configuration
+A0_SET_agent_profile=custom
+A0_SET_agent_memory_subdir=production
+```
+
+**Docker usage:**
+
+When running Docker, you can pass these as environment variables:
+
+```bash
+docker run -p 50080:80 \
+ -e A0_SET_chat_model_provider=anthropic \
+ -e A0_SET_chat_model_name=claude-3-5-sonnet-20241022 \
+ agent0ai/agent-zero
+```
+
+**Notes:**
+
+- These provide initial default values when settings.json doesn't exist or when new settings are added to the application. Once a value is saved in settings.json, it takes precedence over these environment variables.
+- Sensitive settings (API keys, passwords) use their existing environment variables
+- Container/process restart required for changes to take effect
+
+---
+
+### Manual Migration (Legacy or Non-Docker)
+
+If you are migrating from older, non-Docker setups, A0 handles the migration of legacy folders and files automatically at runtime. The right place to save your files and directories is `a0/usr`.
+
+## Conclusion
+
+After following the instructions for your specific operating system, you should have Agent Zero successfully installed and running. You can now start exploring the framework's capabilities and experimenting with creating your own intelligent agents.
+
+**Next Steps:**
+
+- For production server deployments, see the [VPS Deployment Guide](vps-deployment.md)
+- For development setup and extensions, see the [Development Setup Guide](dev-setup.md)
+- For remote access via tunnel, see [Remote Access via Tunneling](../guides/usage.md#remote-access-via-tunneling)
+
+If you encounter any issues during the installation process, please consult the [Troubleshooting section](../guides/troubleshooting.md) of this documentation or refer to the Agent Zero [Skool](https://www.skool.com/agent-zero) or [Discord](https://discord.gg/B8KZKNsPpj) community for assistance.
diff --git a/docs/setup/vps-deployment.md b/docs/setup/vps-deployment.md
index e990a0be5e80346768d96110aba66991a35a6707..74b03f5d6d7205631d0bff18fd817fe887b60a3c 100644
--- a/docs/setup/vps-deployment.md
+++ b/docs/setup/vps-deployment.md
@@ -1,3 +1,770 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:35d128f65e9196f4a7714c2eca191b2a2535049356ad8c7e9a30c60d021068b1
-size 18115
+# Agent Zero Installation Guide
+
+> **Purpose:** Step-by-step guide for deploying Agent Zero instances on VPS/dedicated servers
+> **Author:** Auto-generated from deployment experience
+> **Last Updated:** December 21 2025
+> **Compatibility:** Docker-capable Linux servers (AlmaLinux, CentOS, Rocky, Ubuntu, Debian)
+
+---
+
+## Table of Contents
+
+1. [Prerequisites](#prerequisites)
+2. [Docker Installation](#docker-installation)
+3. [Agent Zero Container Deployment](#agent-zero-container-deployment)
+4. [Apache Reverse Proxy Configuration](#apache-reverse-proxy-configuration)
+5. [SSL/TLS Configuration](#ssltls-configuration)
+6. [Authentication Setup](#authentication-setup)
+7. [Domain & DNS Setup](#domain-dns-setup)
+8. [Verification & Testing](#verification-testing)
+9. [Troubleshooting](#troubleshooting)
+10. [Maintenance & Updates](#maintenance-updates)
+11. [Quick Reference](#quick-reference)
+
+---
+
+## Prerequisites
+
+### Server Requirements
+
+| Requirement | Minimum | Recommended |
+|-------------|---------|-------------|
+| **RAM** | 2 GB | 4+ GB |
+| **Storage** | 20 GB | 50+ GB |
+| **CPU** | 1 vCPU | 2+ vCPU |
+| **OS** | Linux (64-bit) | AlmaLinux 9, Ubuntu 22.04+ |
+| **Network** | Static IP | Dedicated IP with reverse DNS |
+
+### Required Access
+
+- Root or sudo access to the server
+- SSH access (preferably on non-standard port)
+- Domain/subdomain with DNS control
+- SSL certificate (Let's Encrypt or commercial)
+
+### Software Dependencies
+
+- Docker Engine 24.0+
+- Apache 2.4+ with mod_proxy, mod_proxy_http, mod_proxy_wstunnel, mod_ssl, mod_rewrite
+- curl, git (optional)
+
+---
+
+## Docker Installation
+
+> [!NOTE]
+> For detailed Docker installation instructions and alternative methods, see the [Linux Installation section](installation.md#linux-installation) in the main installation guide.
+
+### Method A: Debian/Ubuntu Systems
+
+```bash
+# Update package index
+apt-get update
+
+# Install prerequisites
+apt-get install -y ca-certificates curl gnupg
+
+# Add Docker's official GPG key
+install -m 0755 -d /etc/apt/keyrings
+curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
+chmod a+r /etc/apt/keyrings/docker.gpg
+
+# Set up repository
+echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
+
+# Install Docker
+apt-get update
+apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
+
+# Start and enable Docker
+systemctl enable docker
+systemctl start docker
+```
+
+### Method B: AlmaLinux/Rocky/CentOS/RHEL Systems
+
+```bash
+# Install required packages
+dnf -y install dnf-plugins-core
+
+# Add Docker repository (use CentOS repo for AlmaLinux/Rocky)
+dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
+
+# Install Docker
+dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin
+
+# Start and enable Docker
+systemctl enable docker
+systemctl start docker
+```
+
+### Method C: Generic (Convenience Script)
+
+> ⚠️ **Note:** May not work on all distributions (e.g., AlmaLinux)
+
+```bash
+curl -fsSL https://get.docker.com -o get-docker.sh
+sh get-docker.sh
+systemctl enable docker
+systemctl start docker
+```
+
+### Verify Docker Installation
+
+```bash
+docker --version
+docker run hello-world
+```
+
+---
+
+## Agent Zero Container Deployment
+
+### Step 1: Create Directory Structure
+
+```bash
+# Choose your installation path
+A0_NAME="a0-instance" # Change this to your instance name
+A0_PATH="/opt/${A0_NAME}"
+
+# Create directories
+mkdir -p ${A0_PATH}
+mkdir -p ${A0_PATH}/work_dir
+mkdir -p ${A0_PATH}/memory
+mkdir -p ${A0_PATH}/logs
+```
+
+### Step 2: Create Environment Configuration
+
+```bash
+# Create .env file with authentication
+cat > ${A0_PATH}/.env << 'EOF'
+# Agent Zero Configuration
+# Authentication (REQUIRED for web access)
+AUTH_LOGIN=your_username_here
+AUTH_PASSWORD=your_secure_password_here
+
+# Optional: Additional configuration
+# See Agent Zero documentation for all options
+EOF
+```
+
+> ⚠️ **CRITICAL:** `AUTH_LOGIN` is the **username**, not a boolean!
+> - ✅ Correct: `AUTH_LOGIN=admin`
+> - ❌ Wrong: `AUTH_LOGIN=true`
+
+### Step 3: Choose Host Port
+
+| Port | Use Case |
+|------|----------|
+| `50080` | Standard/recommended for reverse proxy setups |
+| `50081`, `50082`... | Additional instances on same server |
+| `80` | Direct access (not recommended for production) |
+
+### Step 4: Pull and Run Container
+
+```bash
+# Set variables
+A0_NAME="a0-instance"
+A0_PATH="/opt/${A0_NAME}"
+A0_PORT="50080"
+
+# Pull latest image
+docker pull agent0ai/agent-zero:latest
+
+# Run container
+docker run -d --name ${A0_NAME} --restart unless-stopped -p ${A0_PORT}:80 -v ${A0_PATH}/.env:/a0/.env -v ${A0_PATH}/usr:/a0/usr agent0ai/agent-zero:latest
+```
+
+### Step 5: Verify Container
+
+```bash
+# Check container is running
+docker ps | grep ${A0_NAME}
+
+# Check logs
+docker logs ${A0_NAME}
+
+# Test local access
+curl -I http://127.0.0.1:${A0_PORT}/
+```
+
+Expected response: `HTTP/1.1 302 FOUND` with `Location: /login` (if auth enabled)
+
+---
+
+## Apache Reverse Proxy Configuration
+
+### Required Apache Modules
+
+```bash
+# Debian/Ubuntu
+a2enmod proxy proxy_http proxy_wstunnel ssl rewrite headers
+systemctl restart apache2
+
+# AlmaLinux/CentOS (usually pre-loaded)
+httpd -M | grep -E "proxy|rewrite|ssl"
+```
+
+### Configuration for Standard Apache (Debian/Ubuntu)
+
+Create `/etc/apache2/sites-available/a0-instance.conf`:
+
+```apache
+# Agent Zero Reverse Proxy Configuration
+# Instance: a0-instance
+# Domain: a0.example.com
+
+# HTTP - Redirect to HTTPS
+
+ ServerName a0.example.com
+ ServerAlias www.a0.example.com
+
+ RewriteEngine On
+ RewriteCond %{HTTPS} off
+ RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
+
+
+# HTTPS - Proxy to Container
+
+ ServerName a0.example.com
+ ServerAlias www.a0.example.com
+ ServerAdmin webmaster@example.com
+
+ # SSL Configuration
+ SSLEngine on
+ SSLCertificateFile /path/to/certificate.crt
+ SSLCertificateKeyFile /path/to/private.key
+ SSLCertificateChainFile /path/to/chain.crt
+
+ # Proxy Configuration
+ ProxyPreserveHost On
+ ProxyPass / http://127.0.0.1:50080/
+ ProxyPassReverse / http://127.0.0.1:50080/
+
+ # WebSocket Support (Required for real-time features)
+ RewriteEngine On
+ RewriteCond %{HTTP:Upgrade} websocket [NC]
+ RewriteCond %{HTTP:Connection} upgrade [NC]
+ RewriteRule ^/?(.*) ws://127.0.0.1:50080/$1 [P,L]
+
+ # Logging
+ ErrorLog ${APACHE_LOG_DIR}/a0-instance.error.log
+ CustomLog ${APACHE_LOG_DIR}/a0-instance.access.log combined
+
+```
+
+Enable and restart:
+
+```bash
+a2ensite a0-instance.conf
+apache2ctl configtest
+systemctl reload apache2
+```
+
+### Configuration for DirectAdmin Apache (AlmaLinux/CentOS)
+
+#### Option A: Use httpd-includes.conf (Recommended)
+
+Edit `/etc/httpd/conf/extra/httpd-includes.conf`:
+
+```apache
+# Agent Zero Proxy Configuration
+# Instance: a0-instance
+# Domain: a0.example.com
+# Note: Use specific IP, not wildcards, for DirectAdmin compatibility
+
+
+ ServerName a0.example.com
+ ServerAlias www.a0.example.com
+
+ RewriteEngine On
+ RewriteCond %{HTTPS} off
+ RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
+
+
+
+ ServerName a0.example.com
+ ServerAlias www.a0.example.com
+ ServerAdmin webmaster@example.com
+
+ SSLEngine on
+ # DirectAdmin SSL cert paths (adjust user and domain)
+ SSLCertificateFile /usr/local/directadmin/data/users/USERNAME/domains/example.com.cert.combined
+ SSLCertificateKeyFile /usr/local/directadmin/data/users/USERNAME/domains/example.com.key
+
+ ProxyPreserveHost On
+ ProxyPass / http://127.0.0.1:50080/
+ ProxyPassReverse / http://127.0.0.1:50080/
+
+ # WebSocket Support
+ RewriteEngine On
+ RewriteCond %{HTTP:Upgrade} websocket [NC]
+ RewriteCond %{HTTP:Connection} upgrade [NC]
+ RewriteRule ^/?(.*) ws://127.0.0.1:50080/$1 [P,L]
+
+ ErrorLog /var/log/httpd/domains/a0.example.com.error.log
+ CustomLog /var/log/httpd/domains/a0.example.com.access.log combined
+
+```
+
+> ⚠️ **Important for DirectAdmin:**
+> - Use **specific IP address** (e.g., `192.168.1.100:443`), not `*:443`
+> - IP-bound vhosts take precedence over DirectAdmin's vhosts
+> - SSL certs are in `/usr/local/directadmin/data/users/USERNAME/domains/`
+
+#### Option B: Standalone conf.d file
+
+If `/etc/httpd/conf.d/` is included in your Apache config:
+
+```bash
+# Check if conf.d is included
+grep 'conf.d' /etc/httpd/conf/httpd.conf
+
+# If not, add before directadmin-vhosts.conf include:
+sed -i '/Include conf\/extra\/directadmin-vhosts.conf/i Include conf.d/*.conf' /etc/httpd/conf/httpd.conf
+
+# Create config
+mkdir -p /etc/httpd/conf.d
+cat > /etc/httpd/conf.d/httpd-vhosts-a0.conf << 'EOF'
+# Your vhost config here (same as Option A)
+EOF
+```
+
+### Verify and Restart Apache
+
+```bash
+# Test configuration
+httpd -t # AlmaLinux/CentOS
+apachectl -t # Alternative
+apache2ctl -t # Debian/Ubuntu
+
+# Restart
+systemctl restart httpd # AlmaLinux/CentOS
+systemctl restart apache2 # Debian/Ubuntu
+```
+
+---
+
+## SSL/TLS Configuration
+
+### Option A: Let's Encrypt with Certbot
+
+```bash
+# Install Certbot
+# Debian/Ubuntu:
+apt-get install certbot python3-certbot-apache
+
+# AlmaLinux/CentOS:
+dnf install certbot python3-certbot-apache
+
+# Obtain certificate
+certbot --apache -d a0.example.com -d www.a0.example.com
+
+# Auto-renewal (usually automatic, but verify)
+certbot renew --dry-run
+```
+
+### Option B: DirectAdmin Auto-SSL
+
+If using DirectAdmin, SSL is typically managed automatically:
+
+1. Create domain/subdomain in DirectAdmin
+2. Enable "SSL" for the domain
+3. DirectAdmin will obtain Let's Encrypt certificate
+4. Certs stored in `/usr/local/directadmin/data/users/USERNAME/domains/`
+
+### Option C: Manual/Commercial Certificates
+
+Place certificates in secure location:
+
+```bash
+mkdir -p /etc/ssl/a0
+chmod 700 /etc/ssl/a0
+
+# Copy your certificates
+cp certificate.crt /etc/ssl/a0/
+cp private.key /etc/ssl/a0/
+cp chain.crt /etc/ssl/a0/ # if applicable
+
+chmod 600 /etc/ssl/a0/*
+```
+
+---
+
+## Authentication Setup
+
+### Understanding A0 Authentication Variables
+
+| Variable | Purpose | Example |
+|----------|---------|--------|
+| `AUTH_LOGIN` | The **username** for login | `AUTH_LOGIN=admin` |
+| `AUTH_PASSWORD` | The **password** for login | `AUTH_PASSWORD=SecurePass123!` |
+
+> ⚠️ **Common Mistake:** `AUTH_LOGIN` is the username, **not** a boolean to enable auth!
+
+### Setting Up Authentication
+
+```bash
+# Edit .env file
+vi /opt/a0-instance/.env
+
+# Add/update these lines:
+AUTH_LOGIN=your_username
+AUTH_PASSWORD=your_secure_password
+
+# Restart container to apply
+docker restart a0-instance
+```
+
+### Password Requirements
+
+- Minimum 8 characters recommended
+- Special characters are supported (properly escaped)
+- Avoid these characters in passwords: `' " \` $ \` (or escape carefully)
+
+### Disabling Authentication (Not Recommended)
+
+To disable authentication (local/dev use only):
+
+```bash
+# Remove or comment out both lines in .env:
+# AUTH_LOGIN=
+# AUTH_PASSWORD=
+
+docker restart a0-instance
+```
+
+---
+
+## Domain & DNS Setup
+
+### DNS Configuration
+
+Create an A record pointing to your server:
+
+| Type | Name | Value | TTL |
+|------|------|-------|-----|
+| A | a0 | YOUR_SERVER_IP | 300 |
+| A | www.a0 | YOUR_SERVER_IP | 300 |
+
+### DirectAdmin Subdomain Setup
+
+1. Log into DirectAdmin
+2. Navigate to: **Domain Setup** → Select domain → **Subdomain Management**
+3. Create subdomain (e.g., `a0`)
+4. Note: You'll override the DocumentRoot with Apache proxy config
+
+### Verify DNS Propagation
+
+```bash
+# Check DNS resolution
+dig a0.example.com +short
+nslookup a0.example.com
+
+# Should return your server IP
+```
+
+---
+
+## Verification & Testing
+
+### Step-by-Step Verification Checklist
+
+```bash
+# 1. Verify Docker container is running
+docker ps | grep a0-instance
+
+# 2. Check container logs for errors
+docker logs a0-instance --tail 50
+
+# 3. Test local container access
+curl -I http://127.0.0.1:50080/
+# Expected: HTTP/1.1 302 FOUND, Location: /login
+
+# 4. Test Apache config
+httpd -t # or apache2ctl -t
+
+# 5. Check Apache is proxying correctly
+curl -I http://127.0.0.1:80 -H "Host: a0.example.com"
+curl -Ik https://127.0.0.1:443 -H "Host: a0.example.com"
+
+# 6. Test external HTTPS access
+curl -I https://a0.example.com/
+# Expected: HTTP/2 302 with Location: /login
+
+# 7. Test login page loads
+curl -s https://a0.example.com/login | grep -i ""
+# Expected: Login - Agent Zero
+```
+
+### WebSocket Verification
+
+```bash
+# Install wscat if needed
+npm install -g wscat
+
+# Test WebSocket connection
+wscat -c wss://a0.example.com/ws
+```
+
+---
+
+## Troubleshooting
+
+### Issue: "Invalid Credentials" on Login
+
+**Cause:** Incorrect `.env` configuration
+
+**Fix:**
+```bash
+# Verify .env inside container
+docker exec a0-instance cat /a0/.env
+
+# Ensure format is:
+# AUTH_LOGIN=username (NOT AUTH_LOGIN=true)
+# AUTH_PASSWORD=password
+
+# Restart after fixing
+docker restart a0-instance
+```
+
+### Issue: 403 Forbidden
+
+**Cause:** DirectAdmin vhost overriding custom proxy config
+
+**Fix:**
+```bash
+# Check vhost order
+httpd -S 2>&1 | grep your-domain
+
+# Ensure custom config loads BEFORE directadmin-vhosts.conf
+# Use specific IP binding (e.g., 192.168.1.1:443) not wildcards (*:443)
+
+# Restart Apache
+systemctl restart httpd
+```
+
+### Issue: 502 Bad Gateway
+
+**Cause:** Container not running or wrong port
+
+**Fix:**
+```bash
+# Check container status
+docker ps -a | grep a0-instance
+
+# If stopped, check logs
+docker logs a0-instance
+
+# Restart container
+docker start a0-instance
+
+# Verify port binding
+netstat -tlnp | grep 50080
+```
+
+### Issue: 504 Gateway Timeout
+
+**Cause:** Container overloaded or unresponsive
+
+**Fix:**
+```bash
+# Check container resource usage
+docker stats a0-instance --no-stream
+
+# Restart container
+docker restart a0-instance
+
+# Check for memory issues
+free -h
+```
+
+### Issue: WebSocket Connection Failed
+
+**Cause:** Missing WebSocket proxy rules
+
+**Fix:**
+Ensure these lines are in your vhost config:
+
+```apache
+RewriteEngine On
+RewriteCond %{HTTP:Upgrade} websocket [NC]
+RewriteCond %{HTTP:Connection} upgrade [NC]
+RewriteRule ^/?(.*) ws://127.0.0.1:50080/$1 [P,L]
+```
+
+### Issue: Container Won't Start
+
+**Cause:** Port conflict or Docker issue
+
+**Fix:**
+```bash
+# Check what's using the port
+netstat -tlnp | grep 50080
+
+# Remove conflicting container
+docker rm -f conflicting-container
+
+# Check Docker daemon
+systemctl status docker
+journalctl -u docker --since "1 hour ago"
+```
+
+### Issue: Changes to .env Not Taking Effect
+
+**Cause:** Container needs restart to reload env
+
+**Fix:**
+```bash
+docker restart a0-instance
+
+# Verify env is loaded
+docker exec a0-instance cat /a0/.env
+```
+
+---
+
+## Maintenance & Updates
+
+### Updating Agent Zero
+
+```bash
+# Pull latest image
+docker pull agent0ai/agent-zero:latest
+
+# Stop and remove old container (data persists in volumes)
+docker stop a0-instance
+docker rm a0-instance
+
+# Recreate with same settings
+docker run -d --name a0-instance --restart unless-stopped -p 50080:80 -v /opt/a0-instance/.env:/a0/.env -v /opt/a0-instance/usr:/a0/usr -v /opt/agent-zero:latest
+```
+
+### Backup Strategy
+
+```bash
+# Backup all instance data
+tar -czvf a0-backup-$(date +%Y%m%d).tar.gz /opt/a0-instance/
+
+# Key items to backup:
+# - /opt/a0-instance/.env (configuration)
+# - /opt/a0-instance/memory/ (agent memories)
+# - /opt/a0-instance/work_dir/ (working files)
+```
+
+### Monitoring
+
+```bash
+# Check container health
+docker ps --format "table {{.Names}} {{.Status}} {{.Ports}}"
+
+# View recent logs
+docker logs --tail 100 -f a0-instance
+
+# Resource usage
+docker stats a0-instance
+```
+
+### Docker Cleanup
+
+```bash
+# Remove unused images
+docker image prune -f
+
+# Remove all unused Docker resources
+docker system prune -f
+```
+
+---
+
+## Quick Reference
+
+### Essential Commands
+
+```bash
+# Container Management
+docker start a0-instance
+docker stop a0-instance
+docker restart a0-instance
+docker logs a0-instance
+docker exec -it a0-instance bash
+
+# Apache Management
+systemctl restart httpd # RHEL/AlmaLinux
+systemctl restart apache2 # Debian/Ubuntu
+httpd -t # Test config
+
+# Quick Diagnostics
+docker ps | grep a0
+curl -I https://your-domain.com/login
+```
+
+### Standard Paths
+
+| Component | Path |
+|-----------|----- |
+| Instance Data | `/opt/a0-instance/` |
+| Environment File | `/opt/a0-instance/.env` |
+| Memory Storage | `/opt/a0-instance/memory/` |
+| Work Directory | `/opt/a0-instance/work_dir/` |
+| Apache Config (Standard) | `/etc/apache2/sites-available/` |
+| Apache Config (DirectAdmin) | `/etc/httpd/conf/extra/httpd-includes.conf` |
+| DirectAdmin SSL Certs | `/usr/local/directadmin/data/users/USER/domains/` |
+
+### Standard Ports
+
+| Port | Purpose |
+|------|---------|
+| 50080 | First A0 instance |
+| 50081 | Second A0 instance |
+| 50082 | Third A0 instance |
+| 80 | HTTP (redirect to HTTPS) |
+| 443 | HTTPS (main access) |
+
+### .env Template
+
+```bash
+# Agent Zero Configuration Template
+# Copy and customize for each instance
+
+# Authentication (REQUIRED for production)
+AUTH_LOGIN=your_username
+AUTH_PASSWORD=your_secure_password
+
+# Optional: Additional settings
+# Refer to Agent Zero documentation for all options
+```
+
+---
+
+## Appendix: Multi-Instance Setup
+
+For running multiple A0 instances on the same server:
+
+```bash
+# Instance 1: a0-primary on port 50080
+mkdir -p /opt/a0-primary
+# ... create .env, run container on port 50080
+
+# Instance 2: a0-dev on port 50081
+mkdir -p /opt/a0-dev
+# ... create .env, run container on port 50081
+
+# Instance 3: a0-backup on port 50082
+mkdir -p /opt/a0-backup
+# ... create .env, run container on port 50082
+```
+
+Each instance needs:
+- Unique container name
+- Unique host port
+- Separate data directory
+- Separate domain/subdomain
+- Separate Apache vhost config
+
+---
+
+*This guide comes from successful Agent Zero deployments across DirectAdmin and standard Linux environments.*
+
+Contributed by @hurtdidit in the A0 Community.
diff --git a/extensions/AGENTS.md b/extensions/AGENTS.md
index 1af5895758881f263457c0ef0465cb484521a9dd..0658654cb70755fd5f728fb64df2f498bf068fd9 100644
--- a/extensions/AGENTS.md
+++ b/extensions/AGENTS.md
@@ -1,3 +1,39 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:2fee5d7084cc2d56ea3b981a3c921e2e7944bc20a20fd8691d5caf0b94b2d7f8
-size 1537
+# Extensions DOX
+
+## Purpose
+
+- Own core lifecycle extension implementations for backend and WebUI extension points.
+- Keep built-in hook behavior ordered, discoverable, and compatible with plugin extension discovery.
+
+## Ownership
+
+- `python/` contains backend lifecycle hooks executed through `helpers.extension`.
+- `webui/` contains frontend extension contributions loaded through `webui/js/extensions.js`.
+- Plugin-specific extensions belong inside each plugin's `extensions/` directory.
+
+## Local Contracts
+
+- Extension directory names are runtime extension point names.
+- File ordering matters when names include numeric prefixes.
+- Extensions must be safe to run repeatedly when the lifecycle point can fire multiple times.
+- Secret masking, auth, security, and persistence extensions must not be bypassed by convenience changes.
+
+## Work Guidance
+
+- Keep extension code small and focused on its hook point.
+- Move shared logic into `helpers/` when it is reused outside one extension.
+- Coordinate changes with plugin extension docs and tests when extension point semantics change.
+
+## Verification
+
+- Run targeted lifecycle, prompt, stream, WebSocket, or WebUI extension tests for changed hook points.
+- Smoke-test startup when changing initialization, migration, or system-prompt extensions.
+
+## Child DOX Index
+
+Direct child DOX files:
+
+| Child | Scope |
+| --- | --- |
+| [python/AGENTS.md](python/AGENTS.md) | Backend lifecycle extension hook files. |
+| [webui/AGENTS.md](webui/AGENTS.md) | Frontend extension contributions. |
diff --git a/extensions/python/AGENTS.md b/extensions/python/AGENTS.md
index 51c92ac45a1bc038e07a3c6b90aaf8bc2f9dbfff..5138c919c051883975dc8424a0ff024a43ad47fa 100644
--- a/extensions/python/AGENTS.md
+++ b/extensions/python/AGENTS.md
@@ -1,3 +1,68 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a60e8fc1128128aafbb5a9a5b45033ab0d21af713353e168f3008c2276b71cac
-size 4681
+# Python Extensions DOX
+
+## Purpose
+
+- Own built-in backend lifecycle extensions under `extensions/python/`.
+- Keep Python hook behavior compatible with `helpers.extension.call_extensions_async` and `call_extensions_sync`.
+
+## Ownership
+
+- Each direct subdirectory is one named extension point.
+- Python files inside an extension point are loaded in deterministic filename order.
+- Implicit `@extensible` hook implementations use `_functions////` layout when present.
+
+## Local Contracts
+
+- Extension functions must match the arguments supplied by their hook point.
+- Preserve numeric prefixes when ordering affects prompt construction, stream masking, persistence, or cleanup.
+- Use `AgentContext` from `agent` when context access is needed.
+- Do not log unmasked secrets, raw hidden prompt sections, or private user data.
+
+## Work Guidance
+
+- Keep extension modules import-light; many hooks run during hot paths.
+- Use mutable `ctx` or `data` dictionaries according to the hook contract when rewriting content.
+- Add or update tests when a hook changes prompt content, message history, tool output, streaming, or persistence behavior.
+
+## Verification
+
+- Run targeted tests for the affected lifecycle area.
+- Run a startup smoke check for `agent_init`, `startup_migration`, or `system_prompt` changes when practical.
+
+## Child DOX Index
+
+Direct child DOX files:
+
+| Child | Scope |
+| --- | --- |
+| [_functions/AGENTS.md](_functions/AGENTS.md) | Implicit `@extensible` backend hook implementations. |
+| [agent_init/AGENTS.md](agent_init/AGENTS.md) | Agent context initialization hooks. |
+| [banners/AGENTS.md](banners/AGENTS.md) | Backend banner and discovery-card contributions. |
+| [before_main_llm_call/AGENTS.md](before_main_llm_call/AGENTS.md) | Pre-main-model-call behavior. |
+| [error_format/AGENTS.md](error_format/AGENTS.md) | Error formatting and masking behavior. |
+| [hist_add_before/AGENTS.md](hist_add_before/AGENTS.md) | Pre-history-insertion masking behavior. |
+| [hist_add_tool_result/AGENTS.md](hist_add_tool_result/AGENTS.md) | Tool-result history side effects. |
+| [job_loop/AGENTS.md](job_loop/AGENTS.md) | Periodic backend maintenance jobs. |
+| [message_loop_end/AGENTS.md](message_loop_end/AGENTS.md) | End-of-message-loop history and persistence behavior. |
+| [message_loop_result/AGENTS.md](message_loop_result/AGENTS.md) | Completed model-result normalization and default-processing interception. |
+| [message_loop_prompts_after/AGENTS.md](message_loop_prompts_after/AGENTS.md) | Prompt protocol and extras assembled around message-loop prompt construction. |
+| [message_loop_prompts_before/AGENTS.md](message_loop_prompts_before/AGENTS.md) | Pre-prompt-construction message-loop gates. |
+| [message_loop_start/AGENTS.md](message_loop_start/AGENTS.md) | Start-of-message-loop iteration state. |
+| [monologue_end/AGENTS.md](monologue_end/AGENTS.md) | End-of-monologue UI and cleanup behavior. |
+| [monologue_start/AGENTS.md](monologue_start/AGENTS.md) | Core start-of-monologue lifecycle extensions. |
+| [process_chain_end/AGENTS.md](process_chain_end/AGENTS.md) | Process-chain completion and queued-message handling. |
+| [reasoning_stream/AGENTS.md](reasoning_stream/AGENTS.md) | Full reasoning stream handling. |
+| [reasoning_stream_chunk/AGENTS.md](reasoning_stream_chunk/AGENTS.md) | Reasoning stream chunk masking. |
+| [reasoning_stream_end/AGENTS.md](reasoning_stream_end/AGENTS.md) | Reasoning stream finalization. |
+| [response_stream/AGENTS.md](response_stream/AGENTS.md) | Full assistant response stream handling. |
+| [response_stream_chunk/AGENTS.md](response_stream_chunk/AGENTS.md) | Assistant response chunk masking. |
+| [response_stream_end/AGENTS.md](response_stream_end/AGENTS.md) | Assistant response stream finalization. |
+| [startup_migration/AGENTS.md](startup_migration/AGENTS.md) | Startup migrations. |
+| [system_prompt/AGENTS.md](system_prompt/AGENTS.md) | Core system prompt section construction. |
+| [tool_execute_after/AGENTS.md](tool_execute_after/AGENTS.md) | Post-tool-execution processing. |
+| [tool_execute_before/AGENTS.md](tool_execute_before/AGENTS.md) | Pre-tool-execution processing. |
+| [user_message_ui/AGENTS.md](user_message_ui/AGENTS.md) | User-visible UI message hooks. |
+| [util_model_call_before/AGENTS.md](util_model_call_before/AGENTS.md) | Pre-utility-model-call masking. |
+| [webui_ws_connect/AGENTS.md](webui_ws_connect/AGENTS.md) | WebUI WebSocket connect behavior. |
+| [webui_ws_disconnect/AGENTS.md](webui_ws_disconnect/AGENTS.md) | WebUI WebSocket disconnect behavior. |
+| [webui_ws_event/AGENTS.md](webui_ws_event/AGENTS.md) | Incoming WebUI WebSocket event behavior. |
diff --git a/extensions/python/_functions/AGENTS.md b/extensions/python/_functions/AGENTS.md
index d5f9482461ab7bdf0ac5c4b870f76fd300a101eb..0b7954fdb98eb21ce2cfedee1ae6f27921e637b6 100644
--- a/extensions/python/_functions/AGENTS.md
+++ b/extensions/python/_functions/AGENTS.md
@@ -1,3 +1,33 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:45b0d49727663cfd9f6c3bf81431bde197789b2392924aa68bed1a8afae2c296
-size 1501
+# Python Function Extensions DOX
+
+## Purpose
+
+- Own implicit `@extensible` backend hook implementations.
+- Preserve nested module, class/function, method, and `start`/`end` extension layout.
+
+## Ownership
+
+- Each nested path mirrors a Python module and qualname segment.
+- Leaf `start/` and `end/` directories own ordered extension files for that extensible function point.
+
+## Local Contracts
+
+- Do not flatten nested qualname paths into retired legacy folder names.
+- Extension functions must match the implicit hook's supplied arguments.
+- Preserve ordering prefixes where exception handling, watchdog registration, or cleanup depends on them.
+- Hooks that mirror persisted AI responses into UI logs must reuse existing stream log items and avoid duplicating live response-tool logs.
+- The `AgentContext.run_task/end` hook attaches integration callbacks to the returned `DeferredTask`; keep terminal side effects out of `agent.py`.
+- Recovery-loop circuit breakers must stop at the General Settings limit and render their user-visible cost warning from a core framework prompt.
+- Prompt settings snapshots must be task-local, accessed through `get_settings_for_prompt()`, and end with the matching `Agent.prepare_prompt` call, including exceptional exits.
+
+## Work Guidance
+
+- Keep implicit hook extensions narrow and colocated with the exact function point they extend.
+
+## Verification
+
+- Run targeted tests for the affected function point after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/_functions/__main__/init_a0/end/_10_register_watchdogs.py b/extensions/python/_functions/__main__/init_a0/end/_10_register_watchdogs.py
index 4e5b0c10ebb9212ae8c1131414152ed51238c840..e61001e03119bad2073c52aa237a5552a405d51f 100644
--- a/extensions/python/_functions/__main__/init_a0/end/_10_register_watchdogs.py
+++ b/extensions/python/_functions/__main__/init_a0/end/_10_register_watchdogs.py
@@ -1,3 +1,11 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3585ef1b3cd71ea587ba5fed1ebdc4e37be8e6517bc888d4744938bc27561cdd
-size 344
+from helpers.extension import Extension
+
+
+class RegisterWatchDogs(Extension):
+
+ def execute(self, **kwargs):
+ from helpers.plugins import register_watchdogs as register_plugins_watchdogs
+ from helpers.api import register_watchdogs as register_api_watchdogs
+
+ register_plugins_watchdogs()
+ register_api_watchdogs()
\ No newline at end of file
diff --git a/extensions/python/_functions/agent/Agent/handle_exception/end/_40_handle_intervention_exception.py b/extensions/python/_functions/agent/Agent/handle_exception/end/_40_handle_intervention_exception.py
index 4ee872948ed3301abfc7f012ce416888555a14c6..71437278c5f450598132a4833b501fb57978ba17 100644
--- a/extensions/python/_functions/agent/Agent/handle_exception/end/_40_handle_intervention_exception.py
+++ b/extensions/python/_functions/agent/Agent/handle_exception/end/_40_handle_intervention_exception.py
@@ -1,3 +1,21 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:04ee5f0807d0d8f697979f33c0ff2ba7049a85bbad3aa6955738a3a60b0336f9
-size 640
+from datetime import datetime, timezone
+from helpers.extension import Extension
+from agent import LoopData
+from helpers.localization import Localization
+from helpers.errors import InterventionException
+from helpers import errors
+from helpers.print_style import PrintStyle
+
+
+class HandleInterventionException(Extension):
+ async def execute(self, data: dict = {}, **kwargs):
+ if not self.agent:
+ return
+
+ if not data.get("exception"):
+ return
+
+ if isinstance(data["exception"], InterventionException):
+ data["exception"] = None # skip the exception and continue message loop
+
+
diff --git a/extensions/python/_functions/agent/Agent/handle_exception/end/_50_handle_repairable_exception.py b/extensions/python/_functions/agent/Agent/handle_exception/end/_50_handle_repairable_exception.py
index 7eb09f6e6bcf80b4c53a42092d3d1a160b1a2f81..e6d098c2a0e886f3c2eae5d8ebb0c4c5b472ca84 100644
--- a/extensions/python/_functions/agent/Agent/handle_exception/end/_50_handle_repairable_exception.py
+++ b/extensions/python/_functions/agent/Agent/handle_exception/end/_50_handle_repairable_exception.py
@@ -1,3 +1,25 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:67e15a2835ee4e4d02f7f277a32a42273f6bddad4e8b2448f3588aec2bcc54b0
-size 991
+from datetime import datetime, timezone
+from helpers.extension import Extension
+from agent import LoopData
+from helpers.localization import Localization
+from helpers.errors import RepairableException
+from helpers import errors, extension
+from helpers.print_style import PrintStyle
+
+class HandleRepairableException(Extension):
+ async def execute(self, data: dict = {}, **kwargs):
+ if not self.agent:
+ return
+
+ if not data.get("exception"):
+ return
+
+ if isinstance(data["exception"], RepairableException):
+ msg = {"message": errors.format_error(data["exception"])}
+ await extension.call_extensions_async("error_format", agent=self.agent, msg=msg)
+ wmsg = self.agent.hist_add_warning(msg["message"])
+ PrintStyle(font_color="red", padding=True).print(msg["message"])
+ self.agent.context.log.log(type="warning", content=msg["message"], id=wmsg.id)
+ data["exception"] = None
+
+
diff --git a/extensions/python/_functions/agent/Agent/handle_exception/end/_90_handle_critical_exception.py b/extensions/python/_functions/agent/Agent/handle_exception/end/_90_handle_critical_exception.py
index 380a50366fa7008178dfeeec5340828fd3d0ad2d..ccd6edfc71096f2de0c3a61cdb69a01946a33b49 100644
--- a/extensions/python/_functions/agent/Agent/handle_exception/end/_90_handle_critical_exception.py
+++ b/extensions/python/_functions/agent/Agent/handle_exception/end/_90_handle_critical_exception.py
@@ -1,3 +1,43 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:762c8469756a147e96d7cbed07cb0d585d296006b10475f7fbe143e6ded43b32
-size 1500
+import asyncio
+
+from helpers.extension import Extension
+from helpers.print_style import PrintStyle
+from helpers import errors
+
+from helpers.errors import HandledException
+
+
+class HandleCriticalException(Extension):
+ async def execute(self, data: dict = {}, **kwargs):
+ if not self.agent:
+ return
+
+ if not (exception:= data.get("exception")):
+ return
+
+ # when exception is HandledException, keep it active, no logging here
+ if isinstance(exception, HandledException):
+ return
+
+ # asyncio cancel - chat is being terminated, print out and re-raise as handledException
+ if isinstance(exception, asyncio.CancelledError):
+ PrintStyle(font_color="white", background_color="red", padding=True).print(
+ f"Context {self.agent.context.id} terminated during message loop"
+ )
+ data["exception"] = HandledException(exception)
+ return
+
+ # other exceptions should be logged and re-raised as HandledException
+ error_text = errors.error_text(exception)
+ error_message = errors.format_error(exception)
+
+ PrintStyle(font_color="red", padding=True).print(error_message)
+ self.agent.context.log.log(
+ type="error",
+ content=error_message,
+ )
+ PrintStyle(font_color="red", padding=True).print(
+ f"{self.agent.agent_name}: {error_text}"
+ )
+
+ data["exception"] = HandledException(exception)
diff --git a/extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py b/extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py
index 3eea722cc698e9215ef007bd10b863fe79179a3a..28c80e77c543bb442f3a22059c632c1f8c32c42b 100644
--- a/extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py
+++ b/extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py
@@ -1,3 +1,47 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:cbb24ea2ec8c6acf3f4a7df4b6042464e4c3471118f865a843ca2d9f90b90950
-size 1498
+from typing import Any
+
+from helpers import extract_tools
+from helpers.extension import Extension
+
+
+class LogPlainResponses(Extension):
+ def execute(self, data: dict[str, Any] | None = None, **kwargs):
+ if not self.agent or not isinstance(data, dict):
+ return
+
+ call_kwargs = data.get("kwargs")
+ if not isinstance(call_kwargs, dict):
+ call_kwargs = {}
+
+ llm_result = call_kwargs.get("llm_result")
+ if getattr(llm_result, "mode", "") != "responses":
+ return
+
+ message = call_kwargs.get("message")
+ call_args = data.get("args")
+ if message is None and isinstance(call_args, tuple) and len(call_args) > 1:
+ message = call_args[1]
+ if not isinstance(message, str) or not message:
+ return
+ if (
+ extract_tools.extract_tool_request(message) is not None
+ or extract_tools.is_misformatted_tool_request(message)
+ ):
+ return
+
+ params = getattr(getattr(self.agent, "loop_data", None), "params_temporary", None)
+ if not isinstance(params, dict) or "log_item_response" in params:
+ return
+
+ log_item = params.get("log_item_generating")
+ if log_item is None:
+ return
+
+ params["log_item_response"] = log_item
+ log_item.update(
+ type="response",
+ heading="",
+ content=message,
+ finished=True,
+ update_progress="none",
+ )
diff --git a/extensions/python/_functions/agent/Agent/hist_add_warning/end/_90_stop_unusable_response_loop.py b/extensions/python/_functions/agent/Agent/hist_add_warning/end/_90_stop_unusable_response_loop.py
index fbffb7ad0ecbb808d80f0053987e136c02a137a5..87670a2731f7e7a23371861e09f77115865caebc 100644
--- a/extensions/python/_functions/agent/Agent/hist_add_warning/end/_90_stop_unusable_response_loop.py
+++ b/extensions/python/_functions/agent/Agent/hist_add_warning/end/_90_stop_unusable_response_loop.py
@@ -1,3 +1,56 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:7c138b131522d75756865ef2aebc656f12c7d16f0ce5feefc6c733a3916b1b84
-size 2012
+from helpers.errors import HandledException
+from helpers.extension import Extension
+from helpers.settings import get_settings
+
+
+STATE_KEY = "_unusable_response_failures"
+
+
+class StopUnusableResponseLoop(Extension):
+ def execute(self, data: dict | None = None, **kwargs):
+ if not self.agent or not isinstance(data, dict):
+ return
+
+ call_kwargs = data.get("kwargs")
+ message = call_kwargs.get("message") if isinstance(call_kwargs, dict) else None
+ call_args = data.get("args")
+ if message is None and isinstance(call_args, tuple) and len(call_args) > 1:
+ message = call_args[1]
+
+ if not isinstance(message, str):
+ return
+ if message not in {
+ self.agent.read_prompt("fw.msg_misformat.md"),
+ self.agent.read_prompt("fw.msg_repeat.md"),
+ self.agent.read_prompt("fw.msg_empty_response.md"),
+ }:
+ return
+
+ loop_data = getattr(self.agent, "loop_data", None)
+ state = getattr(loop_data, "params_persistent", None)
+ iteration = getattr(loop_data, "iteration", None)
+ if not isinstance(state, dict) or not isinstance(iteration, int):
+ return
+
+ previous = state.get(STATE_KEY, {})
+ if not isinstance(previous, dict):
+ previous = {}
+ previous_iteration = previous.get("iteration")
+ if previous_iteration == iteration:
+ return
+
+ count = (
+ previous.get("count", 0) + 1
+ if previous_iteration == iteration - 1
+ else 1
+ )
+ state[STATE_KEY] = {"iteration": iteration, "count": count}
+ limit = get_settings()["max_consecutive_unusable_responses"]
+ if count < limit:
+ return
+
+ stop_message = self.agent.read_prompt(
+ "fw.msg_unusable_response_limit.md", limit=limit
+ )
+ self.agent.context.log.log(type="warning", content=stop_message)
+ data["exception"] = HandledException(stop_message)
diff --git a/extensions/python/_functions/agent/Agent/prepare_prompt/end/_00_restore_settings.py b/extensions/python/_functions/agent/Agent/prepare_prompt/end/_00_restore_settings.py
index 9cf7d94604519e0cb5331c25429d7463dd0d5b2e..6165340897a98d425d8e4429e32da10030ebe416 100644
--- a/extensions/python/_functions/agent/Agent/prepare_prompt/end/_00_restore_settings.py
+++ b/extensions/python/_functions/agent/Agent/prepare_prompt/end/_00_restore_settings.py
@@ -1,3 +1,12 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:02352e6a668d95ed2d72d77c3fe417f4ceea29a3ad7601666059c569d06e48e9
-size 385
+from helpers import settings
+from helpers.extension import Extension
+
+
+_TOKEN_KEY = "_prompt_settings_snapshot_token"
+
+
+class RestorePromptSettings(Extension):
+ def execute(self, data: dict | None = None, **kwargs):
+ token = data.pop(_TOKEN_KEY, None) if isinstance(data, dict) else None
+ if token is not None:
+ settings.end_prompt_settings_snapshot(token)
diff --git a/extensions/python/_functions/agent/Agent/prepare_prompt/start/_99_snapshot_settings.py b/extensions/python/_functions/agent/Agent/prepare_prompt/start/_99_snapshot_settings.py
index 0d074c933c36b295117d8c08482154a1f9e25887..acc2e8b5f53a1a984e9bc9f8b509ea6dbdf3f8ed 100644
--- a/extensions/python/_functions/agent/Agent/prepare_prompt/start/_99_snapshot_settings.py
+++ b/extensions/python/_functions/agent/Agent/prepare_prompt/start/_99_snapshot_settings.py
@@ -1,3 +1,11 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:8f60a19ebaf9a35b14e788a31e3fce5a7285fa9fee1228374f2b25edff31a477
-size 328
+from helpers import settings
+from helpers.extension import Extension
+
+
+_TOKEN_KEY = "_prompt_settings_snapshot_token"
+
+
+class SnapshotPromptSettings(Extension):
+ def execute(self, data: dict | None = None, **kwargs):
+ if isinstance(data, dict):
+ data[_TOKEN_KEY] = settings.begin_prompt_settings_snapshot()
diff --git a/extensions/python/_functions/agent/AgentContext/run_task/end/_10_mark_state_dirty.py b/extensions/python/_functions/agent/AgentContext/run_task/end/_10_mark_state_dirty.py
index 483bb4d7993c5584988422c5d3d07fc0a1417b81..be7afe395f0ec5c4b5902948e4c4879d1f19e70a 100644
--- a/extensions/python/_functions/agent/AgentContext/run_task/end/_10_mark_state_dirty.py
+++ b/extensions/python/_functions/agent/AgentContext/run_task/end/_10_mark_state_dirty.py
@@ -1,3 +1,14 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:02112c06ff56b8de8ed877895b58c0e0ed8057800e55eccf12a9e128f3878d4c
-size 534
+from helpers.defer import DeferredTask
+from helpers.extension import Extension
+from helpers.state_monitor_integration import mark_dirty_all
+
+
+class MarkStateDirty(Extension):
+ def execute(self, data: dict | None = None, **kwargs) -> None:
+ task = data.get("result") if isinstance(data, dict) else None
+ if isinstance(task, DeferredTask):
+ task.add_done_callback(
+ lambda _future: mark_dirty_all(
+ reason="agent.AgentContext.run_task_done",
+ )
+ )
diff --git a/extensions/python/agent_init/AGENTS.md b/extensions/python/agent_init/AGENTS.md
index c6808a4421044615013881734a823e77ee403a1a..3121c8244d7976da6fac1fb4a4cc808c3bcd15e5 100644
--- a/extensions/python/agent_init/AGENTS.md
+++ b/extensions/python/agent_init/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a0d9eb62f086092e47acc26b8d95af4020a180c2639da41a45f31987b76f6401
-size 625
+# Agent Init Extensions DOX
+
+## Purpose
+
+- Own backend extensions that run when an agent context initializes.
+
+## Ownership
+
+- Ordered Python files own initial UI message setup and profile settings load behavior.
+
+## Local Contracts
+
+- Keep initialization idempotent for contexts that may be restored or reloaded.
+- Preserve ordering between initial message creation and profile settings loading.
+
+## Work Guidance
+
+- Coordinate changes with profile loading, settings resolution, and startup smoke checks.
+
+## Verification
+
+- Smoke-test new chat/context initialization after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/agent_init/_10_initial_message.py b/extensions/python/agent_init/_10_initial_message.py
index ca0a6aa9e39b1d256d528f16ea6cec2444cd9621..cca5f798e36b5acb18436a4048d4f519ad61ed6f 100644
--- a/extensions/python/agent_init/_10_initial_message.py
+++ b/extensions/python/agent_init/_10_initial_message.py
@@ -1,3 +1,44 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a600f43500b56a633d3dd0f8eec64e84fd32bfc6a43b55657652c8dc24ccc7a4
-size 1525
+import json
+from agent import LoopData
+from helpers.extension import Extension
+
+
+class InitialMessage(Extension):
+
+ def execute(self, **kwargs):
+ """
+ Add an initial greeting message when first user message is processed.
+ Called only once per session via _process_chain method.
+ """
+ if not self.agent:
+ return
+
+ # Only add initial message for main agent (A0), not subordinate agents
+ if self.agent.number != 0:
+ return
+
+ # If the context already contains log messages, do not add another initial message
+ if self.agent.context.log.logs:
+ return
+
+ # Construct the initial message from prompt template
+ initial_message = self.agent.read_prompt("fw.initial_message.md")
+
+ # add initial loop data to agent (for hist_add_ai_response)
+ self.agent.loop_data = LoopData(user_message=None)
+
+ # Add the message to history as an AI response
+ msg = self.agent.hist_add_ai_response(initial_message)
+
+ # json parse the message, get the tool_args text
+ initial_message_json = json.loads(initial_message)
+ initial_message_text = initial_message_json.get("tool_args", {}).get("text", "Hello! How can I help you?")
+
+ # Add to log (green bubble) for immediate UI display
+ self.agent.context.log.log(
+ type="response",
+ content=initial_message_text,
+ finished=True,
+ update_progress="none",
+ id=msg.id,
+ )
diff --git a/extensions/python/agent_init/_15_load_profile_settings.py b/extensions/python/agent_init/_15_load_profile_settings.py
index 6b6eb7793bd6ec76ec90e57c604596a56a6b53c0..a03913b5f9d6c280ea8d0a57544527e2c0539242 100644
--- a/extensions/python/agent_init/_15_load_profile_settings.py
+++ b/extensions/python/agent_init/_15_load_profile_settings.py
@@ -1,3 +1,53 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:661afc11f43f87eb7afcb2cef1690856bb298bde0e99945739fdc4132722d734
-size 2200
+from initialize import initialize_agent
+from helpers import dirty_json, files, subagents, projects
+from helpers.extension import Extension
+
+
+class LoadProfileSettings(Extension):
+
+ def execute(self, **kwargs) -> None:
+
+ if not self.agent or not self.agent.config.profile:
+ return
+
+ config_files = subagents.get_paths(self.agent, "settings.json", include_default=False, include_user=False)
+ settings_override = {}
+ for settings_path in config_files:
+ if files.exists(settings_path):
+ try:
+ override_settings_str = files.read_file(settings_path)
+ override_settings = dirty_json.try_parse(override_settings_str)
+ if isinstance(override_settings, dict):
+ settings_override.update(override_settings)
+ else:
+ raise Exception(
+ f"Subordinate settings in {settings_path} must be a JSON object."
+ )
+ except Exception as e:
+ self.agent.context.log.log(
+ type="error",
+ content=(
+ f"Error loading subordinate settings from {settings_path} for "
+ f"profile '{self.agent.config.profile}': {e}"
+ ),
+ )
+
+ if settings_override:
+ current_config = self.agent.config
+ new_config = initialize_agent(override_settings=settings_override)
+
+ for override_key, config_attr in (
+ ("agent_profile", "profile"),
+ ("mcp_servers", "mcp_servers"),
+ ):
+ if override_key not in settings_override:
+ setattr(new_config, config_attr, getattr(current_config, config_attr))
+ self.agent.config = new_config
+ # self.agent.context.log.log(
+ # type="info",
+ # content=(
+ # "Loaded custom settings for agent "
+ # f"{self.agent.number} with profile '{self.agent.config.profile}'."
+ # ),
+ # )
+
diff --git a/extensions/python/banners/AGENTS.md b/extensions/python/banners/AGENTS.md
index e1aa0f4fe295f9f7d51f4d2c6a21b84cba5b8514..f2ce20f10f4d5799d1edca5562e58f8af639da69 100644
--- a/extensions/python/banners/AGENTS.md
+++ b/extensions/python/banners/AGENTS.md
@@ -1,3 +1,28 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9384eec1a33ca8900836671c77730e4c3e7ef573abe31700150c8f7b6e2950ff
-size 808
+# Banner Extensions DOX
+
+## Purpose
+
+- Own backend banner and discovery-card contributions.
+
+## Ownership
+
+- Ordered Python files append alert banners or discovery cards to the mutable `banners` list.
+
+## Local Contracts
+
+- Banner IDs must be unique and stable.
+- Use supported banner/card fields and types only.
+- Banner HTML links that trigger WebUI behavior should use supported structured actions, such as `data-banner-action`, instead of inline JavaScript handlers.
+- Do not expose secrets, local paths, or raw system diagnostics in banner text.
+
+## Work Guidance
+
+- Gate setup or warning banners on current configuration/status where possible.
+
+## Verification
+
+- Smoke-test welcome-screen banner rendering, ordering, dismissal, and CTA behavior after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/banners/_10_unsecured_connection.py b/extensions/python/banners/_10_unsecured_connection.py
index 9ed757b1c2d8582d547b4a367cdaf7744dfed954..19145fef3f7ebfdee434fa8404bd8ac34b3978a2 100644
--- a/extensions/python/banners/_10_unsecured_connection.py
+++ b/extensions/python/banners/_10_unsecured_connection.py
@@ -1,3 +1,63 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6a8d4f8ed25d1e17d786ae5ea6b1f1d6d29f2b80bc8cce4d4bb4807c5d56c623
-size 2640
+from helpers.extension import Extension
+from helpers import dotenv
+import re
+
+
+class UnsecuredConnectionCheck(Extension):
+ """Check: non-local without credentials, or credentials over non-HTTPS."""
+
+ async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
+ hostname = frontend_context.get("hostname", "")
+ protocol = frontend_context.get("protocol", "")
+
+ auth_login = dotenv.get_dotenv_value(dotenv.KEY_AUTH_LOGIN, "")
+ auth_password = dotenv.get_dotenv_value(dotenv.KEY_AUTH_PASSWORD, "")
+ has_credentials = bool(auth_login and auth_login.strip() and auth_password and auth_password.strip())
+
+ is_local = self._is_localhost(hostname)
+ is_https = protocol == "https:"
+
+ if not is_local and not has_credentials:
+ banners.append({
+ "id": "unsecured-connection",
+ "type": "warning",
+ "priority": 80,
+ "title": "Unsecured Connection",
+ "html": """You are accessing Agent Zero from a non-local address without authentication.
+
+ Configure credentials in Settings → External Services → Authentication.""",
+ "dismissible": True,
+ "source": "backend"
+ })
+
+ if has_credentials and not is_local and not is_https:
+ banners.append({
+ "id": "credentials-unencrypted",
+ "type": "warning",
+ "priority": 90,
+ "title": "Credentials May Be Sent Unencrypted",
+ "html": """Your connection is not using HTTPS. Login credentials may be transmitted in plain text.
+ Consider using HTTPS or a secure tunnel.""",
+ "dismissible": True,
+ "source": "backend"
+ })
+
+ def _is_localhost(self, hostname: str) -> bool:
+ local_patterns = ["localhost", "127.0.0.1", "::1", "0.0.0.0"]
+
+ if hostname in local_patterns:
+ return True
+
+ # RFC1918 private ranges
+ if re.match(r"^192\.168\.\d{1,3}\.\d{1,3}$", hostname):
+ return True
+ if re.match(r"^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$", hostname):
+ return True
+ if re.match(r"^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$", hostname):
+ return True
+
+ # .local domains
+ if hostname.endswith(".local"):
+ return True
+
+ return False
diff --git a/extensions/python/banners/_30_system_resources.py b/extensions/python/banners/_30_system_resources.py
index 899b15ca93eb52e54ef4ee493c5b19106ac52870..3c1949f4f422b28980c27bb83f9cdf4e4380c918 100644
--- a/extensions/python/banners/_30_system_resources.py
+++ b/extensions/python/banners/_30_system_resources.py
@@ -1,3 +1,151 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c25cebb701f5ee4166d857d7bd34ea1d149507108691cd94cc78c553a606ec3d
-size 5422
+from helpers.extension import Extension
+import os
+import psutil
+
+
+class SystemResourcesCheck(Extension):
+ async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
+ try:
+ cpu_percent = psutil.cpu_percent(interval=0.1)
+ except Exception:
+ cpu_percent = None
+
+ try:
+ cpu_cores = psutil.cpu_count(logical=True)
+ except Exception:
+ cpu_cores = None
+
+ load_avg = self._get_load_average()
+
+ try:
+ vm = psutil.virtual_memory()
+ ram_percent = vm.percent
+ ram_used_gb = (vm.total - vm.available) / (1024 ** 3)
+ ram_total_gb = vm.total / (1024 ** 3)
+ except Exception:
+ ram_percent = None
+
+ ram_used_gb = None
+ ram_total_gb = None
+
+ disk_percent, disk_used_gb, disk_total_gb, disk_path = self._get_disk_usage()
+
+ try:
+ net = psutil.net_io_counters()
+ net_sent = self._format_bytes(net.bytes_sent)
+ net_recv = self._format_bytes(net.bytes_recv)
+ except Exception:
+ net_sent = "N/A"
+ net_recv = "N/A"
+
+ load_value = "N/A"
+ if load_avg:
+ la1, la5, la15 = load_avg
+ load_value = f"{la1:.2f} / {la5:.2f} / {la15:.2f}"
+
+ if disk_percent is None or disk_used_gb is None or disk_total_gb is None:
+ disk_value = "N/A"
+ else:
+ disk_value = f"{disk_used_gb:.2f}/{disk_total_gb:.2f} GB"
+
+ if cpu_percent is None:
+ cpu_value = "N/A"
+ else:
+ cores_value = "" if cpu_cores is None else f" ({cpu_cores} cores)"
+ cpu_value = f"{cpu_percent:.0f}%{cores_value}"
+
+ if ram_percent is None or ram_used_gb is None or ram_total_gb is None:
+ ram_value = "N/A"
+ else:
+ ram_value = f"{ram_used_gb:.2f}/{ram_total_gb:.2f} GB"
+
+ cpu_bar = self._bar_html(cpu_percent)
+ ram_bar = self._bar_html(ram_percent)
+ disk_bar = self._bar_html(disk_percent)
+
+ banners.append({
+ "id": "system-resources",
+ "type": "info",
+ "priority": 10,
+ "title": "System Resources",
+ "html": (
+ "
"
+ "
"
+ "
"
+ "
CPU
"
+ f"
{cpu_value}
"
+ "
"
+ f"{cpu_bar}"
+ "
"
+ "
"
+ "
"
+ "
RAM
"
+ f"
{ram_value}
"
+ "
"
+ f"{ram_bar}"
+ "
"
+ "
"
+ "
"
+ "
Disk
"
+ f"
{disk_value}
"
+ "
"
+ f"{disk_bar}"
+ "
"
+ ""
+ "
"
+ ),
+ "dismissible": True,
+ "source": "backend",
+ })
+
+ def _bar_html(self, percent: float | None) -> str:
+ if percent is None:
+ return ""
+
+ p = max(0.0, min(100.0, float(percent)))
+ if p >= 85:
+ color = "#ef4444"
+ elif p >= 70:
+ color = "#f59e0b"
+ else:
+ color = "#22c55e"
+
+ return (
+ "
"
+ f""
+ "
"
+ )
+
+ def _get_load_average(self) -> tuple[float, float, float] | None:
+ try:
+ return os.getloadavg()
+ except Exception:
+ return None
+
+ def _get_disk_usage(self) -> tuple[float | None, float | None, float | None, str]:
+ for path in ["/", os.path.expanduser("~")]:
+ try:
+ usage = psutil.disk_usage(path)
+ used_gb = usage.used / (1024 ** 3)
+ total_gb = usage.total / (1024 ** 3)
+ return usage.percent, used_gb, total_gb, path
+ except Exception:
+ continue
+ return None, None, None, "/"
+
+ def _format_bytes(self, value: int) -> str:
+ size = float(value)
+ for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
+ if size < 1024:
+ return f"{size:.1f} {unit}"
+ size /= 1024
+ return f"{size:.1f} EB"
diff --git a/extensions/python/before_main_llm_call/AGENTS.md b/extensions/python/before_main_llm_call/AGENTS.md
index 10863d53a77dadd4a7d8254a01bb5b0c5d356f0f..36214e3b3f6b93f1fa0b18bd97f0a0ec27c9eee3 100644
--- a/extensions/python/before_main_llm_call/AGENTS.md
+++ b/extensions/python/before_main_llm_call/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9a6e854a5cf0d094abc2bc4da91833b82b0301d173ddff8537b16b341a60a868
-size 655
+# Before Main LLM Call Extensions DOX
+
+## Purpose
+
+- Own backend behavior that runs immediately before the main LLM call.
+
+## Ownership
+
+- Ordered Python files own pre-call logging or context preparation for streaming/model execution.
+
+## Local Contracts
+
+- Do not mutate prompt or history data unless the hook contract explicitly passes mutable state for that purpose.
+- Avoid logging secrets, hidden prompt sections, or private user data.
+
+## Work Guidance
+
+- Keep this hook light because it sits on the main model hot path.
+
+## Verification
+
+- Run targeted model-call or streaming tests after behavior changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/before_main_llm_call/_10_log_for_stream.py b/extensions/python/before_main_llm_call/_10_log_for_stream.py
index 04903bdd49063a53c644e7cb182ebcfa9938cd93..66fab40112d6e884f5d49ae860e7154fac0d98c8 100644
--- a/extensions/python/before_main_llm_call/_10_log_for_stream.py
+++ b/extensions/python/before_main_llm_call/_10_log_for_stream.py
@@ -1,3 +1,33 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:39f2b897c81318b9f399bd96aa5883bfa866bee8857a62cdd69061aacfed94ac
-size 1107
+from helpers import persist_chat, tokens
+from helpers.extension import Extension
+from agent import LoopData
+import asyncio
+from helpers.log import LogItem
+from helpers import log
+import math
+import uuid
+
+
+class LogForStream(Extension):
+
+ async def execute(self, loop_data: LoopData = LoopData(), text: str = "", **kwargs):
+ if not self.agent:
+ return
+
+ # create log message and store it in loop data temporary params
+ if "log_item_generating" not in loop_data.params_temporary:
+ loop_data.params_temporary["log_item_generating"] = (
+ self.agent.context.log.log(
+ type="agent",
+ heading=build_default_heading(self.agent),
+ id=str(uuid.uuid4()),
+ )
+ )
+
+def build_heading(agent, text: str, icon: str = "network_intelligence"):
+ # Include agent identifier for all agents (A0:, A1:, A2:, etc.)
+ agent_prefix = f"{agent.agent_name}: "
+ return f"{agent_prefix}{text}"
+
+def build_default_heading(agent):
+ return build_heading(agent, "Calling LLM...")
\ No newline at end of file
diff --git a/extensions/python/error_format/AGENTS.md b/extensions/python/error_format/AGENTS.md
index a11b09ee38dfe20bb6a0d44a6652bc3400b912fe..6dd136a737ed62fefcea000bfc8b401ab30f41d2 100644
--- a/extensions/python/error_format/AGENTS.md
+++ b/extensions/python/error_format/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9b7b73348ce16977cdbdcb128c99408b750a985698adf49d8c4a8c2976c2b059
-size 616
+# Error Format Extensions DOX
+
+## Purpose
+
+- Own backend error formatting and masking before errors are shown or logged.
+
+## Ownership
+
+- Ordered Python files own mutation of error-format data passed by the hook.
+
+## Local Contracts
+
+- Preserve secret masking and safe user-facing error messages.
+- Do not expose raw tokens, credentials, hidden prompts, or private payloads.
+
+## Work Guidance
+
+- Keep masking rules conservative and synchronized with tool/model error surfaces.
+
+## Verification
+
+- Test or inspect representative masked and unmasked error paths after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/error_format/_10_mask_errors.py b/extensions/python/error_format/_10_mask_errors.py
index cdfd1575d863e5e6e945d85cfd11c73bdf5837ef..3547e52208ddee8453ba4d75a68f12f0652a026f 100644
--- a/extensions/python/error_format/_10_mask_errors.py
+++ b/extensions/python/error_format/_10_mask_errors.py
@@ -1,3 +1,20 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:30811f67fa20158348d9bd71dc8ad3c11c5562330e090d9fcae9ef2e34d9dd18
-size 523
+from helpers.extension import Extension
+from helpers.secrets import get_secrets_manager
+
+
+class MaskErrorSecrets(Extension):
+
+ async def execute(self, **kwargs):
+ if not self.agent:
+ return
+
+ # Get error data from kwargs
+ msg = kwargs.get("msg")
+ if not msg:
+ return
+
+ secrets_mgr = get_secrets_manager(self.agent.context)
+
+ # Mask the error message
+ if "message" in msg:
+ msg["message"] = secrets_mgr.mask_values(msg["message"])
diff --git a/extensions/python/hist_add_before/AGENTS.md b/extensions/python/hist_add_before/AGENTS.md
index 9b39114d1c74bd30dcf2e6c097c0f14e004c7e5b..791b47294aa8c747bee7d31a007c515ea4a4f627 100644
--- a/extensions/python/hist_add_before/AGENTS.md
+++ b/extensions/python/hist_add_before/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5857c34d73e867c3034d4a2edad9d2e054469e9b2a81b5ee843fcd8ee8547e23
-size 629
+# History Add Before Extensions DOX
+
+## Purpose
+
+- Own preprocessing before messages are added to agent history.
+
+## Ownership
+
+- Ordered Python files own history content masking before persistence or model reuse.
+
+## Local Contracts
+
+- Preserve secret and sensitive-content masking before history storage.
+- Do not remove fields required by downstream history organization or replay.
+
+## Work Guidance
+
+- Coordinate history mutation changes with message persistence and plugin-owned history consumers.
+
+## Verification
+
+- Test message history insertion for masked content after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/hist_add_before/_10_mask_content.py b/extensions/python/hist_add_before/_10_mask_content.py
index b9defa010eef28908f73da019535b6565b124f68..5b09e14c542982496dae859e6c5d1326d53f98e9 100644
--- a/extensions/python/hist_add_before/_10_mask_content.py
+++ b/extensions/python/hist_add_before/_10_mask_content.py
@@ -1,3 +1,35 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:eb613a29492b4e9fa9b238de709d3fcee747b7ab6dee78e7871b0371dafb3f41
-size 1212
+from helpers.extension import Extension
+from helpers.secrets import get_secrets_manager
+
+
+class MaskHistoryContent(Extension):
+
+ def execute(self, **kwargs):
+ if not self.agent:
+ return
+
+ # Get content data from kwargs
+ content_data = kwargs.get("content_data")
+ if not content_data:
+ return
+
+ try:
+ secrets_mgr = get_secrets_manager(self.agent.context)
+
+ # Mask the content before adding to history
+ content_data["content"] = self._mask_content(content_data["content"], secrets_mgr)
+ except Exception as e:
+ # If masking fails, proceed without masking
+ pass
+
+ def _mask_content(self, content, secrets_mgr):
+ """Recursively mask secrets in message content."""
+ if isinstance(content, str):
+ return secrets_mgr.mask_values(content)
+ elif isinstance(content, list):
+ return [self._mask_content(item, secrets_mgr) for item in content]
+ elif isinstance(content, dict):
+ return {k: self._mask_content(v, secrets_mgr) for k, v in content.items()}
+ else:
+ # For other types, return as-is
+ return content
diff --git a/extensions/python/hist_add_tool_result/AGENTS.md b/extensions/python/hist_add_tool_result/AGENTS.md
index 73ab089c7831291667e7732d4fc9bdeb08dba30f..c1dd0ecf284e34a844642e1ba035aa2416b5c21d 100644
--- a/extensions/python/hist_add_tool_result/AGENTS.md
+++ b/extensions/python/hist_add_tool_result/AGENTS.md
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:b7fa7f5240203dec28a2b603a075b29bcbdaeecaf77450cb1dcf5f410577ffc7
-size 692
+# History Tool Result Extensions DOX
+
+## Purpose
+
+- Own processing after tool results are added to history.
+
+## Ownership
+
+- Ordered Python files own tool-call file persistence and related history side effects.
+
+## Local Contracts
+
+- Preserve tool result traceability without leaking secrets.
+- Keep file artifacts inside expected runtime/user-owned paths.
+- Skip BACKGROUND contexts; background workers must remain ephemeral and must not create chat message files.
+
+## Work Guidance
+
+- Coordinate changes with tool output storage and chat persistence behavior.
+
+## Verification
+
+- Smoke-test a tool call that produces persisted output after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/hist_add_tool_result/_90_save_tool_call_file.py b/extensions/python/hist_add_tool_result/_90_save_tool_call_file.py
index f5d855288bb894ed7ecc6be0c2ea94e05d901d98..fd5a328546559de92ae866b37bb6ac68d5b3e927 100644
--- a/extensions/python/hist_add_tool_result/_90_save_tool_call_file.py
+++ b/extensions/python/hist_add_tool_result/_90_save_tool_call_file.py
@@ -1,3 +1,44 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:8dd610d93ebf73e70c78f2eb476b1c766dae1ecf330f4597b894064a21e03874
-size 1208
+from typing import Any
+from agent import AgentContextType
+from helpers.extension import Extension
+from helpers import files, persist_chat
+import os, re
+
+LEN_MIN = 500
+
+class SaveToolCallFile(Extension):
+ def execute(self, data: dict[str, Any] | None = None, **kwargs):
+ if not self.agent:
+ return
+
+ if self.agent.context.type == AgentContextType.BACKGROUND:
+ return
+
+ if not data:
+ return
+
+ # get tool call result
+ result = data.get("tool_result") if isinstance(data, dict) else None
+ if result is None:
+ return
+
+ # skip short results
+ if len(str(result)) < LEN_MIN:
+ return
+
+ # message files directory
+ msgs_folder = persist_chat.get_chat_msg_files_folder(self.agent.context.id)
+ os.makedirs(msgs_folder, exist_ok=True)
+
+ # count the files in the directory
+ last_num = len(os.listdir(msgs_folder))
+
+ # create new file
+ new_file = files.get_abs_path(msgs_folder, f"{last_num+1}.txt")
+ files.write_file(
+ new_file,
+ result,
+ )
+
+ # add the path to the history
+ data["file"] = new_file
diff --git a/extensions/python/job_loop/AGENTS.md b/extensions/python/job_loop/AGENTS.md
index 09ab88dbac4252b44171783a4a3e4ad6d3e31ad4..afa7597a3dca5282d0f2b7ee92b492360d2bdab2 100644
--- a/extensions/python/job_loop/AGENTS.md
+++ b/extensions/python/job_loop/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a6af0f3ca610f74f000248f7483e06b217b803155116f24fd818137c87ae8eb4
-size 600
+# Job Loop Extensions DOX
+
+## Purpose
+
+- Own periodic backend maintenance jobs.
+
+## Ownership
+
+- Ordered Python files own cleanup of expired API chats, cache trimming, and future job-loop tasks.
+
+## Local Contracts
+
+- Jobs must be idempotent and safe to run repeatedly.
+- Keep cleanup scoped to owned caches, temporary contexts, or documented runtime state.
+
+## Work Guidance
+
+- Avoid expensive work on every loop; use timestamps or thresholds when practical.
+
+## Verification
+
+- Run targeted cleanup/cache tests or smoke-test job-loop startup after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/job_loop/_20_cleanup_expired_api_chats.py b/extensions/python/job_loop/_20_cleanup_expired_api_chats.py
index 76593d5b4024cd920a50dd7c747cf810ac845700..b8d9da0a096deba5af16463fbf49939a32710c02 100644
--- a/extensions/python/job_loop/_20_cleanup_expired_api_chats.py
+++ b/extensions/python/job_loop/_20_cleanup_expired_api_chats.py
@@ -1,3 +1,63 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:77ff57423672bedcbdf501f27cbcb7816192e088e2e98893be9c76ae819e67a9
-size 2119
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from agent import AgentContext
+from helpers import persist_chat
+from helpers.extension import Extension
+from helpers.print_style import PrintStyle
+from helpers.state_monitor_integration import mark_dirty_all
+
+
+CHECK_INTERVAL = timedelta(hours=1)
+LIFETIME_KEY = "lifetime_hours"
+
+
+class CleanupExpiredApiChats(Extension):
+ _last_check: datetime | None = None
+
+ async def execute(self, data: dict[str, Any] | None = None, **kwargs):
+ now = datetime.now(timezone.utc)
+ if type(self)._last_check and now - type(self)._last_check < CHECK_INTERVAL:
+ return
+ type(self)._last_check = now
+
+ removed = 0
+ for context in list(AgentContext.all()):
+ lifetime_hours = context.get_data(LIFETIME_KEY)
+ if lifetime_hours is None:
+ continue
+
+ try:
+ lifetime = timedelta(hours=float(lifetime_hours))
+ except (TypeError, ValueError):
+ PrintStyle.error(
+ f"Invalid chat lifetime for {context.id}: {lifetime_hours}"
+ )
+ continue
+
+ if lifetime <= timedelta(0) or context.is_running():
+ continue
+
+ last_message = _as_utc(context.last_message)
+ if now - last_message <= lifetime:
+ continue
+
+ try:
+ context.reset()
+ AgentContext.remove(context.id)
+ persist_chat.remove_chat(context.id)
+ removed += 1
+ PrintStyle().print(f"Cleaned up expired API chat: {context.id}")
+ except Exception as e:
+ PrintStyle.error(f"Failed to cleanup expired API chat {context.id}: {e}")
+
+ if removed:
+ mark_dirty_all(reason="job_loop.CleanupExpiredApiChats")
+
+
+def _as_utc(value: datetime | None) -> datetime:
+ if value is None:
+ return datetime.fromtimestamp(0, timezone.utc)
+ if value.tzinfo is None:
+ return value.replace(tzinfo=timezone.utc)
+ return value.astimezone(timezone.utc)
diff --git a/extensions/python/job_loop/_50_trim_cache.py b/extensions/python/job_loop/_50_trim_cache.py
index fec29e9519f394915733af9137f5cc5de80c59c4..74989fa86351910ad23a216d49904bf8c064b8ea 100644
--- a/extensions/python/job_loop/_50_trim_cache.py
+++ b/extensions/python/job_loop/_50_trim_cache.py
@@ -1,3 +1,9 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a5b536371abc86345790f9014baa645fc7d33fcd0a1062dc0fc27c56b7948587
-size 274
+from typing import Any
+from helpers.extension import Extension
+from helpers import cache
+
+
+class SaveToolCallFile(Extension):
+ def execute(self, data: dict[str, Any] | None = None, **kwargs):
+ # trim unused cache entries
+ cache.trim_cache("*", seconds=300)
diff --git a/extensions/python/message_loop_end/AGENTS.md b/extensions/python/message_loop_end/AGENTS.md
index 873abd0829e2cfed53768e937d2d5b21d6051d8a..2200c4aa3b773ca996e05bb2a538dbbe4012cb8c 100644
--- a/extensions/python/message_loop_end/AGENTS.md
+++ b/extensions/python/message_loop_end/AGENTS.md
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:aad03752d410b9e80eef91fa1e1b5a45b7907f2085001f6591e47878495d6f5c
-size 803
+# Message Loop End Extensions DOX
+
+## Purpose
+
+- Own backend behavior that runs after a message loop completes.
+
+## Ownership
+
+- Ordered Python files own history organization and chat persistence at loop end.
+
+## Local Contracts
+
+- Preserve history consistency before saving chats.
+- Do not skip persistence for successful loops unless the hook contract explicitly permits it.
+- History compression that rewrites local history must clear the active Responses provider continuation while preserving stored response IDs for cleanup.
+
+## Work Guidance
+
+- Coordinate changes with chat serialization, history organization, and WebUI refresh behavior.
+
+## Verification
+
+- Smoke-test sending a message, reloading the chat, and checking persisted history after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/message_loop_end/_10_organize_history.py b/extensions/python/message_loop_end/_10_organize_history.py
index e7b5130d3b849ce08ed7441c4aec81432392b70a..0cc205bd6ba959ebcf25f4926359e60ed7678fdf 100644
--- a/extensions/python/message_loop_end/_10_organize_history.py
+++ b/extensions/python/message_loop_end/_10_organize_history.py
@@ -1,3 +1,30 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:70998648652bb1192f47f3bc4a4907a1b7860045bf6f76dbf075841cea0394bb
-size 1047
+from helpers.extension import Extension
+from agent import LoopData
+from helpers.defer import DeferredTask, THREAD_BACKGROUND
+from helpers.history import clear_responses_provider_state
+
+DATA_NAME_TASK = "_organize_history_task"
+
+
+async def compress_history(agent) -> bool:
+ compressed = bool(await agent.history.compress())
+ if compressed:
+ clear_responses_provider_state(agent)
+ return compressed
+
+
+class OrganizeHistory(Extension):
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ # is there a running task? if yes, skip this round, the wait extension will double check the context size
+ task: DeferredTask|None = self.agent.get_data(DATA_NAME_TASK)
+ if task and not task.is_ready():
+ return
+
+ # start task
+ task = DeferredTask(thread_name=THREAD_BACKGROUND)
+ task.start_task(compress_history, self.agent)
+ # set to agent to be able to wait for it
+ self.agent.set_data(DATA_NAME_TASK, task)
diff --git a/extensions/python/message_loop_end/_90_save_chat.py b/extensions/python/message_loop_end/_90_save_chat.py
index 6d6601e4c052aebeef85a66adac51dc54b29ca86..72a3410be9e2ec1a7188214bb8d7bea3fae3f6af 100644
--- a/extensions/python/message_loop_end/_90_save_chat.py
+++ b/extensions/python/message_loop_end/_90_save_chat.py
@@ -1,3 +1,15 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:8f2efeb0b9ddafa494eb58cdc1f649eb2edf9f0ae7c16d8f58d6cb95d6bc87ab
-size 479
+from helpers.extension import Extension
+from agent import LoopData, AgentContextType
+from helpers import persist_chat
+
+
+class SaveChat(Extension):
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ # Skip saving BACKGROUND contexts as they should be ephemeral
+ if self.agent.context.type == AgentContextType.BACKGROUND:
+ return
+
+ persist_chat.save_tmp_chat(self.agent.context)
diff --git a/extensions/python/message_loop_prompts_after/AGENTS.md b/extensions/python/message_loop_prompts_after/AGENTS.md
index fdf7d914f574d9048913b5ac5f2c093faaf672b8..1a183d994789723ae818035b0204362f166807b3 100644
--- a/extensions/python/message_loop_prompts_after/AGENTS.md
+++ b/extensions/python/message_loop_prompts_after/AGENTS.md
@@ -1,3 +1,33 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:42e777c4abc98d45ef762257a7bd55f079df91a7b6dc1ef1bffbfc72b288cf4d
-size 1383
+# Message Loop Prompts After Extensions DOX
+
+## Purpose
+
+- Own prompt protocol, prompt extras, and history reattachment around primary message-loop prompt construction.
+
+## Ownership
+
+- Ordered Python files own current datetime, relevant-skill hints, loaded-skill history reattachment, agent info, parallel job status, and workdir extras injection.
+- Explicitly loaded skill bodies belong in tool-result history with metadata so they can survive persistence and be reattached after compaction.
+- Explicitly loaded skill IDs are chat-wide context data, not agent-local state.
+- Skills must not write selected or loaded skill bodies into protocol or extras.
+
+## Local Contracts
+
+- Keep injected content bounded and clearly attributed.
+- Preserve ordering where later prompt extras depend on earlier recall or load results.
+- Do not expose secrets or private files from workdir extras.
+- Relevant-skill recall should search the raw user message when available, not the rendered history wrapper.
+- Relevant-skill hints must not advertise loading when profile policy blocks
+ `skills_tool`.
+
+## Work Guidance
+
+- Coordinate prompt protocol, history-reattachment, and prompt-extra changes with skill, workdir, and profile contracts.
+
+## Verification
+
+- Inspect rendered prompt protocol/history/extras or run prompt-construction tests after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/message_loop_prompts_after/_60_include_current_datetime.py b/extensions/python/message_loop_prompts_after/_60_include_current_datetime.py
index faef383960337c1080bdbf4e2cdaf1f96f95f94a..c57c52291f745046b9e38add33ce18749bb3f60a 100644
--- a/extensions/python/message_loop_prompts_after/_60_include_current_datetime.py
+++ b/extensions/python/message_loop_prompts_after/_60_include_current_datetime.py
@@ -1,3 +1,19 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6737327bbd60eb39b4cf9d581c243a472cba4f69e2abe6bb42f207970d3f601e
-size 633
+from helpers.extension import Extension
+from agent import LoopData
+from helpers.localization import Localization
+
+
+class IncludeCurrentDatetime(Extension):
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ current_datetime = Localization.get().now().strftime("%Y-%m-%d %H:%M:%S %Z")
+
+ # read prompt
+ datetime_prompt = self.agent.read_prompt(
+ "agent.system.datetime.md", date_time=current_datetime
+ )
+
+ # add current datetime to the loop data
+ loop_data.extras_temporary["current_datetime"] = datetime_prompt
diff --git a/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py b/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py
index 39dd620c03a6d9a3ef23abfece5bea7919f09519..ccfd6f42cf213bf35ad5f65f95e3ea1c7619f937 100644
--- a/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py
+++ b/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py
@@ -1,3 +1,45 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:130e5e731e029d363cf3cf361b33613c846a40627a81fb0eb2967c568fff24ea
-size 1544
+from agent import LoopData
+from helpers.extension import Extension
+from helpers import skills as skills_helper, tool_policy
+
+
+class RecallRelevantSkills(Extension):
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent or loop_data.iteration != 0:
+ return
+ if not tool_policy.resolve_tool(self.agent, "skills_tool").allowed:
+ return
+
+ content = loop_data.user_message.content if loop_data.user_message else ""
+ if isinstance(content, dict):
+ user_instruction = str(content.get("user_message") or "").strip()
+ else:
+ user_instruction = (
+ loop_data.user_message.output_text() if loop_data.user_message else ""
+ ).strip()
+ if len(user_instruction) < 8:
+ return
+
+ matches = skills_helper.search_skills(
+ user_instruction,
+ limit=6,
+ agent=self.agent,
+ )
+ if not matches:
+ return
+
+ lines: list[str] = []
+ for skill in matches:
+ name = skill.name.strip().replace("\n", " ")[:100]
+ desc = (skill.description or "").replace("\n", " ").strip()
+ if len(desc) > 220:
+ desc = desc[:220].rstrip() + "…"
+ lines.append(f"- {name}: {desc}")
+
+ if not lines:
+ return
+
+ loop_data.extras_temporary["relevant_skills"] = self.agent.read_prompt(
+ "agent.system.skills.relevant.md",
+ skills="\n".join(lines),
+ )
diff --git a/extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py b/extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py
index 20fc4410f42cf8d78e33a28051a9915e919a30b2..6c604005a065727eb1fa019a2bd178ac027852b9 100644
--- a/extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py
+++ b/extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py
@@ -1,3 +1,78 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3d911949e4103bf7d9a756a85517d4cca75aeab223d8f57956244fdf110a1cd3
-size 2668
+from helpers.extension import Extension
+from helpers import skills, tokens
+from agent import LoopData
+
+
+SKILL_REATTACHMENT_TOKEN_BUDGET = 12_000
+SKILL_REATTACHMENT_HEADER = (
+ "Reattached loaded skill instructions after history compaction."
+)
+
+
+class IncludeLoadedSkills(Extension):
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ skill_names = skills.get_loaded_skill_names(self.agent)
+ if not skill_names:
+ return
+
+ # Loaded skill bodies live in tool-result history. This hook only keeps
+ # the ledger clean and restores bodies that compaction hid.
+ visible_skill_names = []
+ loaded_skills = []
+ for skill_name in skill_names:
+ skill = skills.find_skill(skill_name, agent=self.agent)
+ if not skill:
+ continue
+ visible_skill_names.append(skill.name)
+ loaded_skills.append(skill)
+ skills.set_loaded_skill_names(self.agent, visible_skill_names)
+
+ self._reattach_missing_skill_bodies(loop_data, loaded_skills)
+
+ def _reattach_missing_skill_bodies(self, loop_data: LoopData, loaded_skills):
+ if not self.agent or not loaded_skills:
+ return
+
+ visible_skill_names = _visible_skill_names(loop_data.history_output)
+ selected = []
+ used_tokens = 0
+
+ for skill in reversed(loaded_skills):
+ if skill.name in visible_skill_names:
+ continue
+
+ skill_data = skills.load_skill_for_agent(
+ skill_name=skill.name,
+ agent=self.agent,
+ )
+ message = f"{SKILL_REATTACHMENT_HEADER}\n\n{skill_data}"
+ message_tokens = tokens.approximate_tokens(message)
+ if used_tokens + message_tokens > SKILL_REATTACHMENT_TOKEN_BUDGET:
+ continue
+
+ selected.append((skill, message))
+ used_tokens += message_tokens
+
+ for skill, message in reversed(selected):
+ history_message = self.agent.hist_add_tool_result(
+ "skills_tool",
+ message,
+ skill_instructions={
+ "name": skill.name,
+ "path": str(skill.path),
+ "source": "skills_tool:reattach",
+ "content_included": True,
+ },
+ )
+ loop_data.history_output.extend(history_message.output())
+
+
+def _visible_skill_names(history_output) -> set[str]:
+ return {
+ name
+ for message in history_output or []
+ if (name := skills.skill_instruction_name(message))
+ }
diff --git a/extensions/python/message_loop_prompts_after/_70_include_agent_info.py b/extensions/python/message_loop_prompts_after/_70_include_agent_info.py
index 0702cbd63723213dd0c75775be2da12cffebe98d..c4dc3f4d7c67c2ea6b91c318b3a46093631d7df4 100644
--- a/extensions/python/message_loop_prompts_after/_70_include_agent_info.py
+++ b/extensions/python/message_loop_prompts_after/_70_include_agent_info.py
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c10a045b764ddc51c2f00ba3e7a9cc702fd8acd3b31f3b7942707eeea1c432e9
-size 931
+from helpers.extension import Extension
+from agent import LoopData
+
+
+class IncludeAgentInfo(Extension):
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ # read prompt
+ from plugins._model_config.helpers.model_config import (
+ get_chat_model_config,
+ get_effective_preset_name,
+ )
+ chat_cfg = get_chat_model_config(self.agent)
+ preset_name = get_effective_preset_name(self.agent)
+
+ agent_info_prompt = self.agent.read_prompt(
+ "agent.extras.agent_info.md",
+ number=self.agent.number,
+ profile=self.agent.config.profile or "Default",
+ llm=chat_cfg.get("provider", "") + "/" + chat_cfg.get("name", ""),
+ preset=preset_name,
+ )
+
+ # add agent info to the prompt
+ loop_data.extras_temporary["agent_info"] = agent_info_prompt
diff --git a/extensions/python/message_loop_prompts_after/_72_include_parallel_jobs.py b/extensions/python/message_loop_prompts_after/_72_include_parallel_jobs.py
index 094d7a2bba7dbd87223873328010aae2a568815e..70bc0ce4eb9e5b9fe1fe90d98c3792da7cf8cb27 100644
--- a/extensions/python/message_loop_prompts_after/_72_include_parallel_jobs.py
+++ b/extensions/python/message_loop_prompts_after/_72_include_parallel_jobs.py
@@ -1,3 +1,13 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:2d4e5220acd60cb3de270e2e828db61c999c951ab5d9f9bbb9f5457bc1a15f2b
-size 423
+from helpers.extension import Extension
+from agent import LoopData
+from helpers import parallel_tools
+
+
+class IncludeParallelJobs(Extension):
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ extras = await parallel_tools.build_parallel_jobs_extras(self.agent)
+ if extras:
+ loop_data.extras_temporary["parallel_jobs"] = extras
diff --git a/extensions/python/message_loop_prompts_after/_75_include_workdir_extras.py b/extensions/python/message_loop_prompts_after/_75_include_workdir_extras.py
index ab05be42311cb1835120f3ecf044efb0029cc060..a6b5cf10b83de9e0306252836ec72edb1465220d 100644
--- a/extensions/python/message_loop_prompts_after/_75_include_workdir_extras.py
+++ b/extensions/python/message_loop_prompts_after/_75_include_workdir_extras.py
@@ -1,3 +1,96 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:bc46c3d0e739e5f9f66e753ff11d589dd857eff3ad328d9b00244e65ab307dcd
-size 3173
+from helpers.extension import Extension
+from agent import LoopData
+from helpers import projects
+from helpers import settings
+from helpers import runtime
+from helpers import file_tree
+from helpers import files
+
+class IncludeWorkdirExtras(Extension):
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ project_name = projects.get_context_project_name(self.agent.context)
+
+ enabled = False
+ max_depth = 0
+ max_files = 0
+ max_folders = 0
+ max_lines = 0
+ gitignore_raw = ""
+ folder = ""
+ file_structure = ""
+
+ if project_name:
+ project = projects.load_basic_project_data(project_name)
+ enabled = project["file_structure"]["enabled"]
+
+ if not enabled:
+ return
+
+ max_depth = project["file_structure"]["max_depth"]
+ gitignore_raw = project["file_structure"]["gitignore"]
+
+ folder = projects.get_project_folder(project_name)
+ if runtime.is_development():
+ folder = files.normalize_a0_path(folder)
+
+ file_structure = projects.get_file_structure(project_name)
+ else:
+ set = settings.get_settings_for_prompt()
+ enabled = bool(set["workdir_show"])
+
+ if not enabled:
+ return
+
+ max_depth = set["workdir_max_depth"]
+ max_files = set["workdir_max_files"]
+ max_folders = set["workdir_max_folders"]
+ max_lines = set["workdir_max_lines"]
+ gitignore_raw = set["workdir_gitignore"]
+
+ folder = set["workdir_path"]
+ scan_path = files.get_abs_path_development(folder)
+
+ files.create_dir(scan_path)
+
+ file_structure = str(
+ file_tree.file_tree(
+ scan_path,
+ max_depth=max_depth,
+ max_files=max_files,
+ max_folders=max_folders,
+ max_lines=max_lines,
+ ignore=gitignore_raw,
+ output_mode=file_tree.OUTPUT_MODE_STRING,
+ )
+ )
+
+ gitignore = cleanup_gitignore(gitignore_raw)
+
+ file_structure_prompt = self.agent.read_prompt(
+ "agent.extras.workdir_structure.md",
+ max_depth=max_depth,
+ gitignore=gitignore,
+ folder=folder,
+ file_structure=file_structure,
+ )
+
+ loop_data.extras_temporary["project_file_structure"] = file_structure_prompt
+
+
+def cleanup_gitignore(gitignore_raw: str) -> str:
+ """Process gitignore: split lines, strip, remove comments, remove empty lines."""
+ gitignore_lines = []
+ for line in gitignore_raw.split('\n'):
+ # Strip whitespace
+ line = line.strip()
+ # Remove inline comments (everything after #)
+ if '#' in line:
+ line = line.split('#')[0].strip()
+ # Keep only non-empty lines
+ if line:
+ gitignore_lines.append(line)
+
+ return '\n'.join(gitignore_lines) if gitignore_lines else "nothing ignored"
diff --git a/extensions/python/message_loop_prompts_before/AGENTS.md b/extensions/python/message_loop_prompts_before/AGENTS.md
index 4938f736a9440cd9f47f5bb7fccd173b3282f40c..3483592c7098fe1f49b042b147bce438664f0744 100644
--- a/extensions/python/message_loop_prompts_before/AGENTS.md
+++ b/extensions/python/message_loop_prompts_before/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:84ec20842d08ebea0e8f684a9e2ca88b1ae9a41f57e2a9467c6c68442595a91f
-size 578
+# Message Loop Prompts Before Extensions DOX
+
+## Purpose
+
+- Own preprocessing before message-loop prompt construction.
+
+## Ownership
+
+- Ordered Python files own history organization waits and related prompt-preparation gates.
+
+## Local Contracts
+
+- Preserve history consistency before prompts are assembled.
+- Avoid blocking indefinitely on background organization tasks.
+
+## Work Guidance
+
+- Keep waiting behavior observable and bounded.
+
+## Verification
+
+- Smoke-test prompt construction after chats with pending history organization.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/message_loop_prompts_before/_90_organize_history_wait.py b/extensions/python/message_loop_prompts_before/_90_organize_history_wait.py
index f33cada195b90450e78c14170b000623897fd4e2..08966d2b1db730da02c058b0220afa07c2824c64 100644
--- a/extensions/python/message_loop_prompts_before/_90_organize_history_wait.py
+++ b/extensions/python/message_loop_prompts_before/_90_organize_history_wait.py
@@ -1,3 +1,67 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:11ccd593776872e26c8501414b3847ec7f26c90661d81f827ad6258a708b1da4
-size 2453
+from helpers.extension import Extension
+from agent import LoopData
+from extensions.python.message_loop_end._10_organize_history import (
+ DATA_NAME_TASK,
+ compress_history,
+)
+from helpers.defer import DeferredTask, THREAD_BACKGROUND
+
+MAX_SYNC_COMPRESSION_PASSES = 64
+
+
+class OrganizeHistoryWait(Extension):
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ # sync action only required if the history is too large, otherwise leave it in background
+ passes = 0
+ while self.agent.history.is_over_limit():
+ passes += 1
+ before_tokens = self.agent.history.get_tokens()
+
+ # get task
+ task: DeferredTask | None = self.agent.get_data(DATA_NAME_TASK)
+
+ # Check if the task is already done
+ if task:
+ if not task.is_ready():
+ self.agent.context.log.set_progress("Compressing history...")
+
+ # Wait for the task to complete
+ compressed = bool(await task.result())
+
+ # Clear the coroutine data after it's done
+ self.agent.set_data(DATA_NAME_TASK, None)
+ else:
+ # no task was running, start and wait
+ self.agent.context.log.set_progress("Compressing history...")
+ compressed = await compress_history(self.agent)
+
+ after_tokens = self.agent.history.get_tokens()
+ if not compressed or after_tokens >= before_tokens:
+ self._log_compression_stalled(before_tokens, after_tokens)
+ break
+
+ if passes >= MAX_SYNC_COMPRESSION_PASSES:
+ self._log_compression_stalled(
+ before_tokens, after_tokens, max_passes=True
+ )
+ break
+
+ def _log_compression_stalled(
+ self, before_tokens: int, after_tokens: int, max_passes: bool = False
+ ) -> None:
+ if not self.agent:
+ return
+
+ detail = (
+ f"History compression stopped after {MAX_SYNC_COMPRESSION_PASSES} passes"
+ if max_passes
+ else "History compression could not reduce the prompt history further"
+ )
+ self.agent.context.log.log(
+ type="warning",
+ heading="History compression stalled",
+ content=f"{detail}. Tokens before: {before_tokens}; after: {after_tokens}.",
+ )
diff --git a/extensions/python/message_loop_result/AGENTS.md b/extensions/python/message_loop_result/AGENTS.md
index ff01f244c7a3b9514d8be2bea516c1e0f7fbc653..c1395c5fb8e942b325bc81283fb85dcb44fbe234 100644
--- a/extensions/python/message_loop_result/AGENTS.md
+++ b/extensions/python/message_loop_result/AGENTS.md
@@ -1,3 +1,31 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:13ea49ad9c43187bd08b39a3ee2606e11927a6e70a4bd2125b4a1cce1d52516b
-size 1458
+# Message Loop Result Extensions DOX
+
+## Purpose
+
+- Own normalization and policy handling after a model turn completes, before default assistant-history and tool-dispatch processing.
+
+## Ownership
+
+- Extensions receive mutable `result_data` with `llm_result` and may set `skip_default_processing` after fully handling the turn.
+- `_20_empty_response.py` retries turns with neither response nor reasoning, counts them toward the unusable-response limit without adding a warning to model history, and uses `fw.msg_empty_response.md` for agent-prefixed UI warning text only.
+- `_30_repeat_response.py` retries response content that exactly matches `loop_data.last_response`, regardless of reasoning, using `fw.msg_repeat.md` for history and `fw.msg_repeat_response.md` for the agent-prefixed UI warning text.
+
+## Local Contracts
+
+- Files run in deterministic filename order.
+- A handler that sets `skip_default_processing` owns needed history and UI side effects for that turn.
+- Handlers that should not add side effects after an earlier extension has handled the result must return when `skip_default_processing` is set.
+- Do not use this point to mutate streamed partial content.
+
+## Work Guidance
+
+- Normalize a completed result before policy extensions compare or persist it.
+- Keep loop-control policy independent from optional plugins.
+
+## Verification
+
+- Run message-loop and unusable-response regression tests.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/message_loop_result/_20_empty_response.py b/extensions/python/message_loop_result/_20_empty_response.py
index 958fe026f7a3e130d018117cc02ceb87190c7aa9..aa79bf8c8999a98510963f3c0ef3267c697d441e 100644
--- a/extensions/python/message_loop_result/_20_empty_response.py
+++ b/extensions/python/message_loop_result/_20_empty_response.py
@@ -1,3 +1,53 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3ac3e39730ee55e33b28378a1a53a564812b20baa6857eac55164ca30acd78f5
-size 2074
+"""Retry completed model turns with neither response nor reasoning."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from helpers.errors import HandledException
+from helpers.extension import Extension
+from helpers.print_style import PrintStyle
+from helpers.settings import get_settings
+
+
+STATE_KEY = "_unusable_response_failures"
+
+
+class EmptyResponse(Extension):
+ def execute(self, result_data: dict[str, Any] | None = None, **kwargs: Any) -> None:
+ if not self.agent or not isinstance(result_data, dict):
+ return
+ if result_data.get("skip_default_processing"):
+ return
+
+ llm_result = result_data.get("llm_result")
+ response = getattr(llm_result, "response", "")
+ reasoning = getattr(llm_result, "reasoning", "")
+ if not isinstance(response, str) or not isinstance(reasoning, str):
+ return
+ if response.strip() or reasoning.strip():
+ return
+
+ warning = self.agent.read_prompt("fw.msg_empty_response.md")
+ PrintStyle(font_color="orange", padding=True).print(warning)
+ state = self.agent.loop_data.params_persistent
+ previous = state.get(STATE_KEY, {})
+ previous_iteration = previous.get("iteration") if isinstance(previous, dict) else None
+ count = (
+ previous.get("count", 0) + 1
+ if previous_iteration == self.agent.loop_data.iteration - 1
+ else 1
+ )
+ state[STATE_KEY] = {"iteration": self.agent.loop_data.iteration, "count": count}
+ limit = get_settings()["max_consecutive_unusable_responses"]
+ if count >= limit:
+ stop_message = self.agent.read_prompt(
+ "fw.msg_unusable_response_limit.md", limit=limit
+ )
+ self.agent.context.log.log(type="warning", content=stop_message)
+ raise HandledException(stop_message)
+ self.agent.context.log.log(
+ type="warning",
+ content=f"{self.agent.agent_name}: {warning}",
+ )
+ result_data["skip_default_processing"] = True
diff --git a/extensions/python/message_loop_result/_30_repeat_response.py b/extensions/python/message_loop_result/_30_repeat_response.py
index f06dc53316822264b446d678774988a1ff290e15..45457ff82440279275f7fd28bc066b9375317264 100644
--- a/extensions/python/message_loop_result/_30_repeat_response.py
+++ b/extensions/python/message_loop_result/_30_repeat_response.py
@@ -1,3 +1,38 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:829c800ff43a67bfa30e5f18dfd62598ac885b601533949fe0dc2ee599c04d65
-size 1514
+"""Retry completed model turns that repeat the prior response."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from helpers.extension import Extension
+from helpers.print_style import PrintStyle
+
+
+class RepeatResponse(Extension):
+ def execute(self, result_data: dict[str, Any] | None = None, **kwargs: Any) -> None:
+ if not self.agent or not isinstance(result_data, dict):
+ return
+ if result_data.get("skip_default_processing"):
+ return
+
+ llm_result = result_data.get("llm_result")
+ response = getattr(llm_result, "response", "")
+ if not isinstance(response, str) or response != self.agent.loop_data.last_response:
+ return
+
+ warning = self.agent.read_prompt("fw.msg_repeat.md")
+ log_item = self.agent.loop_data.params_temporary.get("log_item_generating")
+ assistant_message = self.agent.hist_add_ai_response(
+ response,
+ id=log_item.id if log_item else "",
+ llm_result=llm_result,
+ )
+ self.agent._remember_llm_result_state(llm_result, assistant_message)
+ warning_message = self.agent.hist_add_warning(message=warning)
+ PrintStyle(font_color="orange", padding=True).print(warning)
+ self.agent.context.log.log(
+ type="warning",
+ content=f"{self.agent.agent_name}: {self.agent.read_prompt('fw.msg_repeat_response.md')}",
+ id=warning_message.id,
+ )
+ result_data["skip_default_processing"] = True
diff --git a/extensions/python/message_loop_start/AGENTS.md b/extensions/python/message_loop_start/AGENTS.md
index 11ceba7b93ac1f06af3d01e1e0d0853c0ff0974a..a0c6c1790905b3f9ebaf8c71b139c54482dcc3dc 100644
--- a/extensions/python/message_loop_start/AGENTS.md
+++ b/extensions/python/message_loop_start/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:293e468e821fbac3bc0fc1baaaf9c8678db6b281ffe69652cb351934b8caa0c4
-size 592
+# Message Loop Start Extensions DOX
+
+## Purpose
+
+- Own backend behavior that runs at the start of each message loop iteration.
+
+## Ownership
+
+- Ordered Python files own iteration counters and future loop-start state setup.
+
+## Local Contracts
+
+- Keep per-loop counters deterministic and scoped to the active context.
+- Do not reset state owned by monologue-level hooks.
+
+## Work Guidance
+
+- Coordinate loop-start state changes with logging, streaming, and process-chain behavior.
+
+## Verification
+
+- Smoke-test multi-turn conversations after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/message_loop_start/_10_iteration_no.py b/extensions/python/message_loop_start/_10_iteration_no.py
index 1b87750c976cb1d63c40f0e208fad6d7c375cf8d..81fc4af97566941adf45f447b3918f82beed89e7 100644
--- a/extensions/python/message_loop_start/_10_iteration_no.py
+++ b/extensions/python/message_loop_start/_10_iteration_no.py
@@ -1,3 +1,17 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:bd43316806328046d783e603e9620ce577395b218fa570fb3bc06fa3c9a3d005
-size 507
+from helpers.extension import Extension
+from agent import Agent, LoopData
+
+DATA_NAME_ITER_NO = "iteration_no"
+
+class IterationNo(Extension):
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ # total iteration number
+ no = self.agent.get_data(DATA_NAME_ITER_NO) or 0
+ self.agent.set_data(DATA_NAME_ITER_NO, no + 1)
+
+
+def get_iter_no(agent: Agent) -> int:
+ return agent.get_data(DATA_NAME_ITER_NO) or 0
\ No newline at end of file
diff --git a/extensions/python/monologue_end/AGENTS.md b/extensions/python/monologue_end/AGENTS.md
index 18939f31faba7aeef73ef94433f091c00167a02d..b842ba0711f63f1e238889091a236d69fc161b14 100644
--- a/extensions/python/monologue_end/AGENTS.md
+++ b/extensions/python/monologue_end/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3a6741c56324d4249821b4a744b4d1cafa912ee61c424b55b85c7b79540febed
-size 576
+# Monologue End Extensions DOX
+
+## Purpose
+
+- Own backend behavior that runs when a monologue ends.
+
+## Ownership
+
+- Ordered Python files own waiting-for-input UI message behavior and future monologue-end cleanup.
+
+## Local Contracts
+
+- Preserve clear UI state when the agent stops for user input.
+- Do not leave loading/processing indicators stale.
+
+## Work Guidance
+
+- Coordinate changes with WebUI loading state and message-loop persistence.
+
+## Verification
+
+- Smoke-test an agent response that returns to waiting-for-input state.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/monologue_end/_90_waiting_for_input_msg.py b/extensions/python/monologue_end/_90_waiting_for_input_msg.py
index e255ac24ddeaad5c46b304d11e0f32193d7a731d..09a025b1046b7b7dcc7601f514c06c8945bf8ac3 100644
--- a/extensions/python/monologue_end/_90_waiting_for_input_msg.py
+++ b/extensions/python/monologue_end/_90_waiting_for_input_msg.py
@@ -1,3 +1,12 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6c783e2e86031a26d33ed50204fbeb28004e1813618f9eb5851e98f2a69712d5
-size 364
+from helpers.extension import Extension
+from agent import LoopData
+
+class WaitingForInputMsg(Extension):
+
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ # show temp info message
+ if self.agent.number == 0:
+ self.agent.context.log.set_initial_progress()
diff --git a/extensions/python/monologue_start/AGENTS.md b/extensions/python/monologue_start/AGENTS.md
index 35412924c97fc7d9845964fe190f270be7cc0827..af75a96458c789a05f01b219ad18ee4bbee99378 100644
--- a/extensions/python/monologue_start/AGENTS.md
+++ b/extensions/python/monologue_start/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a127681e8f3a83a292232daea1386f094e8253a29192cc22fe28c42a9b0ba6d7
-size 598
+# Monologue Start Extensions DOX
+
+## Purpose
+
+- Own core backend behavior that runs when a monologue starts.
+
+## Ownership
+
+- Ordered Python files own core monologue-start setup.
+
+## Local Contracts
+
+- Keep start-of-monologue work bounded and non-blocking where appropriate.
+- Plugin-specific behavior belongs in the owning plugin's `extensions/python/monologue_start/` directory.
+
+## Work Guidance
+
+- Coordinate lifecycle changes with the message loop and relevant plugin hooks.
+
+## Verification
+
+- Smoke-test the first monologue after a new user message.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/process_chain_end/AGENTS.md b/extensions/python/process_chain_end/AGENTS.md
index 17397ce0334b7eef37d97acaf4be03615900c14d..c703404175d82473ffbb02645c0f9e814e73c1f2 100644
--- a/extensions/python/process_chain_end/AGENTS.md
+++ b/extensions/python/process_chain_end/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:189f8b5532b795535555b78d8f726d511bd8f0154e3aee65e924d854f03d4ae0
-size 626
+# Process Chain End Extensions DOX
+
+## Purpose
+
+- Own backend behavior after process-chain execution completes.
+
+## Ownership
+
+- Ordered Python files own queued-message processing and future process-chain completion behavior.
+
+## Local Contracts
+
+- Preserve queue ordering and avoid duplicate message processing.
+- Keep queue side effects synchronized with chat persistence and WebUI state.
+
+## Work Guidance
+
+- Coordinate changes with message queue components and external integration plugins.
+
+## Verification
+
+- Smoke-test queued messages and final response handling after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/process_chain_end/_50_process_queue.py b/extensions/python/process_chain_end/_50_process_queue.py
index 63ae00044a493356edde1d7f776d210689407b0c..ad2960be8e57b222106dc9314cb63386bd2e4faf 100644
--- a/extensions/python/process_chain_end/_50_process_queue.py
+++ b/extensions/python/process_chain_end/_50_process_queue.py
@@ -1,3 +1,39 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:af9c0676106e070f0db2b51b007a5ae330ffc73701768b1832192db8c6d91310
-size 1441
+import asyncio
+from helpers.extension import Extension
+from helpers import message_queue as mq
+from agent import AgentContext, Agent, LoopData
+from helpers.state_monitor_integration import mark_dirty_for_context
+
+
+class ProcessQueue(Extension):
+ """Process queued messages after monologue ends."""
+
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
+ if not self.agent:
+ return
+
+ # Only process for agent0 (main agent)
+ if self.agent.number != 0:
+ return
+
+ context = self.agent.context
+
+ # Check if there are queued messages
+ if mq.has_queue(context):
+ # Schedule delayed task to send next queued message
+ # This allows current monologue to fully complete first
+ asyncio.create_task(self._delayed_send(context))
+
+ async def _delayed_send(self, context: AgentContext):
+ """Wait for task to complete, then send next queued message."""
+
+ # Wait for current task to finish, but no more than 1 minute to prevent hanging tasks
+ total_wait = 0
+ while context.is_running() and total_wait < 60:
+ await asyncio.sleep(0.1)
+ total_wait += 0.1
+
+ # Send next queued message if task is not running
+ if not context.is_running():
+ if mq.send_next(context):
+ mark_dirty_for_context(context.id, reason="message_queue_auto_send")
diff --git a/extensions/python/reasoning_stream/AGENTS.md b/extensions/python/reasoning_stream/AGENTS.md
index ba1f3ab11767e2080665e471665f1df5019a70a5..6cd3e6b2e2bcb73e30d40b7796e624e453598535 100644
--- a/extensions/python/reasoning_stream/AGENTS.md
+++ b/extensions/python/reasoning_stream/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:0b33ce0d7d9d5b546643cd89301af7f7995389be6d8f4bd6839001772d2da8be
-size 576
+# Reasoning Stream Extensions DOX
+
+## Purpose
+
+- Own handling of full reasoning stream updates.
+
+## Ownership
+
+- Ordered Python files own logging reasoning content from stream state.
+
+## Local Contracts
+
+- Preserve masking and privacy rules for reasoning content.
+- Keep stream logging compatible with chunk and end hooks.
+
+## Work Guidance
+
+- Coordinate reasoning stream changes with UI log rendering and hidden-content policy.
+
+## Verification
+
+- Smoke-test reasoning stream display/logging when the active model provides reasoning.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/reasoning_stream/_10_log_from_stream.py b/extensions/python/reasoning_stream/_10_log_from_stream.py
index bdd65838ff80a76c79225c5463ed4c23d379f264..8f228b9b1bfaeedb6a8d43ccb1146409392ea079 100644
--- a/extensions/python/reasoning_stream/_10_log_from_stream.py
+++ b/extensions/python/reasoning_stream/_10_log_from_stream.py
@@ -1,3 +1,34 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:2958f7d5cd63c8c18dc6205c2d86690fc59def12b8a4ce88fb593c167bb16fe7
-size 1269
+from helpers import persist_chat, tokens
+from helpers.extension import Extension
+from agent import LoopData
+import asyncio
+from helpers.log import LogItem
+from helpers import log
+import math
+from extensions.python.before_main_llm_call._10_log_for_stream import build_heading, build_default_heading
+
+class LogFromStream(Extension):
+
+ async def execute(self, loop_data: LoopData = LoopData(), text: str = "", **kwargs):
+ if not self.agent:
+ return
+
+ # thought length indicator
+ length = f"({len(text)})" if text else ""
+ pipes = "|" * math.ceil(math.sqrt(len(text))/2)
+ heading = build_heading(self.agent, f"Reasoning... {pipes}")
+ step = f"Reasoning... {length}"
+
+ # create log message and store it in loop data temporary params
+ if "log_item_generating" not in loop_data.params_temporary:
+ loop_data.params_temporary["log_item_generating"] = (
+ self.agent.context.log.log(
+ type="agent",
+ heading=heading,
+ step=step
+ )
+ )
+
+ # update log message
+ log_item = loop_data.params_temporary["log_item_generating"]
+ log_item.update(heading=heading, reasoning=text, step=step)
diff --git a/extensions/python/reasoning_stream_chunk/AGENTS.md b/extensions/python/reasoning_stream_chunk/AGENTS.md
index 32ba60aaf182064a49379ca1b0825946ed71e4d8..4e7461224b2bfc1594f7ac98c7696e31953bdd6d 100644
--- a/extensions/python/reasoning_stream_chunk/AGENTS.md
+++ b/extensions/python/reasoning_stream_chunk/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d3b83de9707a34473f0519ecc99c9863bc086b9f6d8d270b43085290ef0312ab
-size 598
+# Reasoning Stream Chunk Extensions DOX
+
+## Purpose
+
+- Own handling of incremental reasoning stream chunks.
+
+## Ownership
+
+- Ordered Python files own chunk-level masking before reasoning is displayed or stored.
+
+## Local Contracts
+
+- Mask secrets and sensitive content before chunk data reaches logs or UI.
+- Keep chunk mutation compatible with final stream-end masking.
+
+## Work Guidance
+
+- Keep chunk processing lightweight for streaming performance.
+
+## Verification
+
+- Smoke-test streamed reasoning with representative sensitive patterns after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/reasoning_stream_chunk/_10_mask_stream.py b/extensions/python/reasoning_stream_chunk/_10_mask_stream.py
index 89ad5d6bba8d6d858e128fbf4f599de73943d587..bd3adc855d88cd8e86bf71aa2dde59c19fd1d5b4 100644
--- a/extensions/python/reasoning_stream_chunk/_10_mask_stream.py
+++ b/extensions/python/reasoning_stream_chunk/_10_mask_stream.py
@@ -1,3 +1,41 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:32c5e7b46fac58ba4ce38b204cb91dbf6552e0e1c3dfb9f513e59c374e40d359
-size 1540
+from helpers.extension import Extension
+from helpers.secrets import get_secrets_manager
+
+
+class MaskReasoningStreamChunk(Extension):
+ async def execute(self, **kwargs):
+ if not self.agent:
+ return
+
+ # Get stream data and agent from kwargs
+ stream_data = kwargs.get("stream_data")
+ agent = kwargs.get("agent")
+ if not agent or not stream_data:
+ return
+
+ try:
+ secrets_mgr = get_secrets_manager(self.agent.context)
+
+ # Initialize filter if not exists
+ filter_key = "_reason_stream_filter"
+ filter_instance = agent.get_data(filter_key)
+ if not filter_instance:
+ filter_instance = secrets_mgr.create_streaming_filter()
+ agent.set_data(filter_key, filter_instance)
+
+ # Process the chunk through the streaming filter
+ processed_chunk = filter_instance.process_chunk(stream_data["chunk"])
+
+ # Update the stream data with processed chunk
+ stream_data["chunk"] = processed_chunk
+
+ # Also mask the full text for consistency
+ stream_data["full"] = secrets_mgr.mask_values(stream_data["full"])
+
+ # Print the processed chunk (this is where printing should happen)
+ if processed_chunk:
+ from helpers.print_style import PrintStyle
+ PrintStyle().stream(processed_chunk)
+ except Exception as e:
+ # If masking fails, proceed without masking
+ pass
diff --git a/extensions/python/reasoning_stream_end/AGENTS.md b/extensions/python/reasoning_stream_end/AGENTS.md
index 53ff5bbc1171dd68411eaf6cbea2b49d966f71f5..d702b22ad8153de579860079bedabf76fbe1a473 100644
--- a/extensions/python/reasoning_stream_end/AGENTS.md
+++ b/extensions/python/reasoning_stream_end/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:88d95271df5554a22510896b9cc7ec04631b8e988468b82f9d5e20aa55e369c4
-size 568
+# Reasoning Stream End Extensions DOX
+
+## Purpose
+
+- Own finalization of reasoning stream content.
+
+## Ownership
+
+- Ordered Python files own final reasoning masking and end-of-stream cleanup.
+
+## Local Contracts
+
+- Preserve final masking even if earlier chunk masking missed content.
+- Keep end-state consistent with reasoning stream log entries.
+
+## Work Guidance
+
+- Coordinate final masking changes with chunk masking and UI rendering.
+
+## Verification
+
+- Smoke-test reasoning stream completion with sensitive-content cases.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/reasoning_stream_end/_10_mask_end.py b/extensions/python/reasoning_stream_end/_10_mask_end.py
index 3a6025c98fe196a986ff64168ef419a17f3faa56..89787c4938d8b32cd4b32480a0f82dba4be0ce68 100644
--- a/extensions/python/reasoning_stream_end/_10_mask_end.py
+++ b/extensions/python/reasoning_stream_end/_10_mask_end.py
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:55eae68432367201f292a4bdd13527519d763f45b2247a20cb09901619d3c872
-size 899
+from helpers.extension import Extension
+
+
+class MaskReasoningStreamEnd(Extension):
+ async def execute(self, **kwargs):
+ # Get agent and finalize the streaming filter
+ agent = kwargs.get("agent")
+ if not agent:
+ return
+
+ try:
+ # Finalize the reasoning stream filter if it exists
+ filter_key = "_reason_stream_filter"
+ filter_instance = agent.get_data(filter_key)
+ if filter_instance:
+ tail = filter_instance.finalize()
+
+ # Print any remaining masked content
+ if tail:
+ from helpers.print_style import PrintStyle
+ PrintStyle().stream(tail)
+
+ # Clean up the filter
+ agent.set_data(filter_key, None)
+ except Exception as e:
+ # If masking fails, proceed without masking
+ pass
diff --git a/extensions/python/response_stream/AGENTS.md b/extensions/python/response_stream/AGENTS.md
index 4c940699cc0fcd614c30127f1a6bb1beeb21b0c9..5da570f6bfe95bae1771bdd4a0fb6df642da46ea 100644
--- a/extensions/python/response_stream/AGENTS.md
+++ b/extensions/python/response_stream/AGENTS.md
@@ -1,3 +1,29 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:ef5a18977aa5381108e71b0c6efff2c7e7429f9abe709ccc4a85264cdd30485d
-size 781
+# Response Stream Extensions DOX
+
+## Purpose
+
+- Own handling of full assistant response stream updates.
+
+## Ownership
+
+- Ordered Python files own response logging, include-alias replacement, and live response updates.
+
+## Local Contracts
+
+- Keep streaming output synchronized with UI log items.
+- Treat parsed stream snapshots as partial data; nested tool fields may be `None`
+ until their values arrive.
+- Preserve include-alias replacement semantics where prompts/tools rely on them.
+- Do not expose unmasked secrets in live responses.
+
+## Work Guidance
+
+- Coordinate stream changes with chunk/end hooks and message rendering.
+
+## Verification
+
+- Smoke-test streamed responses, live updates, and include alias replacement after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/response_stream/_10_log_from_stream.py b/extensions/python/response_stream/_10_log_from_stream.py
index 40507a95bc95014848c71208690f7832a17d786b..91621dbe9bbf3b38ce44c9f85422cf6a62906e68 100644
--- a/extensions/python/response_stream/_10_log_from_stream.py
+++ b/extensions/python/response_stream/_10_log_from_stream.py
@@ -1,3 +1,74 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:373f6093952c70a902b9d4c82942c06d5d59da6fb8f1c603a15932c048696565
-size 2988
+from helpers import persist_chat, tokens
+from helpers.extension import Extension
+from agent import LoopData
+import asyncio
+from helpers.log import LogItem
+from helpers import log
+import math
+from extensions.python.before_main_llm_call._10_log_for_stream import build_heading, build_default_heading
+
+
+class LogFromStream(Extension):
+
+ async def execute(
+ self,
+ loop_data: LoopData = LoopData(),
+ text: str = "",
+ parsed: dict = {},
+ **kwargs,
+ ):
+ if not self.agent:
+ return
+
+ heading = build_default_heading(self.agent)
+ if "headline" in parsed:
+ heading = build_heading(self.agent, parsed['headline'])
+ elif "tool_name" in parsed:
+ heading = build_heading(self.agent, f"Using {parsed['tool_name']}") # if the llm skipped headline
+ elif "thoughts" in parsed:
+ # thought length indicator
+ length = "|" * math.ceil(math.sqrt(len(text))/2)
+ heading = build_heading(self.agent, f"Thinking... {length}")
+ else:
+ heading = build_heading(self.agent, "Receiving...")
+
+ # create log message and store it in loop data temporary params
+ if "log_item_generating" not in loop_data.params_temporary:
+ loop_data.params_temporary["log_item_generating"] = (
+ self.agent.context.log.log(
+ type="agent",
+ heading=heading,
+ )
+ )
+
+ # update log message
+ log_item = loop_data.params_temporary["log_item_generating"]
+
+ # keep reasoning from previous logs in kvps
+ kvps = {}
+ if log_item.kvps is not None and "reasoning" in log_item.kvps:
+ kvps["reasoning"] = log_item.kvps["reasoning"]
+
+ # step description for UI - using tool XY, writing Python code, etc.
+ if parsed is not None and "tool_name" in parsed and parsed["tool_name"]:
+ kvps["step"] = f"Using {parsed['tool_name']}..." # using tool XY
+ if parsed["tool_name"]=="code_execution_tool":
+ tool_args = parsed.get("tool_args")
+ if isinstance(tool_args, dict) and "runtime" in tool_args:
+ length = ""
+ code = tool_args.get("code")
+ if isinstance(code, str):
+ length = f"({len(code)})"
+ kvps["step"] = f"Writing code... {length}"
+ if tool_args["runtime"] == "python":
+ kvps["step"] = f"Writing Python code... {length}"
+ elif tool_args["runtime"] == "nodejs":
+ kvps["step"] = f"Writing Node.js code... {length}"
+ elif tool_args["runtime"] == "terminal":
+ kvps["step"] = f"Writing terminal command... {length}"
+ kvps.update(parsed)
+
+
+
+ # update the log item
+ log_item.update(heading=heading, content=text, kvps=kvps)
diff --git a/extensions/python/response_stream/_15_replace_include_alias.py b/extensions/python/response_stream/_15_replace_include_alias.py
index abf3fc3f75c25d2c1c139bd2f2dd43f3cf4e15b5..0994b75f6abf1d3bd4bb9b2ab583d60af93c0db5 100644
--- a/extensions/python/response_stream/_15_replace_include_alias.py
+++ b/extensions/python/response_stream/_15_replace_include_alias.py
@@ -1,3 +1,31 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:33182c00a343a86bcd80aaa0b561098e36efcfc1cf93ab7b38faab9a1c409b81
-size 1116
+from typing import Any
+from helpers.extension import Extension
+from helpers.strings import replace_file_includes
+
+
+class ReplaceIncludeAlias(Extension):
+ async def execute(
+ self,
+ loop_data=None,
+ text: str = "",
+ parsed: dict[str, Any] | None = None,
+ **kwargs
+ ):
+ if not parsed or not isinstance(parsed, dict):
+ return
+
+ def replace_placeholders(value: Any) -> Any:
+ if isinstance(value, str):
+ new_val = value
+ new_val = replace_file_includes(new_val, r"§§include\(([^)]+)\)")
+ return new_val
+ if isinstance(value, dict):
+ return {k: replace_placeholders(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [replace_placeholders(v) for v in value]
+ if isinstance(value, tuple):
+ return tuple(replace_placeholders(v) for v in value)
+ return value
+
+ if "tool_args" in parsed and "tool_name" in parsed:
+ parsed["tool_args"] = replace_placeholders(parsed["tool_args"])
diff --git a/extensions/python/response_stream/_20_live_response.py b/extensions/python/response_stream/_20_live_response.py
index 9d0e6c56d1eed705ded732abec440b3751c825b3..f206bd98f7dd70011a6b55ac9f8ff3e9cd7dfdc6 100644
--- a/extensions/python/response_stream/_20_live_response.py
+++ b/extensions/python/response_stream/_20_live_response.py
@@ -1,3 +1,51 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6fd60016910f1ff691d9e70047817edb560aa0b3b5f2b1180045bb5867f4489d
-size 1829
+from helpers import persist_chat, tokens
+from helpers import extract_tools
+from helpers.extension import Extension
+from agent import LoopData
+import asyncio
+from helpers.log import LogItem
+from helpers import log
+
+
+class LiveResponse(Extension):
+
+ async def execute(
+ self,
+ loop_data: LoopData = LoopData(),
+ text: str = "",
+ parsed: dict = {},
+ **kwargs,
+ ):
+ if not self.agent:
+ return
+
+ try:
+ tool_name, tool_args = extract_tools.normalize_tool_request(parsed)
+ message = tool_args.get("text")
+ if not isinstance(message, str) or not message.strip():
+ message = tool_args.get("message")
+ if (
+ tool_name != "response"
+ or not isinstance(message, str)
+ or not message.strip()
+ ):
+ return # not a response
+
+ # create log message and store it in loop data temporary params
+ if "log_item_response" not in loop_data.params_temporary:
+ # Share id with the agent log item so branching covers the response bubble
+ gen_item = loop_data.params_temporary.get("log_item_generating")
+ shared_id = gen_item.id if gen_item and gen_item.id else ""
+ loop_data.params_temporary["log_item_response"] = (
+ self.agent.context.log.log(
+ type="response",
+ heading=f"icon://chat {self.agent.agent_name}: Responding",
+ id=shared_id,
+ )
+ )
+
+ # update log message
+ log_item = loop_data.params_temporary["log_item_response"]
+ log_item.update(content=message)
+ except Exception as e:
+ pass
diff --git a/extensions/python/response_stream_chunk/AGENTS.md b/extensions/python/response_stream_chunk/AGENTS.md
index f3e316904208dc18979b49da1247f3def3500a1a..9ceff97a6241a45216545674731f9eaaf0088223 100644
--- a/extensions/python/response_stream_chunk/AGENTS.md
+++ b/extensions/python/response_stream_chunk/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:699283534dc6da10e7b6cec11fddddd4e2e50fd02bdf6e3887555d6ed1ed9281
-size 553
+# Response Stream Chunk Extensions DOX
+
+## Purpose
+
+- Own handling of incremental assistant response chunks.
+
+## Ownership
+
+- Ordered Python files own chunk-level response masking.
+
+## Local Contracts
+
+- Mask secrets before response chunks reach UI or persisted logs.
+- Keep chunk processing compatible with final response stream masking.
+
+## Work Guidance
+
+- Keep per-chunk work lightweight for streaming responsiveness.
+
+## Verification
+
+- Smoke-test streaming responses with sensitive patterns after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/response_stream_chunk/_10_mask_stream.py b/extensions/python/response_stream_chunk/_10_mask_stream.py
index 55f0e50e16fba669057e31862d0ac3938b54980d..4690df662b0d25aa1fe36a1ba82883476566b814 100644
--- a/extensions/python/response_stream_chunk/_10_mask_stream.py
+++ b/extensions/python/response_stream_chunk/_10_mask_stream.py
@@ -1,3 +1,43 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:bd11cfb50370d874523f82f586b92a03b192eb995247ff649839eeeb62057789
-size 1560
+from helpers.extension import Extension
+from agent import Agent, LoopData
+from helpers.secrets import get_secrets_manager
+
+
+class MaskResponseStreamChunk(Extension):
+
+ async def execute(self, **kwargs):
+ if not self.agent:
+ return
+
+ # Get stream data and agent from kwargs
+ stream_data = kwargs.get("stream_data")
+ agent = kwargs.get("agent")
+ if not agent or not stream_data:
+ return
+
+ try:
+ secrets_mgr = get_secrets_manager(self.agent.context)
+
+ # Initialize filter if not exists
+ filter_key = "_resp_stream_filter"
+ filter_instance = agent.get_data(filter_key)
+ if not filter_instance:
+ filter_instance = secrets_mgr.create_streaming_filter()
+ agent.set_data(filter_key, filter_instance)
+
+ # Process the chunk through the streaming filter
+ processed_chunk = filter_instance.process_chunk(stream_data["chunk"])
+
+ # Update the stream data with processed chunk
+ stream_data["chunk"] = processed_chunk
+
+ # Also mask the full text for consistency
+ stream_data["full"] = secrets_mgr.mask_values(stream_data["full"])
+
+ # Print the processed chunk (this is where printing should happen)
+ if processed_chunk:
+ from helpers.print_style import PrintStyle
+ PrintStyle().stream(processed_chunk)
+ except Exception as e:
+ # If masking fails, proceed without masking
+ pass
diff --git a/extensions/python/response_stream_end/AGENTS.md b/extensions/python/response_stream_end/AGENTS.md
index 9f1de8dc79c14d2ccda1aae47db951d3b2d9916f..b11052105a7bd572f6209cdd7b2dc074c37b22f2 100644
--- a/extensions/python/response_stream_end/AGENTS.md
+++ b/extensions/python/response_stream_end/AGENTS.md
@@ -1,3 +1,28 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:11e9948bcd2a5898feee819a0241b3c52303c577507c5b26133e4a7976019a07
-size 740
+# Response Stream End Extensions DOX
+
+## Purpose
+
+- Own finalization of assistant response stream content.
+
+## Ownership
+
+- Ordered Python files own final masking and stream-end log updates.
+
+## Local Contracts
+
+- Preserve final masking before response content is considered complete.
+- Keep log state consistent with streamed chunks and final response text.
+- Preserve explicit planning fields; when `thoughts` are absent, expose native
+ reasoning summaries as thoughts after both streams finish.
+
+## Work Guidance
+
+- Coordinate finalization changes with live response and message rendering behavior.
+
+## Verification
+
+- Smoke-test response completion and persisted message display after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/response_stream_end/_10_mask_end.py b/extensions/python/response_stream_end/_10_mask_end.py
index d311d14c49f0d48ac0269bbdf1830c8d2b488fe8..1d277331999fbdc1fedd6ff11bc196779dd6a730 100644
--- a/extensions/python/response_stream_end/_10_mask_end.py
+++ b/extensions/python/response_stream_end/_10_mask_end.py
@@ -1,3 +1,28 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5f7fa3c82c5d468fd680127251a50d3cc189153c55e1c10d038635619eab035d
-size 938
+from helpers.extension import Extension
+from helpers.secrets import SecretsManager
+
+
+class MaskResponseStreamEnd(Extension):
+ async def execute(self, **kwargs):
+ # Get agent and finalize the streaming filter
+ agent = kwargs.get("agent")
+ if not agent:
+ return
+
+ try:
+ # Finalize the response stream filter if it exists
+ filter_key = "_resp_stream_filter"
+ filter_instance = agent.get_data(filter_key)
+ if filter_instance:
+ tail = filter_instance.finalize()
+
+ # Print any remaining masked content
+ if tail:
+ from helpers.print_style import PrintStyle
+ PrintStyle().stream(tail)
+
+ # Clean up the filter
+ agent.set_data(filter_key, None)
+ except Exception as e:
+ # If masking fails, proceed without masking
+ pass
diff --git a/extensions/python/response_stream_end/_15_log_from_stream_end.py b/extensions/python/response_stream_end/_15_log_from_stream_end.py
index edc73333c5841b4558d24791653bae12cded7c48..a23826e9534b38c8cc63c45a8cb0b0d0c34754fe 100644
--- a/extensions/python/response_stream_end/_15_log_from_stream_end.py
+++ b/extensions/python/response_stream_end/_15_log_from_stream_end.py
@@ -1,3 +1,36 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:268417889e71053f838bce32ea1d6d4384b1a092fea330bc90a33c0a4d238716
-size 1095
+from helpers import persist_chat, tokens
+from helpers.extension import Extension
+from agent import LoopData
+import asyncio
+from helpers.log import LogItem
+from helpers import log
+import math
+from extensions.python.before_main_llm_call._10_log_for_stream import build_heading, build_default_heading
+
+
+class LogFromStream(Extension):
+
+ async def execute(
+ self,
+ loop_data: LoopData = LoopData(),
+ text: str = "",
+ parsed: dict = {},
+ **kwargs,
+ ):
+
+ # get log item from loop data temporary params
+ log_item = loop_data.params_temporary["log_item_generating"]
+ if log_item is None:
+ return
+
+ # remove step parameter when done
+ if log_item.kvps is not None and "step" in log_item.kvps:
+ del log_item.kvps["step"]
+
+ if log_item.kvps is not None and "thoughts" not in log_item.kvps:
+ reasoning = log_item.kvps.get("reasoning")
+ if reasoning:
+ log_item.kvps["thoughts"] = [reasoning]
+
+ # update the log item
+ log_item.update(kvps=log_item.kvps)
diff --git a/extensions/python/startup_migration/AGENTS.md b/extensions/python/startup_migration/AGENTS.md
index 7c1c22f30f6442e4f9521cd03fac862ef12037c4..d3ffc39a09938cf82523181773e1156e7e745e1f 100644
--- a/extensions/python/startup_migration/AGENTS.md
+++ b/extensions/python/startup_migration/AGENTS.md
@@ -1,3 +1,29 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:887b991895140d9b98d248cf6db5d4e2058597c1eb0a10eb2e99b73677821f36
-size 1104
+# Startup Migration Extensions DOX
+
+## Purpose
+
+- Own backend startup migrations.
+
+## Ownership
+
+- Ordered Python files in this folder own idempotent migration steps that run during startup.
+
+## Local Contracts
+
+- Migrations must be safe to run repeatedly.
+- Preserve user data and create backups or reversible paths when changing durable state.
+- Keep long-running work bounded and observable.
+- `_10_self_update_manager.py` may replace `/exe/self_update_manager.py` from the repository copy when the installed runtime updater is stale; it must validate required safety markers and keep a backup before replacement.
+- After synchronizing a stale self-update manager, `_10_self_update_manager.py` starts that manager's best-effort Codex CLI refresh in the background so the update that introduces the hook does not need a second restart.
+
+## Work Guidance
+
+- Add migrations only for durable state changes that cannot be handled lazily elsewhere.
+
+## Verification
+
+- Smoke-test startup on a clean checkout and on representative existing user state when practical.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/startup_migration/_10_self_update_manager.py b/extensions/python/startup_migration/_10_self_update_manager.py
index ee570ea27bd26825c8f8afbbd3d95c6cef05a4a7..fff24cc1cb6f44eba811cb4c96363138c6c080bd 100644
--- a/extensions/python/startup_migration/_10_self_update_manager.py
+++ b/extensions/python/startup_migration/_10_self_update_manager.py
@@ -1,3 +1,146 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5c6d2e6497d4fc31ac0479b88ceff22930c6b592428141c772bebaf2dadff632
-size 4755
+from __future__ import annotations
+
+import os
+import shutil
+import stat
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+from helpers.extension import Extension
+from helpers.print_style import PrintStyle
+
+
+SELF_UPDATE_MANAGER_PATH = Path(
+ os.environ.get("A0_SELF_UPDATE_MANAGER_PATH", "/exe/self_update_manager.py")
+)
+SELF_UPDATE_MANAGER_SOURCE_PATH = Path(
+ os.environ.get(
+ "A0_SELF_UPDATE_MANAGER_SOURCE_PATH",
+ "/a0/docker/run/fs/exe/self_update_manager.py",
+ )
+)
+BACKUP_SUFFIX = ".startup-migration-backup"
+REQUIRED_RUNTIME_MARKERS = (
+ "def should_include_usr_backup_entry(",
+ "Skipping non-regular usr backup entry",
+ "def clean_transient_desktop_agent_state(",
+ "clean_transient_desktop_agent_state(REPO_DIR, logger)",
+ "def refresh_codex_cli(",
+ "refresh_codex_cli(logger)",
+)
+
+
+class SelfUpdateManagerRuntimeSync(Extension):
+ def execute(self, **kwargs):
+ result = ensure_self_update_manager_runtime_current()
+ if result.get("updated"):
+ PrintStyle.info("Self-update manager runtime synchronized:", result["target"])
+ warning = start_codex_cli_refresh(result["target"])
+ if warning:
+ PrintStyle.warning("Codex CLI refresh could not be started:", warning)
+ elif result.get("warning"):
+ PrintStyle.warning("Self-update manager runtime sync skipped:", result["warning"])
+
+
+def ensure_self_update_manager_runtime_current(
+ *,
+ target_path: Path | str | None = None,
+ source_path: Path | str | None = None,
+) -> dict[str, Any]:
+ target = Path(target_path) if target_path is not None else SELF_UPDATE_MANAGER_PATH
+ source = Path(source_path) if source_path is not None else SELF_UPDATE_MANAGER_SOURCE_PATH
+
+ target_text, target_warning = _read_regular_text(target, role="runtime self-update manager")
+ if target_text is None:
+ return {"ok": True, "updated": False, "reason": target_warning}
+
+ source_text, source_warning = _read_regular_text(source, role="source self-update manager")
+ if source_text is None:
+ return {"ok": False, "updated": False, "warning": source_warning}
+
+ missing_source_markers = _missing_required_markers(source_text)
+ if missing_source_markers:
+ return {
+ "ok": False,
+ "updated": False,
+ "warning": (
+ "source self-update manager is missing required safety markers: "
+ + ", ".join(missing_source_markers)
+ ),
+ }
+
+ if not _missing_required_markers(target_text):
+ return {"ok": True, "updated": False, "reason": "already-current"}
+
+ try:
+ backup = _replace_runtime_manager(target, source_text)
+ except OSError as exc:
+ return {
+ "ok": False,
+ "updated": False,
+ "warning": f"could not update {target}: {exc}",
+ }
+
+ return {
+ "ok": True,
+ "updated": True,
+ "target": str(target),
+ "backup": str(backup),
+ }
+
+
+def start_codex_cli_refresh(manager_path: Path | str) -> str:
+ try:
+ subprocess.Popen(
+ [sys.executable, str(manager_path), "refresh-codex"],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ except OSError as exc:
+ return str(exc)
+ return ""
+
+
+def _missing_required_markers(text: str) -> list[str]:
+ return [marker for marker in REQUIRED_RUNTIME_MARKERS if marker not in text]
+
+
+def _read_regular_text(path: Path, *, role: str) -> tuple[str | None, str]:
+ try:
+ path_stat = path.lstat()
+ except FileNotFoundError:
+ return None, f"{role} not found: {path}"
+ except OSError as exc:
+ return None, f"{role} could not be inspected: {path}: {exc}"
+
+ if not stat.S_ISREG(path_stat.st_mode):
+ return None, f"{role} is not a regular file: {path}"
+
+ try:
+ return path.read_text(encoding="utf-8"), ""
+ except OSError as exc:
+ return None, f"{role} could not be read: {path}: {exc}"
+
+
+def _replace_runtime_manager(target: Path, source_text: str) -> Path:
+ target_stat = target.stat()
+ backup = _ensure_backup(target)
+ temp_path = target.with_name(f".{target.name}.{os.getpid()}.tmp")
+ try:
+ temp_path.write_text(source_text, encoding="utf-8")
+ os.chmod(temp_path, stat.S_IMODE(target_stat.st_mode))
+ os.replace(temp_path, target)
+ finally:
+ temp_path.unlink(missing_ok=True)
+ return backup
+
+
+def _ensure_backup(target: Path) -> Path:
+ backup = target.with_name(f"{target.name}{BACKUP_SUFFIX}")
+ if not backup.exists():
+ shutil.copy2(target, backup)
+ return backup
diff --git a/extensions/python/system_prompt/AGENTS.md b/extensions/python/system_prompt/AGENTS.md
index 667942ff265f3ec6f1be800c8d7300fd9648163a..2edb213eb2fed5719070d56be6cfa76e452416c1 100644
--- a/extensions/python/system_prompt/AGENTS.md
+++ b/extensions/python/system_prompt/AGENTS.md
@@ -1,3 +1,32 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:96726b0ba326dd8a294277b9e99b7c921e024369038049f9a39cc426bd610a04
-size 1115
+# System Prompt Extensions DOX
+
+## Purpose
+
+- Own construction of core system prompt sections.
+
+## Ownership
+
+- Ordered Python files own main, tools, MCP, secrets, skills, and project prompt sections.
+- Active project instruction bodies and active-project AGENTS.md path-chain guidance are moved into prompt protocol; the system prompt keeps project metadata and stable project rules.
+
+## Local Contracts
+
+- Preserve ordering where sections depend on earlier context.
+- Keep secret-related prompt sections masked and scoped.
+- Prompt additions must be bounded and compatible with tool-call contracts.
+- Discover local tool prompts through `helpers.subagents.get_paths` and apply
+ `helpers.tool_policy` before including their text.
+- Omit the discoverable-skills catalog when profile policy blocks
+ `skills_tool`; loaded skill history remains independent.
+
+## Work Guidance
+
+- Coordinate broad system prompt changes with profiles, skills, tools, plugins, and prompt tests.
+
+## Verification
+
+- Inspect rendered system prompts or run prompt-construction tests after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/system_prompt/_10_main_prompt.py b/extensions/python/system_prompt/_10_main_prompt.py
index eea4d1094994169707bf0ba01901077d0a5f3320..88193540cc55bb69e515a0460d621ffd66c42c23 100644
--- a/extensions/python/system_prompt/_10_main_prompt.py
+++ b/extensions/python/system_prompt/_10_main_prompt.py
@@ -1,3 +1,23 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:dcbca9cae15e9ab7489611a08026ab8c20a861a17d0b867d359c3025fea42998
-size 533
+from typing import Any
+
+from helpers.extension import Extension, extensible
+from agent import Agent, LoopData
+
+
+class MainPrompt(Extension):
+
+ async def execute(
+ self,
+ system_prompt: list[str] = [],
+ loop_data: LoopData = LoopData(),
+ **kwargs: Any,
+ ):
+ if not self.agent:
+ return
+ prompt = await build_prompt(self.agent)
+ system_prompt.append(prompt)
+
+
+@extensible
+async def build_prompt(agent: Agent) -> str:
+ return agent.read_prompt("agent.system.main.md")
diff --git a/extensions/python/system_prompt/_11_tools_prompt.py b/extensions/python/system_prompt/_11_tools_prompt.py
index 570cbff98d3373281299e7f4a1b35dfc06942a2f..bb6de55e6950a96c4513c5aab8e6d933c3c2dd08 100644
--- a/extensions/python/system_prompt/_11_tools_prompt.py
+++ b/extensions/python/system_prompt/_11_tools_prompt.py
@@ -1,3 +1,63 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:31447082ddac66afa7acfbd5789d801e7af13228ebebfad7411b1d6cdfb469f3
-size 1961
+import os
+from typing import Any
+
+from helpers.extension import Extension, extensible
+from helpers import files, subagents, tool_policy
+from helpers.print_style import PrintStyle
+from agent import Agent, LoopData
+
+
+TOOL_KWARGS_KEY = "_tool_prompt_kwargs"
+
+
+class ToolsPrompt(Extension):
+
+ async def execute(
+ self,
+ system_prompt: list[str] = [],
+ loop_data: LoopData = LoopData(),
+ **kwargs: Any,
+ ):
+ if not self.agent:
+ return
+ prompt = await build_prompt(self.agent)
+ system_prompt.append(prompt)
+
+
+@extensible
+async def build_prompt(agent: Agent) -> str:
+ # collect tool files from all prompt directories
+ prompt_dirs = subagents.get_paths(agent, "prompts")
+ tool_files = files.get_unique_filenames_in_dirs(
+ prompt_dirs, "agent.system.tool.*.md"
+ )
+
+ # per-file kwargs registered by plugin config extensions (e.g. _09_text_editor_config)
+ all_tool_kwargs: dict[str, dict[str, Any]] = agent.get_data(TOOL_KWARGS_KEY) or {}
+
+ tools: list[str] = []
+ for tool_file in tool_files:
+ try:
+ basename = os.path.basename(tool_file)
+ extra = all_tool_kwargs.get(basename, {})
+ tool = agent.read_prompt(basename, **extra)
+ tool = tool_policy.filter_tool_prompt(agent, basename, tool)
+ if tool:
+ tools.append(tool)
+ except Exception as e:
+ PrintStyle().error(f"Error loading tool '{tool_file}': {e}")
+
+ tools_str = "\n\n".join(tools)
+ prompt = agent.read_prompt("agent.system.tools.md", tools=tools_str)
+
+ # vision support
+ from plugins._model_config.helpers.model_config import (
+ get_chat_model_config,
+ get_vision_model_config,
+ )
+
+ chat_cfg = get_chat_model_config(agent)
+ if get_vision_model_config(agent) or chat_cfg.get("vision", False):
+ prompt += "\n\n" + agent.read_prompt("agent.system.tools_vision.md")
+
+ return prompt
diff --git a/extensions/python/system_prompt/_12_mcp_prompt.py b/extensions/python/system_prompt/_12_mcp_prompt.py
index 86aafedce7972cbb48bc15002b9699b6276efeba..c18c11fceaee05b29848a1d3c72fc8fb1642f0ad 100644
--- a/extensions/python/system_prompt/_12_mcp_prompt.py
+++ b/extensions/python/system_prompt/_12_mcp_prompt.py
@@ -1,3 +1,33 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9e0000546d286090f1db3d0b0930a0db976164762b353cce2cd64e2c458b5fe4
-size 871
+from typing import Any
+
+from helpers.extension import Extension, extensible
+from helpers.mcp_handler import MCPConfig
+from agent import Agent, LoopData
+
+
+class MCPToolsPrompt(Extension):
+
+ async def execute(
+ self,
+ system_prompt: list[str] = [],
+ loop_data: LoopData = LoopData(),
+ **kwargs: Any,
+ ):
+ if not self.agent:
+ return
+ prompt = await build_prompt(self.agent)
+ if prompt:
+ system_prompt.append(prompt)
+
+
+@extensible
+async def build_prompt(agent: Agent) -> str:
+ mcp_config = MCPConfig.get_for_agent(agent)
+ if not mcp_config.servers:
+ return ""
+
+ pre_progress = agent.context.log.progress
+ agent.context.log.set_progress("Collecting MCP tools")
+ tools = mcp_config.get_tools_prompt(agent=agent)
+ agent.context.log.set_progress(pre_progress)
+ return tools
diff --git a/extensions/python/system_prompt/_13_secrets_prompt.py b/extensions/python/system_prompt/_13_secrets_prompt.py
index 5f50361e71cb2f30e007b05b5b99aeab1381fde6..fdd4cfdd8f95781e5f94497fef6fabd0930c6801 100644
--- a/extensions/python/system_prompt/_13_secrets_prompt.py
+++ b/extensions/python/system_prompt/_13_secrets_prompt.py
@@ -1,3 +1,35 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:e74ac4ba67be562a78a3eb9b52c7ed667854e0d5af6daf4e75336d439009f62f
-size 967
+from typing import Any
+
+from helpers.extension import Extension, extensible
+from agent import Agent, LoopData
+
+
+class SecretsPrompt(Extension):
+
+ async def execute(
+ self,
+ system_prompt: list[str] = [],
+ loop_data: LoopData = LoopData(),
+ **kwargs: Any,
+ ):
+ if not self.agent:
+ return
+ prompt = await build_prompt(self.agent)
+ if prompt:
+ system_prompt.append(prompt)
+
+
+@extensible
+async def build_prompt(agent: Agent) -> str:
+ try:
+ from helpers.secrets import get_secrets_manager
+ from helpers.settings import get_settings_for_prompt
+
+ secrets_manager = get_secrets_manager(agent.context)
+ secrets = secrets_manager.get_secrets_for_prompt()
+ variables = get_settings_for_prompt()["variables"]
+ return agent.read_prompt(
+ "agent.system.secrets.md", secrets=secrets, vars=variables
+ )
+ except Exception:
+ return ""
diff --git a/extensions/python/system_prompt/_13_skills_prompt.py b/extensions/python/system_prompt/_13_skills_prompt.py
index d5de3c4bda412f0bb1ca922db3d9bcb5e75a052c..ba6e6e94d57fdd97e763aef699253bc12678f3ae 100644
--- a/extensions/python/system_prompt/_13_skills_prompt.py
+++ b/extensions/python/system_prompt/_13_skills_prompt.py
@@ -1,3 +1,40 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:2daedce1206d8117a747cf9ddf1fb13f9bfa9862f932922bdf8dcde2e79045b4
-size 1146
+from typing import Any
+
+from helpers.extension import Extension, extensible
+from helpers import skills as skills_helper, tool_policy
+from agent import Agent, LoopData
+
+
+class SkillsPrompt(Extension):
+
+ async def execute(
+ self,
+ system_prompt: list[str] = [],
+ loop_data: LoopData = LoopData(),
+ **kwargs: Any,
+ ):
+ if not self.agent:
+ return
+ prompt = await build_prompt(self.agent)
+ if prompt:
+ system_prompt.append(prompt)
+
+
+@extensible
+async def build_prompt(agent: Agent) -> str:
+ if not tool_policy.resolve_tool(agent, "skills_tool").allowed:
+ return ""
+
+ available = skills_helper.list_skills(agent=agent)
+ result: list[str] = []
+ for skill in available:
+ name = skill.name.strip().replace("\n", " ")[:100]
+ descr = skill.description.replace("\n", " ").strip()
+ if len(descr) > 100:
+ descr = descr[:100].rstrip() + "..."
+ result.append(f"- {name}: {descr}" if descr else f"- {name}")
+
+ if not result:
+ return ""
+
+ return agent.read_prompt("agent.system.skills.md", skills="\n".join(result))
diff --git a/extensions/python/system_prompt/_14_project_prompt.py b/extensions/python/system_prompt/_14_project_prompt.py
index 26859c9bba54ac0b25d93eec39bf69a5c908bfe1..4afa620cd566a35f7114bd242d7d2c76b5b6d3d8 100644
--- a/extensions/python/system_prompt/_14_project_prompt.py
+++ b/extensions/python/system_prompt/_14_project_prompt.py
@@ -1,3 +1,48 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:f66abe117baff9351c6247d2b96963fabfaaae7ed53bee46ef8006d108ee3a4c
-size 1794
+from typing import Any
+
+from helpers.extension import Extension, extensible
+from helpers import projects
+from agent import Agent, LoopData
+
+
+class ProjectPrompt(Extension):
+
+ async def execute(
+ self,
+ system_prompt: list[str] = [],
+ loop_data: LoopData = LoopData(),
+ **kwargs: Any,
+ ):
+ if not self.agent:
+ return
+ prompt = await build_prompt(self.agent, loop_data=loop_data)
+ if prompt:
+ system_prompt.append(prompt)
+
+
+@extensible
+async def build_prompt(agent: Agent, loop_data: LoopData | None = None) -> str:
+ result = agent.read_prompt("agent.system.projects.main.md")
+ project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
+ if loop_data:
+ loop_data.protocol_persistent.pop("agents_md_instructions", None)
+ loop_data.protocol_persistent.pop("project_instructions", None)
+ if project_name:
+ project_vars = projects.build_system_prompt_vars(project_name)
+ if loop_data and project_vars.get("include_agents_md", True):
+ agents_md_protocol = projects.build_agents_md_protocol(project_name)
+ if agents_md_protocol:
+ loop_data.protocol_persistent["agents_md_instructions"] = (
+ agents_md_protocol
+ )
+ if loop_data and project_vars.get("project_instructions"):
+ loop_data.protocol_persistent["project_instructions"] = agent.read_prompt(
+ "agent.protocol.projects.instructions.md",
+ **project_vars,
+ )
+ result += "\n\n" + agent.read_prompt(
+ "agent.system.projects.active.md", **project_vars
+ )
+ else:
+ result += "\n\n" + agent.read_prompt("agent.system.projects.inactive.md")
+ return result
diff --git a/extensions/python/tool_execute_after/AGENTS.md b/extensions/python/tool_execute_after/AGENTS.md
index 425e27ce45eaa00fb1f6c3e0cb46425bb30db9e0..54c4557c53a91c7020de6ff30e876991789b707c 100644
--- a/extensions/python/tool_execute_after/AGENTS.md
+++ b/extensions/python/tool_execute_after/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:15fd452d7381a7f7d25d39b294e066ec97df3164debcbe311633a3257979e760
-size 647
+# Tool Execute After Extensions DOX
+
+## Purpose
+
+- Own backend processing immediately after tool execution.
+
+## Ownership
+
+- Ordered Python files own post-tool secret masking and future tool-result postprocessing.
+
+## Local Contracts
+
+- Mask secrets before tool results reach history, UI, or model-visible context.
+- Do not alter tool `break_loop` or response semantics unless the hook contract owns that behavior.
+
+## Work Guidance
+
+- Coordinate with tool implementations and history hooks when changing tool result data.
+
+## Verification
+
+- Smoke-test tool execution with sensitive output after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/tool_execute_after/_10_mask_secrets.py b/extensions/python/tool_execute_after/_10_mask_secrets.py
index 19eaa439ebffc2a59942d04583b24793a1ed5d8d..362706fd3e4adc742195dd690c51ef85912784ba 100644
--- a/extensions/python/tool_execute_after/_10_mask_secrets.py
+++ b/extensions/python/tool_execute_after/_10_mask_secrets.py
@@ -1,3 +1,15 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c37ca95db6bc9c62af50a69a430f111a48d507b49b66a53a80a9eab513a12115
-size 454
+from helpers.extension import Extension
+from helpers.secrets import get_secrets_manager
+from helpers.tool import Response
+
+
+class MaskToolSecrets(Extension):
+
+ async def execute(self, response: Response | None = None, **kwargs):
+ if not self.agent:
+ return
+
+ if not response:
+ return
+ secrets_mgr = get_secrets_manager(self.agent.context)
+ response.message = secrets_mgr.mask_values(response.message)
diff --git a/extensions/python/tool_execute_before/AGENTS.md b/extensions/python/tool_execute_before/AGENTS.md
index 25b835a27f88a1e9244b1fc7968606476ab2f782..e9f11e78a5dbcad4db2b667ef0a5c77413000dea 100644
--- a/extensions/python/tool_execute_before/AGENTS.md
+++ b/extensions/python/tool_execute_before/AGENTS.md
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c17ba997c1b52d71fb72f518e7f94d020f714661d147ceb38cdc8469751408dc
-size 745
+# Tool Execute Before Extensions DOX
+
+## Purpose
+
+- Own backend processing immediately before tool execution.
+
+## Ownership
+
+- Ordered Python files own prior tool-output replacement, parallel recursion guards, and secret unmasking before execution.
+
+## Local Contracts
+
+- Unmask only values required by the target tool.
+- Preserve safety checks and do not expose secrets to logs or unrelated tools.
+- Keep ordering stable where replacement must occur before unmasking or execution.
+
+## Work Guidance
+
+- Coordinate with secret handling, tool argument preparation, and plugin tool gates.
+
+## Verification
+
+- Smoke-test tool execution with masked secret arguments and prior-output references after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/tool_execute_before/_10_replace_last_tool_output.py b/extensions/python/tool_execute_before/_10_replace_last_tool_output.py
index 8b8d53c0c4730693afd1a2d1a9ae9c0737084aa4..c1cdb388353bdd014953c57a2125dc4cfa845330 100644
--- a/extensions/python/tool_execute_before/_10_replace_last_tool_output.py
+++ b/extensions/python/tool_execute_before/_10_replace_last_tool_output.py
@@ -1,3 +1,36 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c309d8f1eb104a01dece04e4f63026881abb49bf220387ae46f4598ce2e847ea
-size 1272
+from typing import Any
+from helpers.extension import Extension
+
+
+class ReplaceLastToolOutput(Extension):
+ async def execute(self, tool_args: dict[str, Any] | None = None, tool_name: str = "", **kwargs):
+ if not self.agent:
+ return
+
+ if not tool_args:
+ return
+
+ last_call = self.agent.get_data("last_tool_call") or {}
+ last_output = last_call.get("last_tool_output", "")
+ if not last_output:
+ return
+
+ tokens = ("{last_tool_output}", "{{last_tool_output}}")
+
+ def replace_placeholders(value: Any) -> Any:
+ if isinstance(value, str):
+ new_val = value
+ for token in tokens:
+ new_val = new_val.replace(token, last_output)
+ return new_val
+ if isinstance(value, dict):
+ return {k: replace_placeholders(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [replace_placeholders(v) for v in value]
+ if isinstance(value, tuple):
+ return tuple(replace_placeholders(v) for v in value)
+ return value
+
+ updated_args = replace_placeholders(tool_args)
+ tool_args.clear()
+ tool_args.update(updated_args)
diff --git a/extensions/python/tool_execute_before/_10_unmask_secrets.py b/extensions/python/tool_execute_before/_10_unmask_secrets.py
index 9d991cf6ae8b5a8db1a05e20fce5c24d159a3dbe..3ec649d552d74984b21ecd21646dfaa406ee85dd 100644
--- a/extensions/python/tool_execute_before/_10_unmask_secrets.py
+++ b/extensions/python/tool_execute_before/_10_unmask_secrets.py
@@ -1,3 +1,21 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9b8e435be74e6a6d41718fa1de7fe0ff43f5a929da7f0d98e46521f6a8965941
-size 607
+from helpers.extension import Extension
+from helpers.secrets import get_secrets_manager
+
+
+class UnmaskToolSecrets(Extension):
+
+ async def execute(self, **kwargs):
+ if not self.agent:
+ return
+
+ # Get tool args from kwargs
+ tool_args = kwargs.get("tool_args")
+ if not tool_args:
+ return
+
+ secrets_mgr = get_secrets_manager(self.agent.context)
+
+ # Unmask placeholders in args for actual tool execution
+ for k, v in tool_args.items():
+ if isinstance(v, str):
+ tool_args[k] = secrets_mgr.replace_placeholders(v)
diff --git a/extensions/python/tool_execute_before/_20_block_parallel_recursion.py b/extensions/python/tool_execute_before/_20_block_parallel_recursion.py
index b82c1c13047b183f7255ea2eabd9d9b7bdc5c730..a5547ed8ffb496b54ae73e5f803e9d4cfadd5ad8 100644
--- a/extensions/python/tool_execute_before/_20_block_parallel_recursion.py
+++ b/extensions/python/tool_execute_before/_20_block_parallel_recursion.py
@@ -1,3 +1,15 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5eff30bc40ccee527217a79ec08403b138440b64c62f48294725eb2875a46959
-size 571
+from helpers.extension import Extension
+from helpers.errors import RepairableException
+from helpers import parallel_tools
+
+
+class BlockParallelRecursion(Extension):
+ async def execute(self, tool_name: str = "", **kwargs) -> None:
+ if tool_name != "parallel":
+ return
+ if not parallel_tools.is_parallel_worker(self.agent):
+ return
+ raise RepairableException(
+ "The `parallel` tool cannot be used inside a parallel worker. "
+ "Finish the current worker task sequentially and return its result."
+ )
diff --git a/extensions/python/user_message_ui/AGENTS.md b/extensions/python/user_message_ui/AGENTS.md
index 7b44fd1bf72e148919a32f845260b7f15d682e6b..2d9a3b70628f94d6caadba8199dfb9f8853ffa51 100644
--- a/extensions/python/user_message_ui/AGENTS.md
+++ b/extensions/python/user_message_ui/AGENTS.md
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:e7b9b7a8a1234830652c4cb93ae5b200edcfc99d346dea819761f5299d147a36
-size 690
+# User Message UI Extensions DOX
+
+## Purpose
+
+- Own backend behavior triggered around user-visible UI messages.
+
+## Ownership
+
+- Ordered Python files own update-check messaging and future user-message UI hooks.
+
+## Local Contracts
+
+- Keep proactive UI messages relevant, non-spammy, and safe for display.
+- Keep update-available notifications visible until the user dismisses them or opens the updater.
+- Do not expose local diagnostics or update data that should stay internal.
+
+## Work Guidance
+
+- Gate recurring messages so they do not repeat unnecessarily across chats or tabs.
+
+## Verification
+
+- Smoke-test UI message rendering after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/user_message_ui/_10_update_check.py b/extensions/python/user_message_ui/_10_update_check.py
index bbc428cec58d3059c8d1931b249f9f25b9dba84a..6d26c1d9299d54c994829fd8cd23eeac128ca8b5 100644
--- a/extensions/python/user_message_ui/_10_update_check.py
+++ b/extensions/python/user_message_ui/_10_update_check.py
@@ -1,3 +1,123 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:52cf96f36419ed2170beeb307a79eb84715557c54645d47861b6eab309af2fd6
-size 4605
+from helpers import notification
+from helpers.extension import Extension
+from agent import LoopData
+from helpers import files, settings, update_check
+from helpers.localization import Localization
+import datetime
+import json
+
+
+# check for newer versions of A0 available and send notification
+# check after user message is sent from UI, not API, MCP etc. (user is active and can see the notification)
+# do not check too often, use cooldown
+# do not notify too often
+
+last_check = datetime.datetime.fromtimestamp(0, tz=Localization.get().get_tzinfo())
+check_cooldown_seconds = 60
+last_notification_id = None
+last_notification_time = datetime.datetime.fromtimestamp(0, tz=Localization.get().get_tzinfo())
+notification_cooldown_seconds = 60 * 60 * 24
+notification_state_file = "usr/update-check-state.json"
+
+
+def _now() -> datetime.datetime:
+ return Localization.get().now()
+
+
+def _load_notification_state() -> dict:
+ try:
+ return json.loads(files.read_file(notification_state_file))
+ except Exception:
+ return {}
+
+
+def _parse_timestamp(value: str | None) -> datetime.datetime | None:
+ if not value:
+ return None
+ try:
+ parsed = datetime.datetime.fromisoformat(value)
+ except ValueError:
+ return None
+ if parsed.tzinfo:
+ return parsed.astimezone(Localization.get().get_tzinfo())
+ return Localization.get().localize_naive_datetime(parsed)
+
+
+def _remember_notification(notif: dict, now: datetime.datetime):
+ state = {
+ "last_notification_at": now.isoformat(),
+ "last_notification_id": notif.get("id") or "",
+ "last_notification_group": notif.get("group", "update_check"),
+ }
+ files.write_file(notification_state_file, json.dumps(state, indent=2))
+
+
+class UpdateCheck(Extension):
+
+ async def execute(self, loop_data: LoopData = LoopData(), text: str = "", **kwargs):
+ if not self.agent:
+ return
+
+ try:
+ global last_check, last_notification_id, last_notification_time
+
+ # first check if update check is enabled
+ current_settings = settings.get_settings()
+ if not current_settings["update_check_enabled"]:
+ return
+
+ # check if cooldown has passed
+ now = _now()
+ if (now - last_check).total_seconds() < check_cooldown_seconds:
+ return
+ last_check = now
+
+ # check for updates
+ version = await update_check.check_version()
+
+ # if the user should update, send notification
+ if notif := version.get("notification"):
+ now = _now()
+ stored_state = _load_notification_state()
+ stored_notification_time = _parse_timestamp(stored_state.get("last_notification_at"))
+ effective_notification_time = stored_notification_time or last_notification_time
+
+ if (now - effective_notification_time).total_seconds() > notification_cooldown_seconds:
+ last_notification_id = notif.get("id")
+ last_notification_time = now
+ try:
+ _remember_notification(notif, now)
+ except Exception:
+ pass
+ self.send_notification(notif)
+ except Exception as e:
+ pass # no need to log if the update server is inaccessible
+
+
+ def send_notification(self, notif):
+ if not self.agent:
+ return
+
+ message = notif.get(
+ "message",
+ "A newer version of Agent Zero is available. Please update to the latest version.",
+ )
+ message = message.replace(
+ 'Open updater.',
+ '
'
+ '
",
+ )
+ notifs = self.agent.context.get_notification_manager()
+ notifs.send_notification(
+ title=notif.get("title", "Newer version available"),
+ message=message,
+ type=notif.get("type", "info"),
+ detail=notif.get("detail", ""),
+ display_time=0,
+ group=notif.get("group", "update_check"),
+ priority=notif.get("priority", notification.NotificationPriority.NORMAL),
+ id=notif.get("id", "update_check_available"),
+ )
diff --git a/extensions/python/util_model_call_before/AGENTS.md b/extensions/python/util_model_call_before/AGENTS.md
index 1256ac4e74dd486fe8070b7dbf76873c38e5d0e7..dc0bd77f8afa7f22dbfc538dad3fffa50d08fd96 100644
--- a/extensions/python/util_model_call_before/AGENTS.md
+++ b/extensions/python/util_model_call_before/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3e9ab41869d9300aeac86bda3f8411797178ab54a5fb1b76a27b680605293420
-size 601
+# Utility Model Call Before Extensions DOX
+
+## Purpose
+
+- Own preprocessing before utility model calls.
+
+## Ownership
+
+- Ordered Python files own secret masking and future utility-call preparation.
+
+## Local Contracts
+
+- Mask secrets before utility prompts leave the framework.
+- Keep utility model inputs compatible with callers expecting structured outputs.
+
+## Work Guidance
+
+- Coordinate masking changes with main model call and error-format masking behavior.
+
+## Verification
+
+- Test utility model calls that include masked secret patterns after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/util_model_call_before/_10_mask_secrets.py b/extensions/python/util_model_call_before/_10_mask_secrets.py
index 422a8c4f30b6b110b6896e60e9284eed4ec82362..60b9545d5d9bbe6b1ace5b784b62e809c0822134 100644
--- a/extensions/python/util_model_call_before/_10_mask_secrets.py
+++ b/extensions/python/util_model_call_before/_10_mask_secrets.py
@@ -1,3 +1,20 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d65b2c486d010f8e43c30abd1029a502520a04a899c64a6902b9e373f73d2bbf
-size 648
+from helpers.extension import Extension
+from helpers.secrets import get_secrets_manager
+
+
+class MaskToolSecrets(Extension):
+
+ async def execute(self, **kwargs):
+ if not self.agent:
+ return
+
+ # model call data
+ call_data:dict = kwargs.get("call_data", {})
+
+ secrets_mgr = get_secrets_manager(self.agent.context)
+
+ # mask system and user message
+ if system:=call_data.get("system"):
+ call_data["system"] = secrets_mgr.mask_values(system)
+ if message:=call_data.get("message"):
+ call_data["message"] = secrets_mgr.mask_values(message)
\ No newline at end of file
diff --git a/extensions/python/webui_ws_connect/AGENTS.md b/extensions/python/webui_ws_connect/AGENTS.md
index afbd82b1a021c7c5af1b8528f519f6c08f636855..f722ae7b245a9ef613d2ea2998f55ea26676b69a 100644
--- a/extensions/python/webui_ws_connect/AGENTS.md
+++ b/extensions/python/webui_ws_connect/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:dbec3eecc8f858d5c5f8c7cff8f1c4dda2b403c1777859e8ce9a184f40e675cc
-size 563
+# WebUI WebSocket Connect Extensions DOX
+
+## Purpose
+
+- Own backend behavior when a WebUI WebSocket client connects.
+
+## Ownership
+
+- Ordered Python files own state-sync behavior for new WebSocket connections.
+
+## Local Contracts
+
+- Preserve WebSocket auth/session assumptions.
+- Send only state the connected client is allowed to receive.
+
+## Work Guidance
+
+- Coordinate connect behavior with frontend WebSocket client and sync store.
+
+## Verification
+
+- Smoke-test WebUI connection and initial state sync after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/webui_ws_connect/_10_state_sync.py b/extensions/python/webui_ws_connect/_10_state_sync.py
index 84ad5bc970b2b509dc72a213d7de4c7e22a01563..f5263221f1347e09c12416cfd7d89b0fc9b427da 100644
--- a/extensions/python/webui_ws_connect/_10_state_sync.py
+++ b/extensions/python/webui_ws_connect/_10_state_sync.py
@@ -1,3 +1,15 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:53f90d5ce392e8fda645e01036f6f24178925f0def91639e31e6b6ab912df6c6
-size 571
+from helpers.extension import Extension
+from helpers.print_style import PrintStyle
+from helpers.state_monitor import get_state_monitor, _ws_debug_enabled
+
+
+class StateSync(Extension):
+ async def execute(self, instance=None, sid: str = "", **kwargs):
+ if instance is None:
+ return
+
+ monitor = get_state_monitor()
+ monitor.bind_manager(instance.manager, handler_id=instance.identifier)
+ monitor.register_sid(instance.namespace, sid)
+ if _ws_debug_enabled():
+ PrintStyle.debug(f"[WebuiHandler] connect sid={sid}")
diff --git a/extensions/python/webui_ws_disconnect/AGENTS.md b/extensions/python/webui_ws_disconnect/AGENTS.md
index dadec6cb97a735b77125b0b5f36706a9ac7d62f6..70365289107f7337f19c4201f536e7426404f4db 100644
--- a/extensions/python/webui_ws_disconnect/AGENTS.md
+++ b/extensions/python/webui_ws_disconnect/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:0191125e588b0356f0d70cd768202834344e54e729bfd8c089fdd9e82a5ac5f7
-size 583
+# WebUI WebSocket Disconnect Extensions DOX
+
+## Purpose
+
+- Own backend behavior when a WebUI WebSocket client disconnects.
+
+## Ownership
+
+- Ordered Python files own state-sync cleanup for disconnect events.
+
+## Local Contracts
+
+- Cleanup must be idempotent and safe for repeated disconnect events.
+- Do not remove shared state still needed by other active clients.
+
+## Work Guidance
+
+- Coordinate disconnect behavior with frontend reconnect and sync indicators.
+
+## Verification
+
+- Smoke-test disconnect and reconnect behavior after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/webui_ws_disconnect/_10_state_sync.py b/extensions/python/webui_ws_disconnect/_10_state_sync.py
index dc8192f21f7bb3947f078f7524b9cc6b464022d9..adde8fdabef956e1dab7d91b5fff36e34a23d0b8 100644
--- a/extensions/python/webui_ws_disconnect/_10_state_sync.py
+++ b/extensions/python/webui_ws_disconnect/_10_state_sync.py
@@ -1,3 +1,13 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:ab4d45092c4cb8357b32ec7b542df249e966ead38bacb6c49fd4995d98c420e3
-size 471
+from helpers.extension import Extension
+from helpers.print_style import PrintStyle
+from helpers.state_monitor import get_state_monitor, _ws_debug_enabled
+
+
+class StateSync(Extension):
+ async def execute(self, instance=None, sid: str = "", **kwargs):
+ if instance is None:
+ return
+
+ get_state_monitor().unregister_sid(instance.namespace, sid)
+ if _ws_debug_enabled():
+ PrintStyle.debug(f"[WebuiHandler] disconnect sid={sid}")
diff --git a/extensions/python/webui_ws_event/AGENTS.md b/extensions/python/webui_ws_event/AGENTS.md
index 1cf591ce497cd96e9407397db9ba6f53a508a0bd..a47e147b23bd375895fbc633eeac7a881d007a13 100644
--- a/extensions/python/webui_ws_event/AGENTS.md
+++ b/extensions/python/webui_ws_event/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a890a37f19c55cffd2efa2db7055e1a8d4fe62388e98c958bbf39e1e88cf2931
-size 566
+# WebUI WebSocket Event Extensions DOX
+
+## Purpose
+
+- Own backend behavior for incoming WebUI WebSocket events.
+
+## Ownership
+
+- Ordered Python files own state-sync event handling and future WebSocket event extensions.
+
+## Local Contracts
+
+- Validate event names and payloads before acting on them.
+- Preserve auth/session boundaries for all WebSocket events.
+
+## Work Guidance
+
+- Coordinate event changes with frontend WebSocket client and sync store.
+
+## Verification
+
+- Smoke-test relevant WebSocket events after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/python/webui_ws_event/_10_state_sync.py b/extensions/python/webui_ws_event/_10_state_sync.py
index cf6591c219e06213048f5e60c6698def2fc56a13..c29f1c0d86bae5769ad2d0effea70fbe2bcf83b9 100644
--- a/extensions/python/webui_ws_event/_10_state_sync.py
+++ b/extensions/python/webui_ws_event/_10_state_sync.py
@@ -1,3 +1,66 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:7230bdffdffa2013dc86011be8eea0eebb44dabc879db4d4796b94ffbdfebf87
-size 2175
+from helpers import runtime
+from helpers.extension import Extension
+from helpers.print_style import PrintStyle
+from helpers.state_monitor import get_state_monitor, _ws_debug_enabled
+from helpers.state_snapshot import (
+ StateRequestValidationError,
+ parse_state_request_payload,
+)
+
+
+class StateSync(Extension):
+ async def execute(
+ self,
+ instance=None,
+ sid: str = "",
+ event_type: str = "",
+ data: dict | None = None,
+ response_data: dict | None = None,
+ **kwargs,
+ ):
+ if instance is None or data is None:
+ return
+
+ if event_type != "state_request":
+ return
+
+ correlation_id = data.get("correlationId")
+ try:
+ request = parse_state_request_payload(data)
+ except StateRequestValidationError as exc:
+ PrintStyle.warning(
+ f"[WebuiHandler] INVALID_REQUEST sid={sid} reason={exc.reason} details={exc.details!r}"
+ )
+ if response_data is not None:
+ response_data["code"] = "INVALID_REQUEST"
+ response_data["message"] = str(exc)
+ return
+
+ if _ws_debug_enabled():
+ PrintStyle.debug(
+ f"[WebuiHandler] state_request sid={sid} context={request.context!r} "
+ f"log_from={request.log_from} notifications_from={request.notifications_from} timezone={request.timezone!r} "
+ f"correlation_id={correlation_id}"
+ )
+
+ seq_base = 1
+ monitor = get_state_monitor()
+ monitor.update_projection(
+ instance.namespace,
+ sid,
+ request=request,
+ seq_base=seq_base,
+ )
+ monitor.mark_dirty(
+ instance.namespace,
+ sid,
+ reason="webui_ws_event.StateSync.state_request",
+ )
+ if _ws_debug_enabled():
+ PrintStyle.debug(
+ f"[WebuiHandler] state_request accepted sid={sid} seq_base={seq_base}"
+ )
+
+ if response_data is not None:
+ response_data["runtime_epoch"] = runtime.get_runtime_id()
+ response_data["seq_base"] = seq_base
diff --git a/extensions/webui/AGENTS.md b/extensions/webui/AGENTS.md
index 0daec0afc7fbf6d9600fdd3dcbd29563d1a5aa0f..f2c530b9070c9fa1f96f82167e93c826bcffce8a 100644
--- a/extensions/webui/AGENTS.md
+++ b/extensions/webui/AGENTS.md
@@ -1,3 +1,48 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:eca453794f0b7b224c804fc8a595132e1fb7c1ff0817c4f8f1d4cb043144b5ba
-size 2608
+# WebUI Extensions DOX
+
+## Purpose
+
+- Own built-in frontend extension contributions under `extensions/webui/`.
+- Keep WebUI extension points compatible with the core loader and plugin extension model.
+
+## Ownership
+
+- Each direct subdirectory is one frontend extension point.
+- `.html` files are injected as component references through ``.
+- `.js` and `.mjs` files export default functions called by `callJsExtensions`.
+
+## Local Contracts
+
+- HTML contributions must be valid component fragments and include Alpine state only where needed.
+- JavaScript extension modules must export a default function.
+- Extension code must not assume a plugin is installed unless it guards that dependency.
+- Keep extension point names synchronized with `x-extension` IDs and `callJsExtensions()` callers.
+
+## Work Guidance
+
+- Prefer small extension modules that delegate to existing WebUI stores or helpers.
+- Use the notification store for user-facing success, warning, or error feedback.
+- Avoid global DOM queries when an extension hook provides scoped nodes or context.
+
+## Verification
+
+- Manually load the WebUI or run targeted frontend/WebUI tests after changing visible extension behavior.
+- Verify extension cache clearing paths when adding new extension points.
+
+## Child DOX Index
+
+Direct child DOX files:
+
+| Child | Scope |
+| --- | --- |
+| [fetch_api_call_after/AGENTS.md](fetch_api_call_after/AGENTS.md) | Frontend hooks after raw `fetchApi()` calls. |
+| [fetch_api_call_before/AGENTS.md](fetch_api_call_before/AGENTS.md) | Frontend hooks before raw `fetchApi()` calls. |
+| [get_message_handler/AGENTS.md](get_message_handler/AGENTS.md) | Message rendering handler extensions. |
+| [initFw_end/AGENTS.md](initFw_end/AGENTS.md) | Post-WebUI-framework-initialization extensions. |
+| [json_api_call_after/AGENTS.md](json_api_call_after/AGENTS.md) | Frontend hooks after `callJsonApi()` calls. |
+| [json_api_call_before/AGENTS.md](json_api_call_before/AGENTS.md) | Frontend hooks before `callJsonApi()` calls. |
+| [right-canvas-panels/AGENTS.md](right-canvas-panels/AGENTS.md) | Built-in right-canvas panel HTML contributions. |
+| [right_canvas_register_surfaces/AGENTS.md](right_canvas_register_surfaces/AGENTS.md) | Built-in right-canvas surface registrations. |
+| [set_messages_after_loop/AGENTS.md](set_messages_after_loop/AGENTS.md) | Frontend hooks after message DOM updates. |
+| [set_messages_before_loop/AGENTS.md](set_messages_before_loop/AGENTS.md) | Frontend hooks before message DOM updates. |
+| [webui_ws_push/AGENTS.md](webui_ws_push/AGENTS.md) | WebUI WebSocket push-event behavior. |
diff --git a/extensions/webui/fetch_api_call_after/AGENTS.md b/extensions/webui/fetch_api_call_after/AGENTS.md
index d79cb354390bddfef8c1464adb7b0ad00c4b5e64..963be5f11bf0b9c1280f1b8a1c886780bc417baa 100644
--- a/extensions/webui/fetch_api_call_after/AGENTS.md
+++ b/extensions/webui/fetch_api_call_after/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3e9e591ebd3fe47fb36a4171bf7a292b89520d2f10f83864be2f92e652447cf5
-size 634
+# Fetch API Call After Extensions DOX
+
+## Purpose
+
+- Own frontend extension hooks that run after raw `fetchApi()` calls.
+
+## Ownership
+
+- Files in this folder own after-call behavior for CSRF-aware raw fetch flows.
+
+## Local Contracts
+
+- JavaScript modules must export a default function when present.
+- Do not consume response bodies unless the hook contract explicitly passes a clone or mutable context for that purpose.
+
+## Work Guidance
+
+- Keep after-call extensions lightweight and safe for all fetch callers.
+
+## Verification
+
+- Smoke-test frontend API calls after adding behavior here.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/fetch_api_call_before/AGENTS.md b/extensions/webui/fetch_api_call_before/AGENTS.md
index ab820d8be54ceb69a03a679d5218918496f3a61f..c715757d5930262561a40918e121203efa3f8c38 100644
--- a/extensions/webui/fetch_api_call_before/AGENTS.md
+++ b/extensions/webui/fetch_api_call_before/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d332098957e0730486ba06988a990d470c6bcfaae9ae05a6975ae444b90db12e
-size 576
+# Fetch API Call Before Extensions DOX
+
+## Purpose
+
+- Own frontend extension hooks that run before raw `fetchApi()` calls.
+
+## Ownership
+
+- Files in this folder own before-call behavior for CSRF-aware raw fetch flows.
+
+## Local Contracts
+
+- JavaScript modules must export a default function when present.
+- Preserve CSRF, auth, and redirect behavior owned by `/js/api.js`.
+
+## Work Guidance
+
+- Avoid broad request mutation that surprises unrelated API callers.
+
+## Verification
+
+- Smoke-test affected frontend API calls after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/get_message_handler/AGENTS.md b/extensions/webui/get_message_handler/AGENTS.md
index 7f6eb9c7350fb027dcf0e3fbfe4cab8301e59897..ea0a36977842441fa23d20b318a925e2b6024522 100644
--- a/extensions/webui/get_message_handler/AGENTS.md
+++ b/extensions/webui/get_message_handler/AGENTS.md
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:553d954d363ae9caac2616d502ef327625a61b57aef264edf6a44870d429e215
-size 664
+# Get Message Handler Extensions DOX
+
+## Purpose
+
+- Own frontend extensions that provide or modify message rendering handlers.
+
+## Ownership
+
+- Files in this folder own handler registration behavior for rendered chat messages.
+
+## Local Contracts
+
+- JavaScript modules must export a default function when present.
+- Preserve mutable context contracts used by `/js/messages.js`.
+- Do not render unsanitized model or user content.
+
+## Work Guidance
+
+- Coordinate handler changes with message components and plugin message extensions.
+
+## Verification
+
+- Smoke-test message rendering for affected message types after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/initFw_end/AGENTS.md b/extensions/webui/initFw_end/AGENTS.md
index 499b609b3eaa5dda8f73132bd131db9174a08835..7390427ded94cd6285fc52d2c4d1b07d8fd1de73 100644
--- a/extensions/webui/initFw_end/AGENTS.md
+++ b/extensions/webui/initFw_end/AGENTS.md
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:b1a11494a4e3f6545379a24c43cf1a30ab7ea68360ecb54fa175f667e4940643
-size 670
+# Init Framework End Extensions DOX
+
+## Purpose
+
+- Own frontend extensions that run after WebUI framework initialization.
+
+## Ownership
+
+- JavaScript files own post-bootstrap global setup such as self-update helpers and session-scoped UI restoration hooks.
+
+## Local Contracts
+
+- JavaScript modules must export a default function.
+- Setup must be idempotent across reloads and cache resets.
+- Do not register duplicate global listeners.
+
+## Work Guidance
+
+- Coordinate initialization changes with `/js/initFw.js` and component lifecycle directives.
+
+## Verification
+
+- Smoke-test WebUI startup and browser console after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/initFw_end/restoreRestorableModals.js b/extensions/webui/initFw_end/restoreRestorableModals.js
index 07edb2e8ce486d5a0a77f742773f3e996b86dd00..51462c2bfcbd03366759921ce1a5bd258c2b86b4 100644
--- a/extensions/webui/initFw_end/restoreRestorableModals.js
+++ b/extensions/webui/initFw_end/restoreRestorableModals.js
@@ -1,3 +1,5 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:f756412bce11ea7a07758c7a79ac875a69adb977b34d1e8e0bb3cb3f58a07d87
-size 149
+import { restoreRestorableModalStack } from "/js/modals.js";
+
+export default function restoreRestorableModals() {
+ restoreRestorableModalStack();
+}
diff --git a/extensions/webui/initFw_end/selfUpdateGlobal.js b/extensions/webui/initFw_end/selfUpdateGlobal.js
index 46670b04f5f1e6ffa8bbead01e1c90d948b6d5bf..b86b50aafc91df048540a186a7385aa96f77822e 100644
--- a/extensions/webui/initFw_end/selfUpdateGlobal.js
+++ b/extensions/webui/initFw_end/selfUpdateGlobal.js
@@ -1,3 +1,5 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d7ce3bb10568e85774f884354afd74ea33f2f17958d76339557552a83f847acd
-size 171
+import { store } from "/components/settings/external/self-update-store.js";
+
+export default async function selfUpdateGlobal(ctx) {
+ // do nothing, the import is enough
+}
diff --git a/extensions/webui/json_api_call_after/AGENTS.md b/extensions/webui/json_api_call_after/AGENTS.md
index 47d8241bcaa334d377037d5bc0b862d5557313b8..96f356b8d2b010c8078f4e006e934c1aa6ad285c 100644
--- a/extensions/webui/json_api_call_after/AGENTS.md
+++ b/extensions/webui/json_api_call_after/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:b42c8b00b2d581e6cf4736824858c19636208c457c46f9729341675324f0d1a1
-size 585
+# JSON API Call After Extensions DOX
+
+## Purpose
+
+- Own frontend extension hooks that run after `callJsonApi()` calls.
+
+## Ownership
+
+- JavaScript files own after-call behavior such as cache reset handling.
+
+## Local Contracts
+
+- JavaScript modules must export a default function.
+- Preserve JSON API response contracts and avoid hiding errors from callers.
+
+## Work Guidance
+
+- Keep global side effects narrow and tied to explicit API results or mutable contexts.
+
+## Verification
+
+- Smoke-test JSON API callers affected by extension changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/json_api_call_after/cache_reset.js b/extensions/webui/json_api_call_after/cache_reset.js
index be6962ce563ccc8b2174d040a2b87381af15b729..b09e96bbce41404e88611eaee35cd88c7706525c 100644
--- a/extensions/webui/json_api_call_after/cache_reset.js
+++ b/extensions/webui/json_api_call_after/cache_reset.js
@@ -1,3 +1,14 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:ef416a6db9481288f7d4f861cfdedfeffb228888a5d965af0e4c8404dae0cf66
-size 332
+import { clear } from "/js/cache.js";
+
+export default async function resetCache(ctx) {
+ try {
+ // clear frontend cache areas when backend caches are cleared via API
+ if (ctx.endpoint == "cache_reset") {
+ for (const area of ctx.data.areas) {
+ clear(area);
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+}
diff --git a/extensions/webui/json_api_call_before/AGENTS.md b/extensions/webui/json_api_call_before/AGENTS.md
index 9fec86a2554d64d91b55cdf07176e526f04443ab..b14f04ea9a3b69c96e1f3618a2dc8e56929d1c8d 100644
--- a/extensions/webui/json_api_call_before/AGENTS.md
+++ b/extensions/webui/json_api_call_before/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9598aed7f6ad9604983591d8467c4967038b6579a9bd01b5ee2e5525294090cb
-size 583
+# JSON API Call Before Extensions DOX
+
+## Purpose
+
+- Own frontend extension hooks that run before `callJsonApi()` calls.
+
+## Ownership
+
+- Files in this folder own before-call behavior for JSON API requests.
+
+## Local Contracts
+
+- JavaScript modules must export a default function when present.
+- Preserve CSRF/auth behavior and JSON payload shape expected by `/js/api.js`.
+
+## Work Guidance
+
+- Avoid broad request mutation that affects unrelated plugin or core API calls.
+
+## Verification
+
+- Smoke-test affected JSON API calls after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/right-canvas-panels/AGENTS.md b/extensions/webui/right-canvas-panels/AGENTS.md
index 1bc5ed7b13fc880b3b558e740b34bd42d02d3149..014adda270d90dd9c87c54c7cef1c6dd63955e50 100644
--- a/extensions/webui/right-canvas-panels/AGENTS.md
+++ b/extensions/webui/right-canvas-panels/AGENTS.md
@@ -1,3 +1,28 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:58cd137985a9d03bc535dcc24df6d2be5a278b5f8a3788cfac37c54bc1d26729
-size 824
+# Right Canvas Panel Extensions DOX
+
+## Purpose
+
+- Own built-in HTML panel contributions for the right-canvas surface area.
+
+## Ownership
+
+- `.html` files mount WebUI components into the `right-canvas-panels` extension point.
+- Panel wrappers own `data-surface-id` anchors and active/mounted visibility bindings.
+
+## Local Contracts
+
+- Each panel must correspond to a registered right-canvas surface ID.
+- Use `` for reusable component content instead of duplicating panel implementations.
+- Keep canvas panels compatible with `.right-canvas-surface-panel` layout semantics.
+
+## Work Guidance
+
+- Prefer thin wrappers that delegate lifecycle and state to the owning component store.
+
+## Verification
+
+- Smoke-test opening the matching surface from the right-canvas rail.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/right-canvas-panels/files-panel.html b/extensions/webui/right-canvas-panels/files-panel.html
index 034014c362cfbc5ec041feb5a48c933cc6a99253..fc0e7a1e637d81891e20acc997a210bee4027b3a 100644
--- a/extensions/webui/right-canvas-panels/files-panel.html
+++ b/extensions/webui/right-canvas-panels/files-panel.html
@@ -1,3 +1,11 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9db7bbd2f7edc21ec6aabfbae5226eeaaab4bdd25bcee0c1e427b4cf75b145c5
-size 410
+
+
+
diff --git a/extensions/webui/right_canvas_register_surfaces/AGENTS.md b/extensions/webui/right_canvas_register_surfaces/AGENTS.md
index e06ea892f6dd6940a0852d1697bd5990baa55bce..1387cda749837311c9f19bf54eb762a9cf1cf3b5 100644
--- a/extensions/webui/right_canvas_register_surfaces/AGENTS.md
+++ b/extensions/webui/right_canvas_register_surfaces/AGENTS.md
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5a8667b504ade89d0f623a5648ab36c1bb857b6cb32b3261b4d904479ebc075d
-size 674
+# Right Canvas Surface Extensions DOX
+
+## Purpose
+
+- Own frontend registration of built-in right-canvas surfaces.
+
+## Ownership
+
+- JavaScript files own registration of remote link, space agent, file browser, and future core canvas surfaces.
+
+## Local Contracts
+
+- JavaScript modules must export a default function.
+- Surface IDs must be unique and stable.
+- Registered surfaces must point to valid components or handlers.
+
+## Work Guidance
+
+- Coordinate surface registration changes with `webui/components/canvas/` and related plugin panels.
+
+## Verification
+
+- Smoke-test opening each registered right-canvas surface after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/right_canvas_register_surfaces/register-files.js b/extensions/webui/right_canvas_register_surfaces/register-files.js
index bec7156ecf1afeb01366257838f02e058a07088f..f1cd6bd40b1670cbadf9636a14174fb369be9e40 100644
--- a/extensions/webui/right_canvas_register_surfaces/register-files.js
+++ b/extensions/webui/right_canvas_register_surfaces/register-files.js
@@ -1,3 +1,44 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c7e92622aee4b824f63d74c5926e1aac3e31a0ed3df42e3d96c43415eb155c53
-size 1542
+import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
+
+function waitForElement(selector, timeoutMs = 3000) {
+ const found = document.querySelector(selector);
+ if (found) return Promise.resolve(found);
+ return new Promise((resolve) => {
+ const timeout = globalThis.setTimeout(() => {
+ observer.disconnect();
+ resolve(document.querySelector(selector));
+ }, timeoutMs);
+ const observer = new MutationObserver(() => {
+ const element = document.querySelector(selector);
+ if (!element) return;
+ globalThis.clearTimeout(timeout);
+ observer.disconnect();
+ resolve(element);
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ });
+}
+
+export default async function registerFilesSurface(surfaces) {
+ surfaces.registerSurface({
+ id: "files",
+ title: "Files",
+ icon: "folder",
+ order: 5,
+ modalPath: "modals/file-browser/file-browser.html",
+ beginDockHandoff() {
+ fileBrowserStore.beginSurfaceHandoff?.();
+ },
+ finishDockHandoff(payload = {}) {
+ fileBrowserStore.finishSurfaceHandoff?.(payload);
+ },
+ cancelDockHandoff() {
+ fileBrowserStore.cancelSurfaceHandoff?.();
+ },
+ async open(payload = {}) {
+ const panel = await waitForElement('[data-surface-id="files"] .file-browser-root');
+ if (!panel) throw new Error("Files surface panel did not mount.");
+ await fileBrowserStore.openSurface(payload.path || payload.filePath || payload.directory || "");
+ },
+ });
+}
diff --git a/extensions/webui/right_canvas_register_surfaces/register-remote-link.js b/extensions/webui/right_canvas_register_surfaces/register-remote-link.js
index bb1f17c65be56d43c708ccfea4a87a7c4726e3fd..dd0d95d4d1afe79d5a191b1e9934ef82c54aefa2 100644
--- a/extensions/webui/right_canvas_register_surfaces/register-remote-link.js
+++ b/extensions/webui/right_canvas_register_surfaces/register-remote-link.js
@@ -1,3 +1,3 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6425c432f7222030d3eb14daa437967c66ffeb299f05b18c83813f2e7176a04d
-size 145
+export default async function registerRemoteLinkAction() {
+ // Remote Control is opened from the sidebar dropdown, not the right canvas rail.
+}
diff --git a/extensions/webui/right_canvas_register_surfaces/register-space-agent.js b/extensions/webui/right_canvas_register_surfaces/register-space-agent.js
index 201c12c0025272fcdaba452f92b6ef9b2826fdea..5fccb5ecc42c46e49358f23145eb267c81641f97 100644
--- a/extensions/webui/right_canvas_register_surfaces/register-space-agent.js
+++ b/extensions/webui/right_canvas_register_surfaces/register-space-agent.js
@@ -1,3 +1,3 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:8ced6114cbbae8fe627342c8b2c250272cae24d45887fba775a8d04ce0edbc1e
-size 142
+export default async function registerSpaceAgentAction() {
+ // Space Agent is opened from the sidebar dropdown, not the right canvas rail.
+}
diff --git a/extensions/webui/set_messages_after_loop/AGENTS.md b/extensions/webui/set_messages_after_loop/AGENTS.md
index 1f1523afc8fb6e8ecd762d625f587666a0229fce..85fec404413c59c80995e08aee24b11ef7a22ac4 100644
--- a/extensions/webui/set_messages_after_loop/AGENTS.md
+++ b/extensions/webui/set_messages_after_loop/AGENTS.md
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9a13f9ee44f505a75d9ff6a2bcd40cfbfaf7f6f0c3cf91fb024d76ffc1a5b32d
-size 820
+# Set Messages After Loop Extensions DOX
+
+## Purpose
+
+- Own frontend extensions that run after message DOM updates complete.
+
+## Ownership
+
+- Files in this folder own after-render message behavior.
+
+## Local Contracts
+
+- JavaScript modules must export a default function when present.
+- Preserve message DOM stability and avoid duplicate controls on repeated renders.
+- Offscreen live entries in a virtualized chat may appear in `context.results` with `result.virtualized === true` and `result.element === null`; DOM extensions must guard `element`, while args-only side effects may still run.
+
+## Work Guidance
+
+- Use stable markers when injecting controls into message elements.
+
+## Verification
+
+- Smoke-test message rerendering and extension-injected controls after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/set_messages_before_loop/AGENTS.md b/extensions/webui/set_messages_before_loop/AGENTS.md
index bdbe4af40ddb9a902beeb235c281db83197d5725..d417ba566a97b7c27217c633345f977fe4549cce 100644
--- a/extensions/webui/set_messages_before_loop/AGENTS.md
+++ b/extensions/webui/set_messages_before_loop/AGENTS.md
@@ -1,3 +1,26 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d65c552fe70aaa356f9dc16ceacb0f7b0150891a1828d9ecb17322579480798e
-size 568
+# Set Messages Before Loop Extensions DOX
+
+## Purpose
+
+- Own frontend extensions that run before message DOM updates.
+
+## Ownership
+
+- Files in this folder own pre-render message behavior.
+
+## Local Contracts
+
+- JavaScript modules must export a default function when present.
+- Do not remove DOM state needed by message rendering unless the mutable context owns it.
+
+## Work Guidance
+
+- Coordinate pre-render behavior with `/js/messages.js` and message components.
+
+## Verification
+
+- Smoke-test message updates after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/webui_ws_push/AGENTS.md b/extensions/webui/webui_ws_push/AGENTS.md
index 3ef99e937484b020af4281c981900e89e822cb24..58d838730f700a1812a500cc7789b22df6fcb358 100644
--- a/extensions/webui/webui_ws_push/AGENTS.md
+++ b/extensions/webui/webui_ws_push/AGENTS.md
@@ -1,3 +1,27 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:bebc9a8d149e1e5a2be5bcfeb054e0ff79651a95059a28548061c269955a9e16
-size 594
+# WebUI WebSocket Push Extensions DOX
+
+## Purpose
+
+- Own frontend behavior for WebUI WebSocket push events.
+
+## Ownership
+
+- JavaScript files own push-event side effects such as cache clearing.
+
+## Local Contracts
+
+- JavaScript modules must export a default function.
+- Validate event payload shape before acting.
+- Keep cache or state resets scoped to the event type.
+
+## Work Guidance
+
+- Coordinate push behavior with backend WebSocket event extensions and frontend stores.
+
+## Verification
+
+- Smoke-test relevant WebSocket push events after changes.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/extensions/webui/webui_ws_push/clear_cache.js b/extensions/webui/webui_ws_push/clear_cache.js
index 99e535692c06fdcaf10fa0c45075886e6e5fdbcc..8ee5491f82379ed6c7d3173e6b8f986b4f66bb6f 100644
--- a/extensions/webui/webui_ws_push/clear_cache.js
+++ b/extensions/webui/webui_ws_push/clear_cache.js
@@ -1,3 +1,21 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:cd457c882cd97164cf472db1b15810ae94341da445e54262f4395ee736094f04
-size 549
+import { clear, clear_all } from "/js/cache.js";
+
+export default async function clearCache(eventType, envelope) {
+ try {
+ // clear frontend cache areas when backend caches are cleared via API
+ if (eventType == "clear_cache") {
+ const areas = envelope?.data?.areas || [];
+ console.log("Clearing caches", areas);
+ if (areas.length > 0) {
+ for (const area of areas) {
+ clear(area);
+ }
+ } else {
+ // clear all caches
+ clear_all();
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+}
diff --git a/helpers/AGENTS.md b/helpers/AGENTS.md
index ea3c73699010f244bcfef002a42dedb1d9c28b1a..39d24d71ede0ea4a9c184cb601aa87841bc78e46 100644
--- a/helpers/AGENTS.md
+++ b/helpers/AGENTS.md
@@ -1,3 +1,41 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:2cd144169cad81a2d7bc14ede21cfdf6f92a314a03617c56c261e2a89ff4913e
-size 2831
+# Backend Helpers DOX
+
+## Purpose
+
+- Own shared Python framework utilities used by agents, APIs, tools, plugins, WebSockets, persistence, and runtime services.
+- Keep cross-cutting behavior stable and tested.
+
+## Ownership
+
+- Helper modules provide reusable services; feature-specific route handlers belong in `api/`, tool behavior in `tools/`, and plugin-local logic inside plugin directories.
+- Security, auth, settings, file access, plugin discovery, extension dispatch, notifications, state snapshots, scheduler, tunnel, and WebSocket primitives live here.
+
+## Local Contracts
+
+- Preserve public helper APIs used by core code and plugins unless all callers, docs, and tests are updated.
+- Use structured parsers and serializers for YAML, JSON, paths, and URLs instead of ad hoc string handling.
+- Keep path handling constrained to intended roots for user files, uploads, downloads, projects, and workdirs.
+- Project metadata defaults must remain backwards-compatible; missing `include_agents_md` is treated as enabled, project instruction file content is injected with an explicit source path, and active-project AGENTS.md path-chain guidance is assembled into prompt protocol without duplicating the project root AGENTS.md.
+- Do not hardcode secrets, provider keys, local absolute paths, or environment-specific values.
+- Use `RepairableException` for errors an agent may be able to fix.
+- This directory is a file-documented DOX profile: every direct `*.py` helper module must have a same-directory `*.py.dox.md` file named by appending `.dox.md` to the full Python filename.
+- The `*.py.dox.md` file owns helper purpose, public classes/functions, cross-module contracts, persistence or side effects, path/security assumptions, important dependencies, and verification guidance.
+- When a helper module is added, removed, renamed, or behaviorally changed, update its matching `*.py.dox.md` in the same change.
+- Do not leave stale file-level DOX after helper deletion or rename.
+
+## Work Guidance
+
+- Prefer cohesive helper modules over adding unrelated utilities to large files.
+- Keep imports acyclic where possible; defer imports inside functions only when needed to avoid startup cycles.
+- For changes touching auth, CSRF, files, plugins, tunnels, WebSockets, or model calls, read the caller and tests before editing.
+- During the DOX pass, verify that every direct `*.py` file has a matching `*.py.dox.md` and that changed helper behavior is described there.
+
+## Verification
+
+- Run targeted tests for changed helper modules.
+- Run security regression tests for auth, CSRF, filesystem, WebSocket, tunnel, upload, or image-serving changes.
+- Check file-level documentation coverage with a script or shell loop that verifies each `helpers/*.py` has a matching `helpers/*.py.dox.md`.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/api.py b/helpers/api.py
index 1da10cb4ba0b90ebbf7f064bc7a533e456c95b98..6047f3eb9acb9bbeddf12a49133b499fe0f5c1bf 100644
--- a/helpers/api.py
+++ b/helpers/api.py
@@ -1,3 +1,302 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:f88f0dae9b2c4405f56893bfad9efe2c6965717d6f4ce3755ffb0e6318542b52
-size 10096
+from abc import abstractmethod
+import json
+import threading
+from urllib.parse import urlsplit, unquote
+from functools import wraps
+from pathlib import Path
+from typing import Union, Dict, Any
+from flask import (
+ Request,
+ Response,
+ jsonify,
+ Flask,
+ session,
+ request,
+ send_file,
+ redirect,
+ url_for,
+)
+from werkzeug.wrappers.response import Response as BaseResponse
+from helpers.print_style import PrintStyle
+from helpers.errors import format_error
+from helpers import files, cache
+
+ThreadLockType = Union[threading.Lock, threading.RLock]
+
+CACHE_AREA = "api_handlers(api)"
+# cache.toggle_area(CACHE_AREA, False) # cache off for now
+
+Input = dict
+Output = Union[Dict[str, Any], Response]
+
+
+class ApiHandler:
+ def __init__(self, app: Flask, thread_lock: ThreadLockType):
+ self.app = app
+ self.thread_lock = thread_lock
+
+ @classmethod
+ def requires_loopback(cls) -> bool:
+ return False
+
+ @classmethod
+ def requires_api_key(cls) -> bool:
+ return False
+
+ @classmethod
+ def requires_auth(cls) -> bool:
+ return True
+
+ @classmethod
+ def get_methods(cls) -> list[str]:
+ return ["POST"]
+
+ @classmethod
+ def requires_csrf(cls) -> bool:
+ return cls.requires_auth()
+
+ @abstractmethod
+ async def process(self, input: Input, request: Request) -> Output:
+ pass
+
+ async def handle_request(self, request: Request) -> Response:
+ try:
+ # input data from request based on type
+ input_data: Input = {}
+ if request.is_json:
+ try:
+ if request.data: # Check if there's any data
+ input_data = request.get_json()
+ # If empty or not valid JSON, use empty dict
+ except Exception as e:
+ # Just log the error and continue with empty input
+ PrintStyle().print(f"Error parsing JSON: {str(e)}")
+ input_data = {}
+ else:
+ # input_data = {"data": request.get_data(as_text=True)}
+ input_data = {}
+
+ # process via handler
+ output = await self.process(input_data, request)
+
+ # return output based on type
+ if isinstance(output, Response):
+ return output
+ else:
+ response_json = json.dumps(output)
+ return Response(
+ response=response_json, status=200, mimetype="application/json"
+ )
+
+ # return exceptions with 500
+ except Exception as e:
+ error = format_error(e)
+ PrintStyle.error(f"API error: {error}")
+ return Response(response=error, status=500, mimetype="text/plain")
+
+ # get context to run agent zero in
+ def use_context(self, ctxid: str, create_if_not_exists: bool = True):
+ from helpers.context_utils import use_context as _use_context
+ return _use_context(self.thread_lock, ctxid, create_if_not_exists)
+
+
+from helpers.network import is_loopback_address
+
+
+def is_safe_next_url(value: str | None) -> bool:
+ """Return True when value is a safe same-origin redirect target."""
+ if not value:
+ return False
+ if "\r" in value or "\n" in value:
+ return False
+ # Reject raw backslashes (browsers normalize `/\host` to `//host` -> external).
+ if "\\" in value:
+ return False
+
+ # Decode percent-escapes so encoded backslashes (e.g. `%5C`) are caught too.
+ decoded = unquote(value)
+ if "\\" in decoded:
+ return False
+
+ parsed = urlsplit(decoded)
+ if parsed.scheme or parsed.netloc:
+ return False
+
+ # Require an absolute path within this origin, but reject protocol-relative URLs.
+ return parsed.path.startswith("/") and not parsed.path.startswith("//")
+
+
+def get_safe_next_url(value: str | None, fallback: str | None = None) -> str | None:
+ """Return value if it is a safe next URL, otherwise return a safe fallback."""
+ if is_safe_next_url(value):
+ return value
+ if is_safe_next_url(fallback):
+ return fallback
+ return None
+
+
+def get_current_request_next_url() -> str:
+ """Return the current request path/query as a safe relative redirect target."""
+ next_url = request.full_path if request.query_string else request.path
+ return get_safe_next_url(next_url, url_for("serve_index")) or url_for("serve_index")
+
+
+def requires_api_key(f):
+ @wraps(f)
+ async def decorated(*args, **kwargs):
+ from helpers.settings import get_settings
+
+ valid_api_key = get_settings()["mcp_server_token"]
+
+ if api_key := request.headers.get("X-API-KEY"):
+ if api_key != valid_api_key:
+ return Response("Invalid API key", 401)
+ elif request.json and request.json.get("api_key"):
+ api_key = request.json.get("api_key")
+ if api_key != valid_api_key:
+ return Response("Invalid API key", 401)
+ else:
+ return Response("API key required", 401)
+ return await f(*args, **kwargs)
+
+ return decorated
+
+
+def requires_loopback(f):
+ @wraps(f)
+ async def decorated(*args, **kwargs):
+ if not is_loopback_address(str(request.remote_addr)):
+ return Response("Access denied.", 403, {})
+ return await f(*args, **kwargs)
+
+ return decorated
+
+
+def requires_auth(f):
+ @wraps(f)
+ async def decorated(*args, **kwargs):
+ from helpers import login
+
+ user_pass_hash = login.get_credentials_hash()
+ if not user_pass_hash:
+ return await f(*args, **kwargs)
+ if session.get("authentication") != user_pass_hash:
+ return redirect(url_for("login_handler", next=get_current_request_next_url()))
+ return await f(*args, **kwargs)
+
+ return decorated
+
+
+def csrf_protect(f):
+ @wraps(f)
+ async def decorated(*args, **kwargs):
+ from helpers import runtime
+
+ token = session.get("csrf_token")
+ header = request.headers.get("X-CSRF-Token")
+ cookie = request.cookies.get("csrf_token_" + runtime.get_runtime_id())
+ sent = header or cookie
+ if not token or not sent or token != sent:
+ return Response("CSRF token missing or invalid", 403)
+ return await f(*args, **kwargs)
+
+ return decorated
+
+
+def register_api_route(app: Flask, lock: ThreadLockType) -> None:
+ from helpers.modules import load_classes_from_file
+ from helpers import plugins
+
+ async def _dispatch(path: str) -> BaseResponse:
+ # Return cached wrapped handler if available
+ cached = cache.get(CACHE_AREA, path)
+ if cached is not None:
+ return await cached()
+
+ # Resolve file path for the handler
+ # Try built-in and plugin api folders before the user fallback
+ handler_cls: type[ApiHandler] | None = None
+
+ # Check built-in python/api/.py
+ builtin_file = files.get_abs_path(f"api/{path}.py")
+ if files.is_in_dir(builtin_file, files.get_abs_path("api")) and files.exists(
+ builtin_file
+ ):
+ classes = load_classes_from_file(builtin_file, ApiHandler)
+ if classes:
+ handler_cls = classes[0]
+
+ # Check plugin api folders: path format plugins//
+ if handler_cls is None and path.startswith("plugins/"):
+ parts = path.split("/", 2)
+ if len(parts) == 3:
+ _, plugin_name, handler_name = parts
+ plugin_dir = plugins.find_plugin_dir(plugin_name)
+ if plugin_dir:
+ plugin_file = Path(plugin_dir) / "api" / f"{handler_name}.py"
+ if plugin_file.is_file():
+ classes = load_classes_from_file(str(plugin_file), ApiHandler)
+ if classes:
+ handler_cls = classes[0]
+
+ # Check user api/.py
+ if handler_cls is None:
+ user_api_dir = files.get_abs_path(files.USER_DIR, files.API_DIR)
+ user_file = files.get_abs_path(user_api_dir, f"{path}.py")
+ if files.is_in_dir(user_file, user_api_dir) and files.exists(user_file):
+ classes = load_classes_from_file(user_file, ApiHandler)
+ if classes:
+ handler_cls = classes[0]
+
+ if handler_cls is None:
+ return Response(f"API endpoint not found: {path}", 404)
+
+ # Check method is allowed
+ if request.method not in handler_cls.get_methods():
+ return Response(f"Method {request.method} not allowed for: {path}", 405)
+
+ # Build handler call, wrapping with security decorators as required
+ async def call_handler() -> BaseResponse:
+ instance = handler_cls(app, lock)
+ return await instance.handle_request(request=request)
+
+ handler_fn = call_handler
+ if handler_cls.requires_csrf():
+ handler_fn = csrf_protect(handler_fn)
+ if handler_cls.requires_api_key():
+ handler_fn = requires_api_key(handler_fn)
+ if handler_cls.requires_auth():
+ handler_fn = requires_auth(handler_fn)
+ if handler_cls.requires_loopback():
+ handler_fn = requires_loopback(handler_fn)
+
+ cache.add(CACHE_AREA, path, handler_fn)
+ return await handler_fn()
+
+ app.add_url_rule(
+ "/api/",
+ "api_dispatch",
+ _dispatch,
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
+ )
+
+
+def register_watchdogs():
+ from helpers import watchdog
+ from helpers.ws import CACHE_AREA as WS_CACHE_AREA
+
+
+ def on_api_change(items: list[watchdog.WatchItem]):
+ PrintStyle.debug("API endpoint watchdog triggered:", items)
+ cache.clear(CACHE_AREA)
+ cache.clear(WS_CACHE_AREA)
+
+ watchdog.add_watchdog(
+ "api_handlers",
+ roots=[
+ files.get_abs_path(files.API_DIR),
+ files.get_abs_path(files.USER_DIR, files.API_DIR),
+ ],
+ patterns=["*.py"],
+ handler=on_api_change,
+ )
diff --git a/helpers/api.py.dox.md b/helpers/api.py.dox.md
index 373a53ab2ad8d9ec27e8d458f86ffabc61b8bd47..5de8d844c95871f6f94bdd882a715c00f80e9406 100644
--- a/helpers/api.py.dox.md
+++ b/helpers/api.py.dox.md
@@ -1,3 +1,72 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:167de11807ea17617a462adcd716f5e56393617d090a3c9c77eead2216f8f19d
-size 3839
+# api.py DOX
+
+## Purpose
+
+- Own the `api.py` helper module.
+- This module defines API handler registration, request security gates, CSRF checks, and watchdog registration.
+- Keep this file-level DOX profile synchronized with `api.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `api.py` owns the runtime implementation.
+- `api.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `ApiHandler` (no explicit base class)
+ - `requires_loopback(cls) -> bool`
+ - `requires_api_key(cls) -> bool`
+ - `requires_auth(cls) -> bool`
+ - `get_methods(cls) -> list[str]`
+ - `requires_csrf(cls) -> bool`
+ - `async process(self, input: Input, request: Request) -> Output`
+ - `async handle_request(self, request: Request) -> Response`
+ - `use_context(self, ctxid: str, create_if_not_exists: bool=...)`
+- Top-level functions:
+- `requires_api_key(f)`
+- `requires_loopback(f)`
+- `requires_auth(f)`
+- `csrf_protect(f)`
+- `register_api_route(app: Flask, lock: ThreadLockType) -> None`
+- `register_watchdogs()`
+- Notable constants/configuration names: `CACHE_AREA`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- `ApiHandler` defines `process(...)`.
+- `ApiHandler` defines `get_methods(...)`.
+- `ApiHandler` defines `requires_auth(...)`.
+- `ApiHandler` defines `requires_csrf(...)`.
+- `ApiHandler` defines `requires_api_key(...)`.
+- `ApiHandler` defines `requires_loopback(...)`.
+- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, plugin state, settings/state persistence, secret handling.
+- Imported dependency areas include: `abc`, `flask`, `functools`, `helpers`, `helpers.errors`, `helpers.network`, `helpers.print_style`, `json`, `pathlib`, `threading`, `typing`, `werkzeug.wrappers.response`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `wraps`, `app.add_url_rule`, `watchdog.add_watchdog`, `cls.requires_auth`, `_use_context`, `login.get_credentials_hash`, `files.get_abs_path`, `handler_cls.requires_csrf`, `handler_cls.requires_api_key`, `handler_cls.requires_auth`, `handler_cls.requires_loopback`, `cache.add`, `PrintStyle.debug`, `cache.clear`, `get_settings`, `f`, `is_loopback_address`, `Response`, `redirect`, `files.is_in_dir`.
+- HTTP handlers retain built-in `api/` and explicit plugin API precedence, then fall back to standalone `usr/api/`; built-in and user roots are containment-checked, and every loaded handler keeps its declared authentication, CSRF, API-key, loopback, and method gates.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_api_chat_lifetime.py`
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_download_toast_regressions.py`
+ - `tests/test_fasta2a_client.py`
+ - `tests/test_fastmcp_openapi_security.py`
+ - `tests/test_host_browser_connector.py`
+ - `tests/test_image_get_security.py`
+ - `tests/test_model_config_api_keys.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/attachment_manager.py b/helpers/attachment_manager.py
index bad83c49716732280475a9805b0b31fa29903b76..cf366dadfa109d93cfae9c8c4c72cb1f96130e34 100644
--- a/helpers/attachment_manager.py
+++ b/helpers/attachment_manager.py
@@ -1,3 +1,94 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:b453b1d6f4241c2a22790529624c5127377b875e0f22ed65cbbe59907ee0e8a9
-size 3138
+import os
+import io
+import base64
+from PIL import Image
+from typing import Dict, List, Optional, Tuple
+from helpers.security import safe_filename
+from werkzeug.datastructures import FileStorage
+
+from helpers.print_style import PrintStyle
+
+class AttachmentManager:
+ ALLOWED_EXTENSIONS = {
+ 'image': {'jpg', 'jpeg', 'png', 'bmp'},
+ 'code': {'py', 'js', 'sh', 'html', 'css'},
+ 'document': {'md', 'pdf', 'txt', 'csv', 'json'}
+ }
+
+ def __init__(self, work_dir: str):
+ self.work_dir = work_dir
+ os.makedirs(work_dir, exist_ok=True)
+
+ def is_allowed_file(self, filename: str) -> bool:
+ ext = self.get_file_extension(filename)
+ all_allowed = set().union(*self.ALLOWED_EXTENSIONS.values())
+ return ext in all_allowed
+
+ def get_file_type(self, filename: str) -> str:
+ ext = self.get_file_extension(filename)
+ for file_type, extensions in self.ALLOWED_EXTENSIONS.items():
+ if ext in extensions:
+ return file_type
+ return 'unknown'
+
+ @staticmethod
+ def get_file_extension(filename: str) -> str:
+ return filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
+
+ def validate_mime_type(self, file) -> bool:
+ try:
+ mime_type = file.content_type
+ return mime_type.split('/')[0] in ['image', 'text', 'application']
+ except AttributeError:
+ return False
+
+ def save_file(self, file: FileStorage, name: str) -> Tuple[str, Dict]:
+ """Save file and return path and metadata"""
+ try:
+ filename = safe_filename(name)
+ if not filename:
+ raise ValueError("Invalid filename")
+
+ file_path = os.path.join(self.work_dir, filename)
+
+ file_type = self.get_file_type(filename)
+ metadata = {
+ 'filename': filename,
+ 'type': file_type,
+ 'extension': self.get_file_extension(filename),
+ 'preview': None
+ }
+
+ # Save file
+ file.save(file_path)
+
+ # Generate preview for images
+ if file_type == 'image':
+ metadata['preview'] = self.generate_image_preview(file_path)
+
+ return file_path, metadata
+
+ except Exception as e:
+ PrintStyle.error(f"Error saving file {name}: {e}")
+ return None, {} # type: ignore
+
+ def generate_image_preview(self, image_path: str, max_size: int = 800) -> Optional[str]:
+ try:
+ with Image.open(image_path) as img:
+ # Convert image if needed
+ if img.mode in ('RGBA', 'P'):
+ img = img.convert('RGB')
+
+ # Resize for preview
+ img.thumbnail((max_size, max_size))
+
+ # Save to buffer
+ buffer = io.BytesIO()
+ img.save(buffer, format="JPEG", quality=70, optimize=True)
+
+ # Convert to base64
+ return base64.b64encode(buffer.getvalue()).decode('utf-8')
+ except Exception as e:
+ PrintStyle.error(f"Error generating preview for {image_path}: {e}")
+ return None
+
\ No newline at end of file
diff --git a/helpers/attachment_manager.py.dox.md b/helpers/attachment_manager.py.dox.md
index 3e942b51bc742eaad0f838f7767d5499d3c96669..46dd4a93022cfd0119433ba46d6df0b876a0d344 100644
--- a/helpers/attachment_manager.py.dox.md
+++ b/helpers/attachment_manager.py.dox.md
@@ -1,3 +1,47 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9e9e5fbada8cf044337a0690dd7b1688e195a870f589b0ed214992743b7bc1a3
-size 2663
+# attachment_manager.py DOX
+
+## Purpose
+
+- Own the `attachment_manager.py` helper module.
+- This module tracks uploaded or generated attachments associated with chat contexts.
+- Keep this file-level DOX profile synchronized with `attachment_manager.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `attachment_manager.py` owns the runtime implementation.
+- `attachment_manager.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `AttachmentManager` (no explicit base class)
+ - `is_allowed_file(self, filename: str) -> bool`
+ - `get_file_type(self, filename: str) -> str`
+ - `get_file_extension(filename: str) -> str`
+ - `validate_mime_type(self, file) -> bool`
+ - `save_file(self, file: FileStorage, name: str) -> Tuple[str, Dict]`
+ - `generate_image_preview(self, image_path: str, max_size: int=...) -> Optional[str]`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem writes, settings/state persistence.
+- Imported dependency areas include: `PIL`, `base64`, `helpers.print_style`, `helpers.security`, `io`, `os`, `typing`, `werkzeug.datastructures`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `os.makedirs`, `self.get_file_extension`, `set.union`, `filename.rsplit.lower`, `safe_filename`, `os.path.join`, `self.get_file_type`, `file.save`, `ValueError`, `self.generate_image_preview`, `PrintStyle.error`, `img.thumbnail`, `io.BytesIO`, `img.save`, `base64.b64encode.decode`, `mime_type.split`, `img.convert`, `filename.rsplit`, `base64.b64encode`, `buffer.getvalue`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/backup.py b/helpers/backup.py
index f248ca4e6e16d835459e1ed6ef78886c9371eabe..f97ff67af5ab7fdf4733feea2e551bc3d0bbd9da 100644
--- a/helpers/backup.py
+++ b/helpers/backup.py
@@ -1,3 +1,859 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:13ec621ba14566bbdcfdaf93f4f88927b706ff159aebe5bb9e39ec7ee387aa90
-size 38465
+import zipfile
+import json
+import os
+import tempfile
+import datetime
+import platform
+from typing import List, Dict, Any, Optional
+
+from pathspec import PathSpec
+
+from helpers import files, runtime, git, dotenv
+from helpers.localization import Localization
+from helpers.print_style import PrintStyle
+
+
+class BackupService:
+ """
+ Core backup and restore service for Agent Zero.
+
+ Features:
+ - JSON-based metadata with user-editable path specifications
+ - Comprehensive system information collection
+ - Checksum validation for integrity
+ - RFC compatibility through existing file helpers
+ - Git version integration consistent with main application
+ """
+
+ def __init__(self):
+ self.agent_zero_version = self._get_agent_zero_version()
+ self.agent_zero_root = files.get_abs_path("") # Resolved Agent Zero root
+
+ # Build base paths map for pattern resolution
+ self.base_paths = {
+ self.agent_zero_root: self.agent_zero_root,
+ }
+
+ def get_default_backup_metadata(self) -> Dict[str, Any]:
+ """Get default backup patterns and metadata"""
+ timestamp = Localization.get().now_iso()
+
+ default_patterns = self._get_default_patterns()
+ include_patterns, exclude_patterns = self._parse_patterns(default_patterns)
+
+ return {
+ "backup_name": f"agent-zero-backup-{timestamp[:10]}",
+ "include_hidden": True,
+ "include_patterns": include_patterns,
+ "exclude_patterns": exclude_patterns,
+ "backup_config": {
+ "compression_level": 6,
+ "integrity_check": True
+ }
+ }
+
+ def _get_default_patterns(self) -> str:
+ """Get default backup patterns with resolved absolute paths.
+
+ Only includes Agent Zero project directory patterns.
+ """
+ # Ensure paths don't have double slashes
+ agent_root = self.agent_zero_root.rstrip('/')
+
+ return f"""# User data
+# All persistent user data is now centralized in /usr for easier backup and restore
+{agent_root}/usr/**
+!{agent_root}/usr/.time_travel/**
+"""
+
+ def _get_agent_zero_version(self) -> str:
+ """Get current Agent Zero version"""
+ try:
+ # Get version from git info (same as run_ui.py)
+ gitinfo = git.get_git_info()
+ return gitinfo.get("version", "development")
+ except Exception:
+ return "unknown"
+
+ def _resolve_path(self, pattern_path: str) -> str:
+ """Resolve pattern path to absolute system path (now patterns are already absolute)"""
+ return pattern_path
+
+ def _unresolve_path(self, abs_path: str) -> str:
+ """Convert absolute path back to pattern path (now patterns are already absolute)"""
+ return abs_path
+
+ def _parse_patterns(self, patterns: str) -> tuple[list[str], list[str]]:
+ """Parse patterns string into include and exclude pattern arrays"""
+ include_patterns = []
+ exclude_patterns = []
+
+ for line in patterns.split('\n'):
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+
+ if line.startswith('!'):
+ # Exclude pattern
+ exclude_patterns.append(line[1:]) # Remove the '!' prefix
+ else:
+ # Include pattern
+ include_patterns.append(line)
+
+ return include_patterns, exclude_patterns
+
+ def _patterns_to_string(self, include_patterns: list[str], exclude_patterns: list[str]) -> str:
+ """Convert pattern arrays back to patterns string for pathspec processing"""
+ patterns = []
+
+ # Add include patterns
+ for pattern in include_patterns:
+ patterns.append(pattern)
+
+ # Add exclude patterns with '!' prefix
+ for pattern in exclude_patterns:
+ patterns.append(f"!{pattern}")
+
+ return '\n'.join(patterns)
+
+ async def _get_system_info(self) -> Dict[str, Any]:
+ """Collect system information for metadata"""
+ import psutil
+
+ try:
+ return {
+ "platform": platform.platform(),
+ "system": platform.system(),
+ "release": platform.release(),
+ "version": platform.version(),
+ "machine": platform.machine(),
+ "processor": platform.processor(),
+ "architecture": platform.architecture()[0],
+ "hostname": platform.node(),
+ "python_version": platform.python_version(),
+ "cpu_count": str(psutil.cpu_count()),
+ "memory_total": str(psutil.virtual_memory().total),
+ "disk_usage": str(psutil.disk_usage('/').total if os.path.exists('/') else 0)
+ }
+ except Exception as e:
+ return {"error": f"Failed to collect system info: {str(e)}"}
+
+ async def _get_environment_info(self) -> Dict[str, Any]:
+ """Collect environment information for metadata"""
+ try:
+ return {
+ "user": os.environ.get("USER", "unknown"),
+ "home": os.environ.get("HOME", "unknown"),
+ "shell": os.environ.get("SHELL", "unknown"),
+ "path": os.environ.get("PATH", "")[:200] + "..." if len(os.environ.get("PATH", "")) > 200 else os.environ.get("PATH", ""),
+ "timezone": Localization.get().get_timezone(),
+ "working_directory": os.getcwd(),
+ "agent_zero_root": files.get_abs_path(""),
+ "runtime_mode": "development" if runtime.is_development() else "production"
+ }
+ except Exception as e:
+ return {"error": f"Failed to collect environment info: {str(e)}"}
+
+ async def _get_backup_author(self) -> str:
+ """Get backup author/system identifier"""
+ try:
+ import getpass
+ username = getpass.getuser()
+ hostname = platform.node()
+ return f"{username}@{hostname}"
+ except Exception:
+ return "unknown"
+
+ def _count_directories(self, matched_files: List[Dict[str, Any]]) -> int:
+ """Count unique directories in file list"""
+ directories = set()
+ for file_info in matched_files:
+ dir_path = os.path.dirname(file_info["path"])
+ if dir_path:
+ directories.add(dir_path)
+ return len(directories)
+
+ def _get_explicit_patterns(self, include_patterns: List[str]) -> set[str]:
+ """Extract explicit (non-wildcard) patterns that should always be included"""
+ explicit_patterns = set()
+
+ for pattern in include_patterns:
+ # If pattern doesn't contain wildcards, it's explicit
+ if '*' not in pattern and '?' not in pattern:
+ # Remove leading slash for comparison
+ explicit_patterns.add(pattern.lstrip('/'))
+
+ # Also add parent directories as explicit (so hidden dirs can be traversed)
+ path_parts = pattern.lstrip('/').split('/')
+ for i in range(1, len(path_parts)):
+ parent_path = '/'.join(path_parts[:i])
+ explicit_patterns.add(parent_path)
+
+ return explicit_patterns
+
+ def _is_explicitly_included(self, file_path: str, explicit_patterns: set[str]) -> bool:
+ """Check if a file/directory is explicitly included in patterns"""
+ relative_path = file_path.lstrip('/')
+ return relative_path in explicit_patterns
+
+ def _translate_patterns(self, patterns: List[str], backup_metadata: Dict[str, Any]) -> List[str]:
+ """Translate patterns from backed up system to current system.
+
+ Replaces the backed up Agent Zero root path with the current Agent Zero root path
+ in all patterns if there's an exact match at the beginning of the pattern.
+
+ Args:
+ patterns: List of patterns from the backed up system
+ backup_metadata: Backup metadata containing the original agent_zero_root
+
+ Returns:
+ List of translated patterns for the current system
+ """
+ # Get the backed up agent zero root path from metadata
+ environment_info = backup_metadata.get("environment_info", {})
+ backed_up_agent_root = environment_info.get("agent_zero_root", "")
+
+ # Get current agent zero root path
+ current_agent_root = self.agent_zero_root
+
+ # If we don't have the backed up root path, return patterns as-is
+ if not backed_up_agent_root:
+ return patterns
+
+ # Ensure paths have consistent trailing slash handling
+ backed_up_agent_root = backed_up_agent_root.rstrip('/')
+ current_agent_root = current_agent_root.rstrip('/')
+
+ translated_patterns = []
+ for pattern in patterns:
+ # Check if the pattern starts with the backed up agent zero root
+ if pattern.startswith(backed_up_agent_root + '/') or pattern == backed_up_agent_root:
+ # Replace the backed up root with the current root
+ relative_pattern = pattern[len(backed_up_agent_root):].lstrip('/')
+ if relative_pattern:
+ translated_pattern = current_agent_root + '/' + relative_pattern
+ else:
+ translated_pattern = current_agent_root
+ translated_patterns.append(translated_pattern)
+ else:
+ # Pattern doesn't start with backed up agent root, keep as-is
+ translated_patterns.append(pattern)
+
+ return translated_patterns
+
+ async def test_patterns(self, metadata: Dict[str, Any], max_files: Optional[int] = 1000) -> List[Dict[str, Any]]:
+ """Test backup patterns and return list of matched files.
+
+ Pass max_files=None for internal flows that must process the complete
+ match set, such as backup creation and restore cleanup.
+ """
+ include_patterns = metadata.get("include_patterns", [])
+ exclude_patterns = metadata.get("exclude_patterns", [])
+ include_hidden = metadata.get("include_hidden", True)
+
+ # Convert to patterns string for pathspec
+ patterns_string = self._patterns_to_string(include_patterns, exclude_patterns)
+
+ # Parse patterns using pathspec
+ pattern_lines = [line.strip() for line in patterns_string.split('\n') if line.strip() and not line.strip().startswith('#')]
+
+ if not pattern_lines:
+ return []
+
+ # Get explicit patterns for hidden file handling
+ explicit_patterns = self._get_explicit_patterns(include_patterns)
+
+ has_limit = max_files is not None
+ matched_files = []
+ processed_count = 0
+
+ try:
+ spec = PathSpec.from_lines("gitignore", pattern_lines)
+
+ # Walk through base directories
+ for base_pattern_path, base_real_path in self.base_paths.items():
+ if not os.path.exists(base_real_path):
+ continue
+
+ for root, dirs, files_list in os.walk(base_real_path):
+ # Filter hidden directories if not included, BUT allow explicit ones
+ if not include_hidden:
+ dirs_to_keep = []
+ for d in dirs:
+ if not d.startswith('.'):
+ dirs_to_keep.append(d)
+ else:
+ # Check if this hidden directory is explicitly included
+ dir_path = os.path.join(root, d)
+ pattern_path = self._unresolve_path(dir_path)
+ if self._is_explicitly_included(pattern_path, explicit_patterns):
+ dirs_to_keep.append(d)
+ dirs[:] = dirs_to_keep
+
+ for file in files_list:
+ if has_limit and processed_count >= max_files:
+ break
+
+ file_path = os.path.join(root, file)
+ pattern_path = self._unresolve_path(file_path)
+
+ # Skip hidden files if not included, BUT allow explicit ones
+ if not include_hidden and file.startswith('.'):
+ if not self._is_explicitly_included(pattern_path, explicit_patterns):
+ continue
+
+ # Remove leading slash for pathspec matching
+ relative_path = pattern_path.lstrip('/')
+
+ if spec.match_file(relative_path):
+ try:
+ stat = os.stat(file_path)
+ matched_files.append({
+ "path": pattern_path,
+ "real_path": file_path,
+ "size": stat.st_size,
+ "modified": datetime.datetime.fromtimestamp(
+ stat.st_mtime,
+ tz=Localization.get().get_tzinfo(),
+ ).isoformat(),
+ "type": "file"
+ })
+ processed_count += 1
+ except (OSError, IOError):
+ # Skip files we can't access
+ continue
+
+ if has_limit and processed_count >= max_files:
+ break
+
+ if has_limit and processed_count >= max_files:
+ break
+
+ except Exception as e:
+ raise Exception(f"Error processing patterns: {str(e)}")
+
+ return matched_files
+
+ async def create_backup(
+ self,
+ include_patterns: List[str],
+ exclude_patterns: List[str],
+ include_hidden: bool = True,
+ backup_name: str = "agent-zero-backup"
+ ) -> str:
+ """Create backup archive and return path to created file"""
+
+ # Create metadata for test_patterns
+ metadata = {
+ "include_patterns": include_patterns,
+ "exclude_patterns": exclude_patterns,
+ "include_hidden": include_hidden
+ }
+
+ # Get the complete matched file set. Preview and dry-run callers may
+ # cap their scans for UI responsiveness, but the archive itself must be
+ # complete.
+ matched_files = await self.test_patterns(metadata, max_files=None)
+
+ if not matched_files:
+ raise Exception("No files matched the backup patterns")
+
+ # Create temporary zip file
+ temp_dir = tempfile.mkdtemp()
+ zip_path = os.path.join(temp_dir, f"{backup_name}.zip")
+
+ try:
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
+ # Add comprehensive metadata
+ metadata = {
+ # Basic backup information
+ "agent_zero_version": self.agent_zero_version,
+ "timestamp": Localization.get().now_iso(),
+ "backup_name": backup_name,
+ "include_hidden": include_hidden,
+
+ # Pattern arrays for granular control during restore
+ "include_patterns": include_patterns,
+ "exclude_patterns": exclude_patterns,
+
+ # System and environment information
+ "system_info": await self._get_system_info(),
+ "environment_info": await self._get_environment_info(),
+ "backup_author": await self._get_backup_author(),
+
+ # Backup configuration
+ "backup_config": {
+ "include_patterns": include_patterns,
+ "exclude_patterns": exclude_patterns,
+ "include_hidden": include_hidden,
+ "compression_level": 6,
+ "integrity_check": True
+ },
+
+ # File information
+ "files": [
+ {
+ "path": f["path"],
+ "size": f["size"],
+ "modified": f["modified"],
+ "type": "file"
+ }
+ for f in matched_files
+ ],
+
+ # Statistics
+ "total_files": len(matched_files),
+ "backup_size": sum(f["size"] for f in matched_files),
+ "directory_count": self._count_directories(matched_files),
+ }
+
+ zipf.writestr("metadata.json", json.dumps(metadata, indent=2))
+
+ # Add files
+ for file_info in matched_files:
+ real_path = file_info["real_path"]
+ archive_path = file_info["path"].lstrip('/')
+
+ try:
+ if os.path.exists(real_path) and os.path.isfile(real_path):
+ zipf.write(real_path, archive_path)
+ except (OSError, IOError) as e:
+ # Log error but continue with other files
+ PrintStyle().warning(f"Warning: Could not backup file {real_path}: {e}")
+ continue
+
+ return zip_path
+
+ except Exception as e:
+ # Cleanup on error
+ if os.path.exists(zip_path):
+ os.remove(zip_path)
+ raise Exception(f"Error creating backup: {str(e)}")
+
+ async def inspect_backup(self, backup_file) -> Dict[str, Any]:
+ """Inspect backup archive and return metadata"""
+
+ # Save uploaded file temporarily
+ temp_dir = tempfile.mkdtemp()
+ temp_file = os.path.join(temp_dir, "backup.zip")
+
+ try:
+ backup_file.save(temp_file)
+
+ with zipfile.ZipFile(temp_file, 'r') as zipf:
+ # Read metadata
+ if "metadata.json" not in zipf.namelist():
+ raise Exception("Invalid backup file: missing metadata.json")
+
+ metadata_content = zipf.read("metadata.json").decode('utf-8')
+ metadata = json.loads(metadata_content)
+
+ # Add file list from archive
+ files_in_archive = [name for name in zipf.namelist() if name != "metadata.json"]
+ metadata["files_in_archive"] = files_in_archive
+
+ return metadata
+
+ except zipfile.BadZipFile:
+ raise Exception("Invalid backup file: not a valid zip archive")
+ except json.JSONDecodeError:
+ raise Exception("Invalid backup file: corrupted metadata")
+ finally:
+ # Cleanup
+ if os.path.exists(temp_file):
+ os.remove(temp_file)
+ if os.path.exists(temp_dir):
+ os.rmdir(temp_dir)
+
+ async def preview_restore(
+ self,
+ backup_file,
+ restore_include_patterns: Optional[List[str]] = None,
+ restore_exclude_patterns: Optional[List[str]] = None,
+ overwrite_policy: str = "overwrite",
+ clean_before_restore: bool = False,
+ user_edited_metadata: Optional[Dict[str, Any]] = None
+ ) -> Dict[str, Any]:
+ """Preview which files would be restored based on patterns"""
+
+ # Save uploaded file temporarily
+ temp_dir = tempfile.mkdtemp()
+ temp_file = os.path.join(temp_dir, "backup.zip")
+
+ files_to_restore = []
+ skipped_files = []
+
+ try:
+ backup_file.save(temp_file)
+
+ with zipfile.ZipFile(temp_file, 'r') as zipf:
+ # Read backup metadata from archive
+ original_backup_metadata = {}
+ if "metadata.json" in zipf.namelist():
+ metadata_content = zipf.read("metadata.json").decode('utf-8')
+ original_backup_metadata = json.loads(metadata_content)
+
+ # Use user-edited metadata if provided, otherwise fall back to original
+ backup_metadata = user_edited_metadata if user_edited_metadata else original_backup_metadata
+
+ # Get files from archive (excluding metadata files)
+ archive_files = [name for name in zipf.namelist()
+ if name not in ["metadata.json", "checksums.json"]]
+
+ # Create pathspec for restore patterns if provided
+ restore_spec = None
+ if restore_include_patterns or restore_exclude_patterns:
+ pattern_lines = []
+ if restore_include_patterns:
+ # Translate patterns from backed up system to current system
+ translated_include_patterns = self._translate_patterns(restore_include_patterns, original_backup_metadata)
+ for pattern in translated_include_patterns:
+ # Remove leading slash for pathspec matching
+ pattern_lines.append(pattern.lstrip('/'))
+ if restore_exclude_patterns:
+ # Translate patterns from backed up system to current system
+ translated_exclude_patterns = self._translate_patterns(restore_exclude_patterns, original_backup_metadata)
+ for pattern in translated_exclude_patterns:
+ # Remove leading slash for pathspec matching
+ pattern_lines.append(f"!{pattern.lstrip('/')}")
+
+ if pattern_lines:
+ from pathspec import PathSpec
+ restore_spec = PathSpec.from_lines("gitignore", pattern_lines)
+
+ # Process each file in archive
+ for archive_path in archive_files:
+ # Archive path is already the correct relative path (e.g., "a0/tmp/settings.json")
+ original_path = archive_path
+
+ # Translate path from backed up system to current system
+ # Use original metadata for path translation (environment_info needed for this)
+ target_path = self._translate_restore_path(archive_path, original_backup_metadata)
+
+ # For pattern matching, we need to use the translated path (current system)
+ # so that patterns like "/home/rafael/a0/data/**" can match files correctly
+ translated_path_for_matching = target_path.lstrip('/')
+
+ # Check if file matches restore patterns
+ if restore_spec and not restore_spec.match_file(translated_path_for_matching):
+ skipped_files.append({
+ "archive_path": archive_path,
+ "original_path": original_path,
+ "reason": "not_matched_by_pattern"
+ })
+ continue
+
+ # Check file conflict policy for existing files
+ if os.path.exists(target_path):
+ if overwrite_policy == "skip":
+ skipped_files.append({
+ "archive_path": archive_path,
+ "original_path": original_path,
+ "reason": "file_exists_skip_policy"
+ })
+ continue
+
+ # File will be restored
+ files_to_restore.append({
+ "archive_path": archive_path,
+ "original_path": original_path,
+ "target_path": target_path,
+ "action": "restore"
+ })
+
+ # Handle clean before restore if requested
+ files_to_delete = []
+ if clean_before_restore:
+ # Use user-edited metadata for clean operations so patterns from ACE editor are used
+ files_to_delete = await self._find_files_to_clean_with_user_metadata(backup_metadata, original_backup_metadata)
+
+ # Combine delete and restore operations for preview
+ all_operations = files_to_delete + files_to_restore
+
+ return {
+ "files": all_operations,
+ "files_to_delete": files_to_delete,
+ "files_to_restore": files_to_restore,
+ "skipped_files": skipped_files,
+ "total_count": len(all_operations),
+ "delete_count": len(files_to_delete),
+ "restore_count": len(files_to_restore),
+ "skipped_count": len(skipped_files),
+ "backup_metadata": backup_metadata, # Return user-edited metadata
+ "overwrite_policy": overwrite_policy,
+ "clean_before_restore": clean_before_restore
+ }
+
+ except zipfile.BadZipFile:
+ raise Exception("Invalid backup file: not a valid zip archive")
+ except json.JSONDecodeError:
+ raise Exception("Invalid backup file: corrupted metadata")
+ except Exception as e:
+ raise Exception(f"Error previewing restore: {str(e)}")
+ finally:
+ # Cleanup
+ if os.path.exists(temp_file):
+ os.remove(temp_file)
+ if os.path.exists(temp_dir):
+ os.rmdir(temp_dir)
+
+ async def restore_backup(
+ self,
+ backup_file,
+ restore_include_patterns: Optional[List[str]] = None,
+ restore_exclude_patterns: Optional[List[str]] = None,
+ overwrite_policy: str = "overwrite",
+ clean_before_restore: bool = False,
+ user_edited_metadata: Optional[Dict[str, Any]] = None
+ ) -> Dict[str, Any]:
+ """Restore files from backup archive"""
+
+ allowed_origins = dotenv.get_dotenv_value("ALLOWED_ORIGINS", "")
+ dotenv_path = os.path.abspath(dotenv.get_dotenv_file_path())
+
+ # Save uploaded file temporarily
+ temp_dir = tempfile.mkdtemp()
+ temp_file = os.path.join(temp_dir, "backup.zip")
+
+ restored_files = []
+ skipped_files = []
+ errors = []
+ deleted_files = []
+
+ try:
+ backup_file.save(temp_file)
+
+ with zipfile.ZipFile(temp_file, 'r') as zipf:
+ # Read backup metadata from archive
+ original_backup_metadata = {}
+ if "metadata.json" in zipf.namelist():
+ metadata_content = zipf.read("metadata.json").decode('utf-8')
+ original_backup_metadata = json.loads(metadata_content)
+
+ # Use user-edited metadata if provided, otherwise fall back to original
+ backup_metadata = user_edited_metadata if user_edited_metadata else original_backup_metadata
+
+ # Perform clean before restore if requested
+ if clean_before_restore:
+ # Use user-edited metadata for clean operations so patterns from ACE editor are used
+ files_to_delete = await self._find_files_to_clean_with_user_metadata(backup_metadata, original_backup_metadata)
+ for delete_info in files_to_delete:
+ try:
+ real_path = delete_info["real_path"]
+ if os.path.exists(real_path) and os.path.isfile(real_path):
+ os.remove(real_path)
+ deleted_files.append({
+ "path": delete_info["path"],
+ "real_path": real_path,
+ "action": "deleted",
+ "reason": "clean_before_restore"
+ })
+ except Exception as e:
+ errors.append({
+ "path": delete_info["path"],
+ "real_path": delete_info.get("real_path", "unknown"),
+ "error": f"Failed to delete: {str(e)}"
+ })
+
+ # Get files from archive (excluding metadata files)
+ archive_files = [name for name in zipf.namelist()
+ if name not in ["metadata.json", "checksums.json"]]
+
+ # Create pathspec for restore patterns if provided
+ restore_spec = None
+ if restore_include_patterns or restore_exclude_patterns:
+ pattern_lines = []
+ if restore_include_patterns:
+ # Translate patterns from backed up system to current system
+ translated_include_patterns = self._translate_patterns(restore_include_patterns, original_backup_metadata)
+ for pattern in translated_include_patterns:
+ # Remove leading slash for pathspec matching
+ pattern_lines.append(pattern.lstrip('/'))
+ if restore_exclude_patterns:
+ # Translate patterns from backed up system to current system
+ translated_exclude_patterns = self._translate_patterns(restore_exclude_patterns, original_backup_metadata)
+ for pattern in translated_exclude_patterns:
+ # Remove leading slash for pathspec matching
+ pattern_lines.append(f"!{pattern.lstrip('/')}")
+
+ if pattern_lines:
+ from pathspec import PathSpec
+ restore_spec = PathSpec.from_lines("gitignore", pattern_lines)
+
+ # Process each file in archive
+ for archive_path in archive_files:
+ # Archive path is already the correct relative path (e.g., "a0/tmp/settings.json")
+ original_path = archive_path
+
+ # Translate path from backed up system to current system
+ # Use original metadata for path translation (environment_info needed for this)
+ target_path = self._translate_restore_path(archive_path, original_backup_metadata)
+
+ # For pattern matching, we need to use the translated path (current system)
+ # so that patterns like "/home/rafael/a0/data/**" can match files correctly
+ translated_path_for_matching = target_path.lstrip('/')
+
+ # Check if file matches restore patterns
+ if restore_spec and not restore_spec.match_file(translated_path_for_matching):
+ skipped_files.append({
+ "archive_path": archive_path,
+ "original_path": original_path,
+ "reason": "not_matched_by_pattern"
+ })
+ continue
+
+ try:
+ # Handle overwrite policy
+ if os.path.exists(target_path):
+ if overwrite_policy == "skip":
+ skipped_files.append({
+ "archive_path": archive_path,
+ "original_path": original_path,
+ "reason": "file_exists_skip_policy"
+ })
+ continue
+ elif overwrite_policy == "backup":
+ timestamp = Localization.get().now().strftime('%Y%m%d_%H%M%S')
+ backup_path = f"{target_path}.backup.{timestamp}"
+ import shutil
+ shutil.move(target_path, backup_path)
+
+ # Create target directory if needed
+ target_dir = os.path.dirname(target_path)
+ if target_dir:
+ os.makedirs(target_dir, exist_ok=True)
+
+ # Extract file
+ import shutil
+ with zipf.open(archive_path) as source, open(target_path, 'wb') as target:
+ shutil.copyfileobj(source, target)
+
+ if os.path.abspath(target_path) == dotenv_path:
+ dotenv.save_dotenv_value(
+ "ALLOWED_ORIGINS", allowed_origins, reload_env=False
+ )
+
+ restored_files.append({
+ "archive_path": archive_path,
+ "original_path": original_path,
+ "target_path": target_path,
+ "status": "restored"
+ })
+
+ except Exception as e:
+ errors.append({
+ "path": archive_path,
+ "original_path": original_path,
+ "error": str(e)
+ })
+
+ return {
+ "restored_files": restored_files,
+ "deleted_files": deleted_files,
+ "skipped_files": skipped_files,
+ "errors": errors,
+ "backup_metadata": backup_metadata, # Return user-edited metadata
+ "clean_before_restore": clean_before_restore
+ }
+
+ except zipfile.BadZipFile:
+ raise Exception("Invalid backup file: not a valid zip archive")
+ except json.JSONDecodeError:
+ raise Exception("Invalid backup file: corrupted metadata")
+ except Exception as e:
+ raise Exception(f"Error restoring backup: {str(e)}")
+ finally:
+ # Cleanup
+ if os.path.exists(temp_file):
+ os.remove(temp_file)
+ if os.path.exists(temp_dir):
+ os.rmdir(temp_dir)
+
+ def _translate_restore_path(self, archive_path: str, backup_metadata: Dict[str, Any]) -> str:
+ """Translate file path from backed up system to current system.
+
+ Replaces the backed up Agent Zero root path with the current Agent Zero root path
+ if there's an exact match at the beginning of the path.
+
+ Args:
+ archive_path: Original file path from the archive
+ backup_metadata: Backup metadata containing the original agent_zero_root
+
+ Returns:
+ Translated path for the current system
+ """
+ # Get the backed up agent zero root path from metadata
+ environment_info = backup_metadata.get("environment_info", {})
+ backed_up_agent_root = environment_info.get("agent_zero_root", "")
+
+ # Get current agent zero root path
+ current_agent_root = self.agent_zero_root
+
+ # If we don't have the backed up root path, use original path with leading slash
+ if not backed_up_agent_root:
+ return "/" + archive_path.lstrip('/')
+
+ # Ensure paths have consistent trailing slash handling
+ backed_up_agent_root = backed_up_agent_root.rstrip('/')
+ current_agent_root = current_agent_root.rstrip('/')
+
+ # Convert archive path to absolute path (add leading slash if missing)
+ if not archive_path.startswith('/'):
+ absolute_archive_path = "/" + archive_path
+ else:
+ absolute_archive_path = archive_path
+
+ # Check if the archive path starts with the backed up agent zero root
+ if absolute_archive_path.startswith(backed_up_agent_root + '/') or absolute_archive_path == backed_up_agent_root:
+ # Replace the backed up root with the current root
+ relative_path = absolute_archive_path[len(backed_up_agent_root):].lstrip('/')
+ if relative_path:
+ translated_path = current_agent_root + '/' + relative_path
+ else:
+ translated_path = current_agent_root
+ return translated_path
+ else:
+ # Path doesn't start with backed up agent root, return as-is
+ return absolute_archive_path
+
+ async def _find_files_to_clean_with_user_metadata(self, user_metadata: Dict[str, Any], original_metadata: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Find existing files that match patterns from user-edited metadata for clean operations"""
+ # Use user-edited patterns for what to clean
+ user_include_patterns = user_metadata.get("include_patterns", [])
+ user_exclude_patterns = user_metadata.get("exclude_patterns", [])
+ include_hidden = user_metadata.get("include_hidden", True)
+
+ if not user_include_patterns:
+ return []
+
+ # Translate user-edited patterns from backed up system to current system
+ # Use original metadata for path translation (environment_info)
+ translated_include_patterns = self._translate_patterns(user_include_patterns, original_metadata)
+ translated_exclude_patterns = self._translate_patterns(user_exclude_patterns, original_metadata)
+
+ # Create metadata object for testing translated patterns
+ metadata = {
+ "include_patterns": translated_include_patterns,
+ "exclude_patterns": translated_exclude_patterns,
+ "include_hidden": include_hidden
+ }
+
+ # Find existing files that match the translated user-edited patterns
+ try:
+ existing_files = await self.test_patterns(metadata, max_files=None)
+
+ # Convert to delete operations format
+ files_to_delete = []
+ for file_info in existing_files:
+ if os.path.exists(file_info["real_path"]):
+ files_to_delete.append({
+ "path": file_info["path"],
+ "real_path": file_info["real_path"],
+ "action": "delete",
+ "reason": "clean_before_restore"
+ })
+
+ return files_to_delete
+ except Exception:
+ # If pattern testing fails, return empty list to avoid breaking restore
+ return []
diff --git a/helpers/backup.py.dox.md b/helpers/backup.py.dox.md
index 9ec127da51dcd41eb17a25ff770deeb625666a77..5cb9c24ec6d27df9fc88dcf634bffb72d564777c 100644
--- a/helpers/backup.py.dox.md
+++ b/helpers/backup.py.dox.md
@@ -1,3 +1,53 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6a9081041e1d14aa0c78eb5705a05fc8c4f3df395a0c6a6418eeea3a8a23a3f2
-size 3948
+# backup.py DOX
+
+## Purpose
+
+- Own the `backup.py` helper module.
+- This module builds, inspects, previews, tests, and restores Agent Zero backup archives.
+- Keep this file-level DOX profile synchronized with `backup.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `backup.py` owns the runtime implementation.
+- `backup.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `BackupService` (no explicit base class)
+ - `get_default_backup_metadata(self) -> Dict[str, Any]`
+ - `async test_patterns(self, metadata: Dict[str, Any], max_files: Optional[int]=...) -> List[Dict[str, Any]]`
+ - `async create_backup(self, include_patterns: List[str], exclude_patterns: List[str], include_hidden: bool=..., backup_name: str=...) -> str`
+ - `async inspect_backup(self, backup_file) -> Dict[str, Any]`
+ - `async preview_restore(self, backup_file, restore_include_patterns: Optional[List[str]]=..., restore_exclude_patterns: Optional[List[str]]=..., overwrite_policy: str=..., clean_before_restore: bool=..., user_edited_metadata: Optional[Dict[str, Any]]=...) -> Dict[str, Any]`
+ - `async restore_backup(self, backup_file, restore_include_patterns: Optional[List[str]]=..., restore_exclude_patterns: Optional[List[str]]=..., overwrite_policy: str=..., clean_before_restore: bool=..., user_edited_metadata: Optional[Dict[str, Any]]=...) -> Dict[str, Any]`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, settings/state persistence, secret handling.
+- Imported dependency areas include: `datetime`, `helpers`, `helpers.localization`, `helpers.print_style`, `json`, `os`, `pathspec`, `platform`, `tempfile`, `typing`, `zipfile`.
+- `test_patterns(..., max_files=None)` is the unlimited scan mode. UI preview and dry-run callers may pass bounded limits, but real backup creation and restore clean-before-restore must use unlimited matching so archives and cleanup are not silently truncated.
+- Default backup metadata includes persistent `/usr` data but excludes Time Travel shadow history under `usr/.time_travel/**`.
+- Restoring `usr/.env` preserves the destination instance's allowed origins while restoring authentication and other portable configuration from the archive.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `self._get_agent_zero_version`, `files.get_abs_path`, `Localization.get.now_iso`, `self._get_default_patterns`, `self._parse_patterns`, `self.agent_zero_root.rstrip`, `patterns.split`, `join`, `file_path.lstrip`, `backed_up_agent_root.rstrip`, `current_agent_root.rstrip`, `self._patterns_to_string`, `self._get_explicit_patterns`, `tempfile.mkdtemp`, `os.path.join`, `self._translate_patterns`, `git.get_git_info`, `line.strip`, `line.startswith`, `getpass.getuser`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_download_toast_regressions.py`
+ - `tests/test_office_document_store.py`
+ - `tests/test_self_update_tag_filter.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/browser.py b/helpers/browser.py
index 767561da7e5bbd4143e52e5b7a23d09d517d7e10..a10c4429e9261addd3f9ee2d391a7d2fd5238bc0 100644
--- a/helpers/browser.py
+++ b/helpers/browser.py
@@ -1,3 +1,385 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:81c642e0ecdcd7f66b2b1109b51e90cfc1b0e873705925f0ab9fc21b219755e1
-size 13988
+# import asyncio
+# import re
+# from bs4 import BeautifulSoup
+# from playwright.async_api import (
+# async_playwright,
+# Browser as PlaywrightBrowser,
+# Page,
+# Frame,
+# BrowserContext,
+# )
+
+# from helpers import files
+
+
+# class NoPageError(Exception):
+# pass
+
+
+# class Browser:
+
+# load_timeout = 10000
+# interact_timeout = 3000
+# selector_name = "data-a0sel3ct0r"
+
+# def __init__(self, headless=True):
+# self.browser: PlaywrightBrowser = None # type: ignore
+# self.context: BrowserContext = None # type: ignore
+# self.page: Page = None # type: ignore
+# self._playwright = None
+# self.headless = headless
+# self.contexts = {}
+# self.last_selector = ""
+# self.page_loaded = False
+# self.navigation_count = 0
+
+# async def __aenter__(self):
+# await self.start()
+# return self
+
+# async def __aexit__(self, exc_type, exc_val, exc_tb):
+# await self.close()
+
+# async def start(self):
+# """Start browser session"""
+# self._playwright = await async_playwright().start()
+# if not self.browser:
+# self.browser = await self._playwright.chromium.launch(
+# headless=self.headless, args=["--disable-http2"]
+# )
+# if not self.context:
+# self.context = await self.browser.new_context(
+# user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.141 Safari/537.36"
+# )
+
+# self.page = await self.context.new_page()
+# await self.page.set_viewport_size({"width": 1200, "height": 1200})
+
+# # Inject the JavaScript to modify the attachShadow method
+# js_override = files.read_file("lib/browser/init_override.js")
+# await self.page.add_init_script(js_override)
+
+# # Setup frame handling
+# async def inject_script_into_frames(frame):
+# try:
+# await self.wait_tick()
+# if not frame.is_detached():
+# async with asyncio.timeout(0.25):
+# await frame.evaluate(js_override)
+# print(f"Injected script into frame: {frame.url[:100]}")
+# except Exception as e:
+# # Frame might have been detached during injection, which is normal
+# print(
+# f"Could not inject into frame (possibly detached): {str(e)[:100]}"
+# )
+
+# self.page.on(
+# "frameattached",
+# lambda frame: asyncio.ensure_future(inject_script_into_frames(frame)),
+# )
+
+# # Handle page navigation events
+# async def handle_navigation(frame):
+# if frame == self.page.main_frame:
+# print(f"Page navigated to: {frame.url[:100]}")
+# self.page_loaded = False
+# self.navigation_count += 1
+
+# async def handle_load(dummy):
+# print("Page load completed")
+# self.page_loaded = True
+
+# async def handle_request(request):
+# if (
+# request.is_navigation_request()
+# and request.frame == self.page.main_frame
+# ):
+# print(f"Navigation started to: {request.url[:100]}")
+# self.page_loaded = False
+# self.navigation_count += 1
+
+# self.page.on("request", handle_request)
+# self.page.on("framenavigated", handle_navigation)
+# self.page.on("load", handle_load)
+
+# async def close(self):
+# """Close browser session"""
+# if self.browser:
+# await self.browser.close()
+# if self._playwright:
+# await self._playwright.stop()
+
+# async def open(self, url: str):
+# """Open a URL in the browser"""
+# self.last_selector = ""
+# self.contexts = {}
+# if self.page:
+# await self.page.close()
+# await self.start()
+# try:
+# await self.page.goto(
+# url, wait_until="networkidle", timeout=Browser.load_timeout
+# )
+# except TimeoutError as e:
+# pass
+# except Exception as e:
+# print(f"Error opening page: {e}")
+# raise e
+# await self.wait_tick()
+
+# async def get_full_dom(self) -> str:
+# """Get full DOM with unique selectors"""
+# await self._check_page()
+# js_code = files.read_file("lib/browser/extract_dom.js")
+
+# # Get all frames
+# self.contexts = {}
+# frame_contents = {}
+
+# # Extract content from each frame
+# i = -1
+# for frame in self.page.frames:
+# try:
+# if frame.url: # and frame != self.page.main_frame:
+# i += 1
+# frame_mark = self._num_to_alpha(i)
+
+# # Check if frame is still valid
+# await self.wait_tick()
+# if not frame.is_detached():
+# try:
+# # short timeout to identify and skip unresponsive frames
+# async with asyncio.timeout(0.25):
+# await frame.evaluate("window.location.href")
+# except TimeoutError as e:
+# print(f"Skipping unresponsive frame: {frame.url}")
+# continue
+
+# await frame.wait_for_load_state(
+# "domcontentloaded", timeout=1000
+# )
+
+# async with asyncio.timeout(1):
+# content = await frame.evaluate(
+# js_code, [frame_mark, self.selector_name]
+# )
+# self.contexts[frame_mark] = frame
+# frame_contents[frame.url] = content
+# else:
+# print(f"Warning: Frame was detached: {frame.url}")
+# except Exception as e:
+# print(f"Error extracting from frame {frame.url}: {e}")
+
+# # # Get main frame content
+# # main_mark = self._num_to_alpha(0)
+# # main_content = ""
+# # try:
+# # async with asyncio.timeout(1):
+# # main_content = await self.page.evaluate(js_code, [main_mark, self.selector_name])
+# # self.contexts[main_mark] = self.page
+# # except Exception as e:
+# # print(f"Error when extracting from main frame: {e}")
+
+# # Replace iframe placeholders with actual content
+# # for url, content in frame_contents.items():
+# # placeholder = f' str:
+# """Clean and strip HTML content"""
+# if not html_content:
+# return ""
+
+# soup = BeautifulSoup(html_content, "html.parser")
+
+# for tag in soup.find_all(
+# ["br", "hr", "style", "script", "noscript", "meta", "link", "svg"]
+# ):
+# tag.decompose()
+
+# for tag in soup.find_all(True):
+# if tag.attrs and "invisible" in tag.attrs:
+# tag.decompose()
+
+# for tag in soup.find_all(True):
+# allowed_attrs = [
+# self.selector_name,
+# "aria-label",
+# "placeholder",
+# "name",
+# "value",
+# "type",
+# ]
+# attrs = {
+# "selector" if key == self.selector_name else key: tag.attrs[key]
+# for key in allowed_attrs
+# if key in tag.attrs and tag.attrs[key]
+# }
+# tag.attrs = attrs
+
+# def remove_empty(tag_name: str) -> None:
+# for tag in soup.find_all(tag_name):
+# if not tag.attrs:
+# tag.unwrap()
+
+# remove_empty("span")
+# remove_empty("p")
+# remove_empty("strong")
+
+# return soup.prettify(formatter="minimal")
+
+# def process_html_with_selectors(self, html_content: str) -> str:
+# """Process HTML content and add selectors to interactive elements"""
+# if not html_content:
+# return ""
+
+# html_content = re.sub(r"\s+", " ", html_content)
+# soup = BeautifulSoup(html_content, "html.parser")
+
+# structural_tags = [
+# "html",
+# "head",
+# "body",
+# "div",
+# "span",
+# "section",
+# "main",
+# "article",
+# "header",
+# "footer",
+# "nav",
+# "ul",
+# "ol",
+# "li",
+# "tr",
+# "td",
+# "th",
+# ]
+# for tag in structural_tags:
+# for element in soup.find_all(tag):
+# element.unwrap()
+
+# out = str(soup).strip()
+# out = re.sub(r">\s*<", "><", out)
+# out = re.sub(r'aria-label="', 'label="', out)
+
+# # out = re.sub(r'selector="(\d+[a-zA-Z]+)"', r'selector=\1', out)
+# return out
+
+# async def get_clean_dom(self) -> str:
+# """Get clean DOM with selectors"""
+# full_dom = await self.get_full_dom()
+# clean_dom = self.strip_html_dom(full_dom)
+# return self.process_html_with_selectors(clean_dom)
+
+# async def click(self, selector: str):
+# await self._check_page()
+# ctx, selector = self._parse_selector(selector)
+# self.last_selector = selector
+# # js_code = files.read_file("lib/browser/click.js")
+# # result = await self.page.evaluate(js_code, [selector])
+# # if not result:
+# result = await ctx.hover(selector, force=True, timeout=Browser.interact_timeout)
+# await self.wait_tick()
+# result = await ctx.click(selector, force=True, timeout=Browser.interact_timeout)
+# await self.wait_tick()
+
+# # await self.page.wait_for_load_state("networkidle")
+# return result
+
+# async def press(self, key: str):
+# await self._check_page()
+# if self.last_selector:
+# await self.page.press(
+# self.last_selector, key, timeout=Browser.interact_timeout
+# )
+# else:
+# await self.page.keyboard.press(key)
+
+# async def fill(self, selector: str, text: str):
+# await self._check_page()
+# ctx, selector = self._parse_selector(selector)
+# self.last_selector = selector
+# try:
+# await self.click(selector)
+# except Exception as e:
+# pass
+# await ctx.fill(selector, text, force=True, timeout=Browser.interact_timeout)
+# await self.wait_tick()
+
+# async def execute(self, js_code: str):
+# await self._check_page()
+# result = await self.page.evaluate(js_code)
+# return result
+
+# async def screenshot(self, path: str, full_page=False):
+# await self._check_page()
+# await self.page.screenshot(path=path, full_page=full_page)
+
+# def _parse_selector(self, selector: str) -> tuple[Page | Frame, str]:
+# try:
+# ctx = self.page
+# # Check if selector is our UID, return
+# if re.match(r"^\d+[a-zA-Z]+$", selector):
+# alpha_part = "".join(filter(str.isalpha, selector))
+# ctx = self.contexts[alpha_part]
+# selector = f"[{self.selector_name}='{selector}']"
+# return (ctx, selector)
+# except Exception as e:
+# raise Exception(f"Error evaluating selector: {selector}")
+
+# async def _check_page(self):
+# for _ in range(2):
+# try:
+# await self.wait_tick()
+# self.page = self.context.pages[0]
+# if not self.page:
+# raise NoPageError(
+# "No page is open in the browser. Please open a URL first."
+# )
+# # await self.page.wait_for_load_state("networkidle",)
+# async with asyncio.timeout(self.load_timeout / 1000):
+# if not self.page_loaded:
+# while not self.page_loaded:
+# await asyncio.sleep(0.1)
+# await self.wait_tick()
+# return
+# except TimeoutError as e:
+# self.page_loaded = True
+# return
+# except NoPageError as e:
+# raise e
+# except Exception as e:
+# print(f"Error checking page: {e}")
+
+# def _num_to_alpha(self, num: int) -> str:
+# if num < 0:
+# return ""
+
+# result = ""
+# while num >= 0:
+# result = chr(num % 26 + 97) + result
+# num = num // 26 - 1
+
+# return result
+
+# async def wait_tick(self):
+# if self.page:
+# await self.page.evaluate("window.location.href")
+
+# async def wait(self, seconds: float = 1.0):
+# await asyncio.sleep(seconds)
+# await self.wait_tick()
+
+# async def wait_for_action(self):
+# nav_count = self.navigation_count
+# for _ in range(5):
+# await self._check_page()
+# if nav_count != self.navigation_count:
+# print("Navigation detected")
+# await asyncio.sleep(1)
+# return
+# await asyncio.sleep(0.1)
diff --git a/helpers/browser.py.dox.md b/helpers/browser.py.dox.md
index bf8776cb349e9e8bbb439af3b74ec222b13e8c45..0ac4afccabf3ece78068a00853e6565a86dc5494 100644
--- a/helpers/browser.py.dox.md
+++ b/helpers/browser.py.dox.md
@@ -1,3 +1,46 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:bda5c2a3dbd375b58bd5f09737a7249120649ee11e60e9c92a0631c33a5f8854
-size 1932
+# browser.py DOX
+
+## Purpose
+
+- Own the `browser.py` helper module.
+- This module holds shared browser helper state used by browser-facing integrations.
+- Keep this file-level DOX profile synchronized with `browser.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `browser.py` owns the runtime implementation.
+- `browser.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem deletion.
+
+## Key Concepts
+
+- This module is primarily declarative or delegates behavior through classes/imported objects.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_a0_connector_prompt_gating.py`
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_download_toast_regressions.py`
+ - `tests/test_host_browser_connector.py`
+ - `tests/test_oauth_gemini_api.py`
+ - `tests/test_oauth_providers.py`
+ - `tests/test_oauth_static.py`
+ - `tests/test_oauth_xai_grok.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/cache.py b/helpers/cache.py
index 5fd56f5b66208feb41da6190d64aad150f2c23b6..a9d8d283ddf58904237adcede8b445295ce3387e 100644
--- a/helpers/cache.py
+++ b/helpers/cache.py
@@ -1,3 +1,125 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:f84dea8bb2f1eb8d1db361e3444d815d4ebd01dcacd5ff9bcb86a7fd540aec0f
-size 3077
+import fnmatch
+import threading
+import time
+from dataclasses import dataclass
+from typing import Any
+
+_lock = threading.RLock()
+_cache: dict[str, dict[str, "CacheEntry"]] = {}
+
+_enabled_global: bool = True
+_enabled_areas: dict[str, bool] = {}
+
+
+@dataclass(slots=True)
+class CacheEntry:
+ value: Any
+ timestamp: float
+
+
+def toggle_global(enabled: bool) -> None:
+ global _enabled_global
+ _enabled_global = enabled
+
+
+def toggle_area(area: str, enabled: bool) -> None:
+ _enabled_areas[area] = enabled
+
+
+def has(area: str, key: Any) -> bool:
+ if not _is_enabled(area):
+ return False
+ with _lock:
+ entry = _cache.get(area, {}).get(key)
+ if entry is None:
+ return False
+ _touch_entry(entry)
+ return True
+
+
+def add(area: str, key: Any, data: Any) -> None:
+ if not _is_enabled(area):
+ return
+ with _lock:
+ if area not in _cache:
+ _cache[area] = {}
+ _cache[area][key] = _create_entry(data)
+
+
+def get(area: str, key: Any, default: Any = None) -> Any:
+ if not _is_enabled(area):
+ return default
+ with _lock:
+ entry = _cache.get(area, {}).get(key)
+ if entry is None:
+ return default
+ _touch_entry(entry)
+ return entry.value
+
+
+def remove(area: str, key: Any) -> None:
+ if not _is_enabled(area):
+ return
+ with _lock:
+ if area in _cache:
+ _cache[area].pop(key, None)
+
+
+def clear(area: str) -> None:
+ with _lock:
+ if any(ch in area for ch in "*?["):
+ keys_to_remove = [k for k in _cache.keys() if fnmatch.fnmatch(k, area)]
+ for k in keys_to_remove:
+ _cache.pop(k, None)
+ return
+
+ _cache.pop(area, None)
+
+
+def trim_cache(area: str, seconds: float = 300) -> None:
+ cutoff = time.time() - seconds
+ with _lock:
+ for area_key in _get_matching_areas(area):
+ area_cache = _cache.get(area_key)
+ if not area_cache:
+ continue
+
+ keys_to_remove = [key for key, entry in area_cache.items() if entry.timestamp < cutoff]
+ for key in keys_to_remove:
+ area_cache.pop(key, None)
+
+ if not area_cache:
+ _cache.pop(area_key, None)
+
+
+def clear_all() -> None:
+ with _lock:
+ _cache.clear()
+
+
+def _is_enabled(area: str) -> bool:
+ if not _enabled_global:
+ return False
+ return _enabled_areas.get(area, True)
+
+
+def _create_entry(value: Any) -> CacheEntry:
+ return CacheEntry(value=value, timestamp=time.time())
+
+
+def _touch_entry(entry: CacheEntry) -> None:
+ entry.timestamp = time.time()
+
+
+def _get_matching_areas(area: str) -> list[str]:
+ if any(ch in area for ch in "*?["):
+ return [k for k in _cache.keys() if fnmatch.fnmatch(k, area)]
+ return [area]
+
+
+def determine_cache_key(agent, *additional):
+ if agent:
+ profile = agent.config.profile or "none"
+ project = agent.context.get_data("project") or "none"
+ return (profile, project, *additional)
+ return ("none", "none", *additional)
\ No newline at end of file
diff --git a/helpers/cache.py.dox.md b/helpers/cache.py.dox.md
index ca6b06d195ef7e32f4b85242969b7861e5de4b47..abe865f751d38d8c63d2cdd38277a3136b65a30d 100644
--- a/helpers/cache.py.dox.md
+++ b/helpers/cache.py.dox.md
@@ -1,3 +1,64 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a8da3b6ff671b8309a3164438b5831b78d8f26e7f2307a5d6ee27900cd9a3dce
-size 2901
+# cache.py DOX
+
+## Purpose
+
+- Own the `cache.py` helper module.
+- This module provides in-process cache areas with optional global and area toggles.
+- Keep this file-level DOX profile synchronized with `cache.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `cache.py` owns the runtime implementation.
+- `cache.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `CacheEntry` (no explicit base class)
+- Top-level functions:
+- `toggle_global(enabled: bool) -> None`
+- `toggle_area(area: str, enabled: bool) -> None`
+- `has(area: str, key: Any) -> bool`
+- `add(area: str, key: Any, data: Any) -> None`
+- `get(area: str, key: Any, default: Any=...) -> Any`
+- `remove(area: str, key: Any) -> None`
+- `clear(area: str) -> None`
+- `trim_cache(area: str, seconds: float=...) -> None`
+- `clear_all() -> None`
+- `_is_enabled(area: str) -> bool`
+- `_create_entry(value: Any) -> CacheEntry`
+- `_touch_entry(entry: CacheEntry) -> None`
+- `_get_matching_areas(area: str) -> list[str]`
+- `determine_cache_key(agent, *additional)`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem deletion, plugin state, settings/state persistence.
+- Imported dependency areas include: `dataclasses`, `fnmatch`, `threading`, `time`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `threading.RLock`, `dataclass`, `CacheEntry`, `time.time`, `_is_enabled`, `_touch_entry`, `_create_entry`, `_cache.pop`, `_get_matching_areas`, `_cache.clear`, `agent.context.get_data`, `area_cache.pop`, `fnmatch.fnmatch`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_file_tree_visualize.py`
+ - `tests/test_office_canvas_setup.py`
+ - `tests/test_office_document_store.py`
+ - `tests/test_self_update_tag_filter.py`
+ - `tests/test_time_travel.py`
+ - `tests/test_webui_extension_surfaces.py`
+ - `tests/test_whatsapp_bridge_manager.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/call_llm.py b/helpers/call_llm.py
index 608a4576fb8d13c5e75c36068941a1eeeccab4f1..6b9412e19f600d5e7c07727d4fc647e32c1e0d5c 100644
--- a/helpers/call_llm.py
+++ b/helpers/call_llm.py
@@ -1,3 +1,69 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1d6a971a9ec7ef5678890febe02e1b12ab47bd10dbcd6ab6ac3ae0621ea477d2
-size 1655
+from typing import Callable, TypedDict
+from langchain.prompts import (
+ ChatPromptTemplate,
+ FewShotChatMessagePromptTemplate,
+)
+
+from langchain.schema import AIMessage
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from langchain_core.language_models.chat_models import BaseChatModel
+from langchain_core.language_models.llms import BaseLLM
+
+
+class Example(TypedDict):
+ input: str
+ output: str
+
+async def call_llm(
+ system: str,
+ model: BaseChatModel | BaseLLM,
+ message: str,
+ examples: list[Example] = [],
+ callback: Callable[[str], None] | None = None
+):
+
+ example_prompt = ChatPromptTemplate.from_messages(
+ [
+ HumanMessage(content="{input}"),
+ AIMessage(content="{output}"),
+ ]
+ )
+
+ few_shot_prompt = FewShotChatMessagePromptTemplate(
+ example_prompt=example_prompt,
+ examples=examples, # type: ignore
+ input_variables=[],
+ )
+
+ few_shot_prompt.format()
+
+
+ final_prompt = ChatPromptTemplate.from_messages(
+ [
+ SystemMessage(content=system),
+ few_shot_prompt,
+ HumanMessage(content=message),
+ ]
+ )
+
+ chain = final_prompt | model
+
+ response = ""
+ async for chunk in chain.astream({}):
+ # await self.handle_intervention() # wait for intervention and handle it, if paused
+
+ if isinstance(chunk, str):
+ content = chunk
+ elif hasattr(chunk, "content"):
+ content = str(chunk.content)
+ else:
+ content = str(chunk)
+
+ if callback:
+ callback(content)
+
+ response += content
+
+ return response
+
diff --git a/helpers/call_llm.py.dox.md b/helpers/call_llm.py.dox.md
index 39e6e9a8a63fe9f56901a5d233c493c978db25a6..0ba5bd966a146b4e4a712b82ce948b3ead8738db 100644
--- a/helpers/call_llm.py.dox.md
+++ b/helpers/call_llm.py.dox.md
@@ -1,3 +1,43 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:278a628462e8b8a8fdcaca14088d1f26168c042e5dd72de4a623012a152cd088
-size 2216
+# call_llm.py DOX
+
+## Purpose
+
+- Own the `call_llm.py` helper module.
+- This module wraps LiteLLM/model calls with examples, streaming, and extension hooks.
+- Keep this file-level DOX profile synchronized with `call_llm.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `call_llm.py` owns the runtime implementation.
+- `call_llm.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `Example` (`TypedDict`)
+- Top-level functions:
+- `async call_llm(system: str, model: BaseChatModel | BaseLLM, message: str, examples: list[Example]=..., callback: Callable[[str], None] | None=...)`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: model calls.
+- Imported dependency areas include: `langchain.prompts`, `langchain.schema`, `langchain_core.language_models.chat_models`, `langchain_core.language_models.llms`, `langchain_core.messages`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `ChatPromptTemplate.from_messages`, `FewShotChatMessagePromptTemplate`, `few_shot_prompt.format`, `chain.astream`, `HumanMessage`, `AIMessage`, `SystemMessage`, `callback`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/chat_media.py b/helpers/chat_media.py
index 63ea1b9d6ca09ef19d1b9a60f2158cebec5c7e76..d93af7cd435001f1699048bdd39acc10c91e8972 100644
--- a/helpers/chat_media.py
+++ b/helpers/chat_media.py
@@ -1,3 +1,244 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6ebb591b5e036182552222c68b6dbd68c3b3edc07a906de35404f19e65dcaf7f
-size 7391
+from __future__ import annotations
+
+import time
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal
+
+from helpers import files, media_artifacts
+
+
+DEFAULT_MAX_IMAGE_BYTES = media_artifacts.DEFAULT_MAX_ARTIFACT_SIZE_BYTES
+ImageCategory = Literal["images", "screenshots"]
+
+
+@dataclass(frozen=True)
+class ChatImage:
+ path: str
+ a0_path: str
+ mime: str
+ size: int
+
+
+def screenshot_dir(context_id: str, source: str) -> Path:
+ return artifact_dir(context_id, category="screenshots", source=source)
+
+
+def artifact_dir(
+ context_id: str,
+ *,
+ category: ImageCategory = "images",
+ source: str = "vision-load",
+) -> Path:
+ context_segment = files.safe_file_name(str(context_id or "default")).strip("._") or "default"
+ safe_category = files.safe_file_name(category).strip("._") or "images"
+ safe_source = files.safe_file_name(source).strip("._") or "vision-load"
+
+ return Path(files.get_abs_path("usr/chats", context_segment)) / safe_category / safe_source
+
+
+def save_image_bytes(
+ *,
+ context_id: str,
+ payload: bytes,
+ mime_type: str = "image/png",
+ category: ImageCategory = "images",
+ source: str = "vision-load",
+ preferred_name: str = "",
+ max_bytes: int | None = DEFAULT_MAX_IMAGE_BYTES,
+) -> ChatImage:
+ data = bytes(payload or b"")
+ if not data:
+ raise media_artifacts.EmptyBase64Data("image payload is empty")
+ if max_bytes is not None and len(data) > max_bytes:
+ raise media_artifacts.ArtifactTooLarge(len(data), max_bytes)
+
+ safe_mime = media_artifacts.normalize_mime(
+ mime_type,
+ default="image/png",
+ required_prefix="image/",
+ )
+ default_extension = media_artifacts.guess_extension(safe_mime, ".png")
+ default_filename = f"{source or 'image'}{default_extension}"
+ filename = media_artifacts.safe_filename(
+ preferred_name,
+ default=default_filename,
+ default_extension=default_extension,
+ )
+ filename_path = Path(filename)
+ stem = filename_path.stem or Path(default_filename).stem or "image"
+ suffix = filename_path.suffix or default_extension
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
+ path = artifact_dir(context_id, category=category, source=source) / (
+ f"{stem}-{timestamp}-{uuid.uuid4().hex[:8]}{suffix}"
+ )
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(data)
+ return ChatImage(
+ path=str(path),
+ a0_path=files.normalize_a0_path(str(path)),
+ mime=safe_mime,
+ size=len(data),
+ )
+
+
+def save_image_base64(
+ *,
+ context_id: str,
+ data: str,
+ mime_type: str = "image/png",
+ category: ImageCategory = "images",
+ source: str = "vision-load",
+ preferred_name: str = "",
+ max_bytes: int | None = DEFAULT_MAX_IMAGE_BYTES,
+) -> ChatImage:
+ payload = media_artifacts.decode_base64_payload(data, max_bytes=max_bytes)
+ return save_image_bytes(
+ context_id=context_id,
+ payload=payload.payload,
+ mime_type=mime_type,
+ category=category,
+ source=source,
+ preferred_name=preferred_name,
+ max_bytes=max_bytes,
+ )
+
+
+def save_image_file(
+ *,
+ context_id: str,
+ path: str | Path,
+ category: ImageCategory = "images",
+ source: str = "vision-load",
+ preferred_name: str = "",
+ max_bytes: int | None = DEFAULT_MAX_IMAGE_BYTES,
+) -> ChatImage:
+ image_path = Path(path)
+ payload = image_path.read_bytes()
+ mime = media_artifacts.normalize_mime(
+ _guess_image_mime(image_path),
+ default="image/png",
+ required_prefix="image/",
+ )
+ return save_image_bytes(
+ context_id=context_id,
+ payload=payload,
+ mime_type=mime,
+ category=category,
+ source=source,
+ preferred_name=preferred_name or image_path.name,
+ max_bytes=max_bytes,
+ )
+
+
+def save_image_data_url(
+ *,
+ context_id: str,
+ data_url: str,
+ category: ImageCategory = "images",
+ source: str = "vision-load",
+ preferred_name: str = "",
+ max_bytes: int | None = DEFAULT_MAX_IMAGE_BYTES,
+) -> ChatImage:
+ header, encoded = _split_image_data_url(data_url)
+ mime = header.removeprefix("data:").split(";", 1)[0] or "image/png"
+ return save_image_base64(
+ context_id=context_id,
+ data=encoded,
+ mime_type=mime,
+ category=category,
+ source=source,
+ preferred_name=preferred_name,
+ max_bytes=max_bytes,
+ )
+
+
+def materialize_image_ref(
+ *,
+ context_id: str,
+ url: str,
+ source: str = "",
+ preferred_name: str = "",
+ max_bytes: int | None = DEFAULT_MAX_IMAGE_BYTES,
+) -> str:
+ value = str(url or "").strip()
+ if not value or not str(context_id or "").strip():
+ return value
+
+ resolved_source = source or infer_source(value, preferred_name)
+ category = category_for_source(resolved_source)
+ if _is_data_image_url(value):
+ saved = save_image_data_url(
+ context_id=context_id,
+ data_url=value,
+ category=category,
+ source=resolved_source,
+ preferred_name=preferred_name,
+ max_bytes=max_bytes,
+ )
+ return saved.a0_path
+
+ from helpers import images
+
+ source_path = images.resolve_ref(value)
+ if is_chat_scoped_path(context_id=context_id, path=source_path):
+ return files.normalize_a0_path(str(source_path))
+ saved = save_image_file(
+ context_id=context_id,
+ path=source_path,
+ category=category,
+ source=resolved_source,
+ preferred_name=preferred_name or source_path.name,
+ max_bytes=max_bytes,
+ )
+ return saved.a0_path
+
+
+def is_chat_scoped_path(*, context_id: str, path: str | Path) -> bool:
+ if not str(context_id or "").strip():
+ return False
+ try:
+ target = Path(path).resolve(strict=False)
+ root = artifact_dir(context_id, category="images", source="vision-load").parents[1].resolve(strict=False)
+ return target == root or root in target.parents
+ except OSError:
+ return False
+
+
+def infer_source(value: str = "", preferred_name: str = "") -> str:
+ raw = f"{value or ''} {preferred_name or ''}".lower()
+ if "computer-use" in raw or "computer_use" in raw or "_a0_connector/computer_use" in raw:
+ return "computer-use"
+ if "/desktop/screenshots/" in raw or "\\desktop\\screenshots\\" in raw or "desktop-" in raw:
+ return "desktop"
+ if (
+ "/browser/screenshots/" in raw
+ or "\\browser\\screenshots\\" in raw
+ or "host-browser" in raw
+ or "browser-" in raw
+ ):
+ return "browser"
+ return "vision-load"
+
+
+def category_for_source(source: str) -> ImageCategory:
+ return "screenshots" if source in {"desktop", "browser", "computer-use"} else "images"
+
+
+def _guess_image_mime(path: Path) -> str:
+ import mimetypes
+
+ return mimetypes.guess_type(path.name)[0] or "image/png"
+
+
+def _is_data_image_url(value: str) -> bool:
+ normalized = str(value or "").strip().lower()
+ return normalized.startswith("data:image/") and ";base64," in normalized
+
+
+def _split_image_data_url(data_url: str) -> tuple[str, str]:
+ value = str(data_url or "").strip()
+ if not _is_data_image_url(value) or "," not in value:
+ raise ValueError("image data URL must be data:image/*;base64,...")
+ return value.split(",", 1)
diff --git a/helpers/chat_media.py.dox.md b/helpers/chat_media.py.dox.md
index 9c1dfa81fa854f8c1d58532e48e34fd4ad17d41b..5aa9af9fb857e056a39012dfdd0507cf0ea05af0 100644
--- a/helpers/chat_media.py.dox.md
+++ b/helpers/chat_media.py.dox.md
@@ -1,3 +1,60 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:85cdb7c91c0e3ad73ea21317c4c6bf50b6749ed92830e4f2bbf3fe528297a746
-size 3768
+# chat_media.py DOX
+
+## Purpose
+
+- Own the `chat_media.py` helper module.
+- This module materializes, stores, and resolves chat-scoped image/media artifacts.
+- Keep this file-level DOX profile synchronized with `chat_media.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `chat_media.py` owns the runtime implementation.
+- `chat_media.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `ChatImage` (no explicit base class)
+- Top-level functions:
+- `screenshot_dir(context_id: str, source: str) -> Path`
+- `artifact_dir(context_id: str, category: ImageCategory=..., source: str=...) -> Path`
+- `save_image_bytes(context_id: str, payload: bytes, mime_type: str=..., category: ImageCategory=..., source: str=..., preferred_name: str=..., max_bytes: int | None=...) -> ChatImage`
+- `save_image_base64(context_id: str, data: str, mime_type: str=..., category: ImageCategory=..., source: str=..., preferred_name: str=..., max_bytes: int | None=...) -> ChatImage`
+- `save_image_file(context_id: str, path: str | Path, category: ImageCategory=..., source: str=..., preferred_name: str=..., max_bytes: int | None=...) -> ChatImage`
+- `save_image_data_url(context_id: str, data_url: str, category: ImageCategory=..., source: str=..., preferred_name: str=..., max_bytes: int | None=...) -> ChatImage`
+- `materialize_image_ref(context_id: str, url: str, source: str=..., preferred_name: str=..., max_bytes: int | None=...) -> str`
+- `is_chat_scoped_path(context_id: str, path: str | Path) -> bool`
+- `infer_source(value: str=..., preferred_name: str=...) -> str`
+- `category_for_source(source: str) -> ImageCategory`
+- `_guess_image_mime(path: Path) -> str`
+- `_is_data_image_url(value: str) -> bool`
+- `_split_image_data_url(data_url: str) -> tuple[str, str]`
+- Notable constants/configuration names: `DEFAULT_MAX_IMAGE_BYTES`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion.
+- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `pathlib`, `time`, `typing`, `uuid`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `dataclass`, `artifact_dir`, `bytes`, `media_artifacts.normalize_mime`, `media_artifacts.guess_extension`, `media_artifacts.safe_filename`, `Path`, `time.strftime`, `path.parent.mkdir`, `path.write_bytes`, `ChatImage`, `media_artifacts.decode_base64_payload`, `save_image_bytes`, `image_path.read_bytes`, `_split_image_data_url`, `save_image_base64`, `str.strip`, `category_for_source`, `_is_data_image_url`, `images.resolve_ref`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_host_browser_connector.py`
+ - `tests/test_tool_action_contracts.py`
+ - `tests/test_vision_load_image_refs.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/cli_tunnel.py b/helpers/cli_tunnel.py
index 4196244c84e2c59c41138722034bd53a648b3ca6..a37344c8c352c04f99be345fe11807a194e87e4a 100644
--- a/helpers/cli_tunnel.py
+++ b/helpers/cli_tunnel.py
@@ -1,3 +1,269 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:fd695ba3317849d762fc054e9020460aaeff46428424e2a99fdb5427d5c3e6a9
-size 8944
+import os
+import platform
+import queue
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import threading
+import time
+import urllib.request
+import zipfile
+from collections import deque
+from pathlib import Path
+
+from flaredantic import NotifyEvent
+
+from helpers import files
+from helpers.tunnel_common import TunnelHelper
+
+
+RUNTIME_BIN_DIR = Path(files.get_abs_path("tmp", "bin"))
+
+
+def executable_name(name):
+ return f"{name}.exe" if platform.system().lower() == "windows" else name
+
+
+def chmod_executable(path):
+ if platform.system().lower() != "windows":
+ path.chmod(0o755)
+
+
+def notify_download(notify, message, data=None):
+ if callable(notify):
+ notify(NotifyEvent.DOWNLOADING, message, data)
+
+
+def notify_download_complete(notify, message, data=None):
+ if callable(notify):
+ notify(NotifyEvent.DOWNLOAD_COMPLETE, message, data)
+
+
+def download_file(url, destination, notify=None):
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ notify_download(notify, f"Downloading {url}")
+ temp_fd, temp_name = tempfile.mkstemp(
+ prefix=f"{destination.name}.",
+ suffix=".download",
+ dir=str(destination.parent),
+ )
+ os.close(temp_fd)
+ temp_path = Path(temp_name)
+ try:
+ with urllib.request.urlopen(url, timeout=60) as response:
+ with temp_path.open("wb") as handle:
+ shutil.copyfileobj(response, handle)
+ temp_path.replace(destination)
+ notify_download_complete(notify, f"Downloaded {destination.name}")
+ return destination
+ except Exception:
+ temp_path.unlink(missing_ok=True)
+ raise
+
+
+def platform_parts():
+ system = platform.system().lower()
+ machine = platform.machine().lower()
+ if machine in {"x86_64", "amd64"}:
+ arch = "amd64"
+ elif machine in {"aarch64", "arm64"}:
+ arch = "arm64"
+ elif machine in {"i386", "i686", "x86"}:
+ arch = "386"
+ elif machine.startswith("arm"):
+ arch = "arm"
+ else:
+ raise RuntimeError(f"Unsupported CPU architecture for tunnel binary: {machine}")
+
+ if system not in {"linux", "darwin", "windows"}:
+ raise RuntimeError(f"Unsupported operating system for tunnel binary: {system}")
+ return system, arch
+
+
+def extract_named_members_from_tar(archive_path, destination_dir, member_names):
+ destination_dir.mkdir(parents=True, exist_ok=True)
+ extracted = {}
+ wanted = set(member_names)
+ with tarfile.open(archive_path, "r:*") as archive:
+ for member in archive.getmembers():
+ member_name = Path(member.name).name
+ if member_name not in wanted or not member.isfile():
+ continue
+ target = destination_dir / executable_name(member_name)
+ source = archive.extractfile(member)
+ if source is None:
+ continue
+ with source, target.open("wb") as handle:
+ shutil.copyfileobj(source, handle)
+ chmod_executable(target)
+ extracted[member_name] = target
+ missing = wanted - set(extracted)
+ if missing:
+ raise RuntimeError(
+ f"Archive {archive_path.name} did not contain expected binaries: "
+ f"{', '.join(sorted(missing))}."
+ )
+ return extracted
+
+
+def extract_named_members_from_zip(archive_path, destination_dir, member_names):
+ destination_dir.mkdir(parents=True, exist_ok=True)
+ extracted = {}
+ wanted = set(member_names)
+ with zipfile.ZipFile(archive_path) as archive:
+ for member in archive.infolist():
+ member_name = Path(member.filename).name
+ normalized_name = member_name.removesuffix(".exe")
+ if normalized_name not in wanted or member.is_dir():
+ continue
+ target = destination_dir / executable_name(normalized_name)
+ with archive.open(member) as source, target.open("wb") as handle:
+ shutil.copyfileobj(source, handle)
+ chmod_executable(target)
+ extracted[normalized_name] = target
+ missing = wanted - set(extracted)
+ if missing:
+ raise RuntimeError(
+ f"Archive {archive_path.name} did not contain expected binaries: "
+ f"{', '.join(sorted(missing))}."
+ )
+ return extracted
+
+
+class CliTunnelHelper(TunnelHelper):
+ label = "CLI tunnel"
+
+ def __init__(
+ self,
+ *,
+ label,
+ binary,
+ port,
+ command,
+ url_pattern,
+ missing_binary_message,
+ shutdown_command=None,
+ timeout=30,
+ notify=None,
+ binary_resolver=None,
+ preflight=None,
+ output_handler=None,
+ ):
+ super().__init__(port, notify=notify)
+ self.label = label
+ self.binary = binary
+ self.command = command
+ self.url_pattern = url_pattern
+ self.missing_binary_message = missing_binary_message
+ self.shutdown_command = shutdown_command
+ self.timeout = timeout
+ self.tunnel_process = None
+ self.binary_path = None
+ self.binary_resolver = binary_resolver
+ self.preflight = preflight
+ self.command_prefix = []
+ self.output_handler = output_handler
+
+ def _extract_url(self, line):
+ match = self.url_pattern.search(line)
+ if not match:
+ return None
+ return match.group(1 if match.lastindex else 0).rstrip(".,)")
+
+ def start(self):
+ binary_path = (
+ self.binary_resolver(self._notify)
+ if callable(self.binary_resolver)
+ else shutil.which(self.binary)
+ )
+ if not binary_path:
+ raise RuntimeError(self.missing_binary_message)
+ self.binary_path = binary_path
+ if callable(self.preflight):
+ preflight_result = self.preflight(binary_path, notify=self._notify)
+ if isinstance(preflight_result, dict):
+ self.command_prefix = list(preflight_result.get("command_prefix") or [])
+
+ command = [binary_path, *self.command_prefix, *self.command[1:]]
+ self.notify_starting(self.label)
+ self.tunnel_process = subprocess.Popen(
+ command,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ )
+
+ output_queue = queue.Queue()
+
+ def read_output():
+ if self.tunnel_process is None or self.tunnel_process.stdout is None:
+ return
+ for line in self.tunnel_process.stdout:
+ output_queue.put(line)
+
+ threading.Thread(target=read_output, daemon=True).start()
+
+ deadline = time.monotonic() + self.timeout
+ recent_output = deque(maxlen=8)
+ while time.monotonic() < deadline:
+ try:
+ line = output_queue.get(timeout=0.1)
+ except queue.Empty:
+ if self.tunnel_process.poll() is not None:
+ break
+ continue
+
+ cleaned_line = line.strip()
+ if cleaned_line:
+ recent_output.append(cleaned_line)
+ if callable(self.output_handler):
+ try:
+ self.output_handler(cleaned_line, self._notify)
+ except Exception:
+ pass
+ url = self._extract_url(cleaned_line)
+ if url:
+ self.tunnel_url = url
+ self.notify_url_ready(self.label, url)
+ return self.tunnel_url
+
+ details = " ".join(recent_output)
+ if self.tunnel_process.poll() is not None:
+ raise RuntimeError(
+ f"{self.label} exited before it reported a remote URL."
+ + (f" Output: {details}" if details else "")
+ )
+ self._terminate_process()
+ raise RuntimeError(
+ f"{self.label} did not report a remote URL within {self.timeout} seconds."
+ + (f" Output: {details}" if details else "")
+ )
+
+ def _terminate_process(self):
+ if not self.tunnel_process or self.tunnel_process.poll() is not None:
+ return
+ self.tunnel_process.terminate()
+ try:
+ self.tunnel_process.wait(timeout=8)
+ except subprocess.TimeoutExpired:
+ self.tunnel_process.kill()
+ self.tunnel_process.wait(timeout=3)
+
+ def stop(self):
+ self._terminate_process()
+
+ if self.shutdown_command:
+ binary_path = self.binary_path or shutil.which(self.binary)
+ if binary_path:
+ subprocess.run(
+ [binary_path, *self.command_prefix, *self.shutdown_command[1:]],
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ timeout=10,
+ )
+ self.tunnel_url = None
+ self.notify_stopped(self.label)
+ return True
diff --git a/helpers/cli_tunnel.py.dox.md b/helpers/cli_tunnel.py.dox.md
index 7882d8a964249d3939d9c63a47f883326a3876ed..da8fc4a0c41c0c612281ae97a4fb1bad041708a4 100644
--- a/helpers/cli_tunnel.py.dox.md
+++ b/helpers/cli_tunnel.py.dox.md
@@ -1,3 +1,54 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1223cb6aa135b2d919b343c897d471ca918eda732cece7962334d7d1e08b6665
-size 2891
+# cli_tunnel.py DOX
+
+## Purpose
+
+- Own the `cli_tunnel.py` helper module.
+- This module provides shared download and install mechanics for CLI-based tunnel providers.
+- Keep this file-level DOX profile synchronized with `cli_tunnel.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `cli_tunnel.py` owns the runtime implementation.
+- `cli_tunnel.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `CliTunnelHelper` (`TunnelHelper`)
+ - `start(self)`
+ - `stop(self)`
+- Top-level functions:
+- `executable_name(name)`
+- `chmod_executable(path)`
+- `notify_download(notify, message, data=...)`
+- `notify_download_complete(notify, message, data=...)`
+- `download_file(url, destination, notify=...)`
+- `platform_parts()`
+- `extract_named_members_from_tar(archive_path, destination_dir, member_names)`
+- `extract_named_members_from_zip(archive_path, destination_dir, member_names)`
+- Notable constants/configuration names: `RUNTIME_BIN_DIR`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, network calls, subprocess/runtime control, tunnel state.
+- Imported dependency areas include: `collections`, `flaredantic`, `helpers`, `helpers.tunnel_common`, `os`, `pathlib`, `platform`, `queue`, `shutil`, `subprocess`, `tarfile`, `tempfile`, `threading`, `time`, `urllib.request`, `zipfile`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `Path`, `files.get_abs_path`, `callable`, `destination.parent.mkdir`, `notify_download`, `tempfile.mkstemp`, `os.close`, `platform.system.lower`, `platform.machine.lower`, `destination_dir.mkdir`, `path.chmod`, `notify`, `temp_path.replace`, `notify_download_complete`, `RuntimeError`, `archive.getmembers`, `zipfile.ZipFile`, `archive.infolist`, `super.__init__`, `self.url_pattern.search`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_tunnel_remote_link.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/cloudflare_tunnel._py b/helpers/cloudflare_tunnel._py
index 4d782af19249d0355984c8d3701ef871226ab320..3d0e825f21d407696737e9df30e278c4c4b2022f 100644
--- a/helpers/cloudflare_tunnel._py
+++ b/helpers/cloudflare_tunnel._py
@@ -1,3 +1,157 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:72fc28c5e6b9508111f3be5aeae12a1642ae588b08839ee7b4a5e0aedc5e5cec
-size 6236
+import os
+import platform
+import requests
+import subprocess
+import threading
+from helpers import files
+from helpers.print_style import PrintStyle
+
+class CloudflareTunnel:
+ def __init__(self, port: int):
+ self.port = port
+ self.bin_dir = "tmp" # Relative path
+ self.cloudflared_path = None
+ self.tunnel_process = None
+ self.tunnel_url = None
+ self._stop_event = threading.Event()
+
+ def download_cloudflared(self):
+ """Downloads the appropriate cloudflared binary for the current system"""
+ # Create bin directory if it doesn't exist using files helper
+ os.makedirs(files.get_abs_path(self.bin_dir), exist_ok=True)
+
+ # Determine OS and architecture
+ system = platform.system().lower()
+ arch = platform.machine().lower()
+
+ # Define executable name
+ executable_name = "cloudflared.exe" if system == "windows" else "cloudflared"
+ install_path = files.get_abs_path(self.bin_dir, executable_name)
+
+ # Return if already exists
+ if files.exists(self.bin_dir, executable_name):
+ self.cloudflared_path = install_path
+ return install_path
+
+ # Map platform/arch to download URLs
+ base_url = "https://github.com/cloudflare/cloudflared/releases/latest/download/"
+
+ if system == "darwin": # macOS
+ # Download and extract .tgz for macOS
+ download_file = "cloudflared-darwin-amd64.tgz" if arch == "x86_64" else "cloudflared-darwin-arm64.tgz"
+ download_url = f"{base_url}{download_file}"
+ download_path = files.get_abs_path(self.bin_dir, download_file)
+
+ PrintStyle().print(f"\nDownloading cloudflared from: {download_url}")
+ response = requests.get(download_url, stream=True)
+ if response.status_code != 200:
+ raise RuntimeError(f"Failed to download cloudflared: {response.status_code}")
+
+ # Save the .tgz file
+ with open(download_path, "wb") as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ f.write(chunk)
+
+ # Extract cloudflared binary from .tgz
+ import tarfile
+ with tarfile.open(download_path, "r:gz") as tar:
+ tar.extract("cloudflared", files.get_abs_path(self.bin_dir))
+
+ # Cleanup .tgz file
+ os.remove(download_path)
+
+ else: # Linux and Windows
+ if system == "linux":
+ if arch in ["x86_64", "amd64"]:
+ download_file = "cloudflared-linux-amd64"
+ elif arch == "arm64" or arch == "aarch64":
+ download_file = "cloudflared-linux-arm64"
+ elif arch == "arm":
+ download_file = "cloudflared-linux-arm"
+ else:
+ download_file = "cloudflared-linux-386"
+ elif system == "windows":
+ download_file = "cloudflared-windows-amd64.exe"
+ else:
+ raise RuntimeError(f"Unsupported platform: {system} {arch}")
+
+ download_url = f"{base_url}{download_file}"
+ download_path = files.get_abs_path(self.bin_dir, download_file)
+
+ PrintStyle().print(f"\nDownloading cloudflared from: {download_url}")
+ response = requests.get(download_url, stream=True)
+ if response.status_code != 200:
+ raise RuntimeError(f"Failed to download cloudflared: {response.status_code}")
+
+ with open(download_path, "wb") as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ f.write(chunk)
+
+
+ # Rename and set permissions
+ if os.path.exists(install_path):
+ os.remove(install_path)
+ os.rename(download_path, install_path)
+
+ # Set executable permissions
+ if system != "windows":
+ os.chmod(install_path, 0o755)
+
+ self.cloudflared_path = install_path
+ return install_path
+
+ def _extract_tunnel_url(self, process):
+ """Extracts the tunnel URL from cloudflared output"""
+ while not self._stop_event.is_set():
+ line = process.stdout.readline()
+ if not line:
+ break
+
+ if isinstance(line, bytes):
+ line = line.decode('utf-8')
+
+ if "trycloudflare.com" in line and "https://" in line:
+ start = line.find("https://")
+ end = line.find("trycloudflare.com") + len("trycloudflare.com")
+ self.tunnel_url = line[start:end].strip()
+ PrintStyle().print("\n=== Cloudflare Tunnel URL ===")
+ PrintStyle().print(f"URL: {self.tunnel_url}")
+ PrintStyle().print("============================\n")
+ return
+
+ def start(self):
+ """Starts Cloudflare Tunnel"""
+ if not self.cloudflared_path:
+ self.download_cloudflared()
+
+ PrintStyle().print("\nStarting Cloudflare Tunnel...")
+ # Start tunnel process
+ self.tunnel_process = subprocess.Popen(
+ [
+ str(self.cloudflared_path),
+ "tunnel",
+ "--url",
+ f"http://localhost:{self.port}"
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ bufsize=1,
+ universal_newlines=True
+ )
+
+ # Extract tunnel URL in separate thread
+ threading.Thread(
+ target=self._extract_tunnel_url,
+ args=(self.tunnel_process,),
+ daemon=True
+ ).start()
+
+ def stop(self):
+ """Stops Cloudflare Tunnel"""
+ self._stop_event.set()
+ if self.tunnel_process:
+ PrintStyle().print("\nStopping Cloudflare Tunnel...")
+ self.tunnel_process.terminate()
+ self.tunnel_process.wait()
+ self.tunnel_process = None
+ self.tunnel_url = None
diff --git a/helpers/cloudflare_tunnel.py b/helpers/cloudflare_tunnel.py
index dbaf4a7d9332dcb5e6a3e8a4131e1052558bc984..ba446f32a9c6a9d6e8100790b893f5b53f335e37 100644
--- a/helpers/cloudflare_tunnel.py
+++ b/helpers/cloudflare_tunnel.py
@@ -1,3 +1,11 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5815a980d3bf50c48f67db92cde267bd1e62568dcb6326b12757c9edd093506d
-size 314
+from flaredantic import FlareConfig, FlareTunnel
+
+from helpers.tunnel_common import FlaredanticTunnelHelper
+
+
+class CloudflareTunnel(FlaredanticTunnelHelper):
+ label = "Cloudflare Tunnel"
+
+ def build_tunnel(self):
+ config = FlareConfig(port=self.port, verbose=True)
+ return FlareTunnel(config)
diff --git a/helpers/cloudflare_tunnel.py.dox.md b/helpers/cloudflare_tunnel.py.dox.md
index f9b99e3dfd554b14701cf65cdb0c27a8bb048727..e2172dcae299c437bab887f8ae332c9a2ee01e70 100644
--- a/helpers/cloudflare_tunnel.py.dox.md
+++ b/helpers/cloudflare_tunnel.py.dox.md
@@ -1,3 +1,43 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:50c9e16330bb3daf1296d5a5ff23506283d2f8ab82facbebda098dfac5a027a3
-size 1843
+# cloudflare_tunnel.py DOX
+
+## Purpose
+
+- Own the `cloudflare_tunnel.py` helper module.
+- This module implements Cloudflare tunnel provider lifecycle behavior.
+- Keep this file-level DOX profile synchronized with `cloudflare_tunnel.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `cloudflare_tunnel.py` owns the runtime implementation.
+- `cloudflare_tunnel.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `CloudflareTunnel` (`FlaredanticTunnelHelper`)
+ - `build_tunnel(self)`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: settings/state persistence, tunnel state.
+- Imported dependency areas include: `flaredantic`, `helpers.tunnel_common`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `FlareConfig`, `FlareTunnel`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_tunnel_remote_link.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/context.py b/helpers/context.py
index c6e7b6915d8bf544d55cbf90c795049221f0abb0..2dc5609f0e256869e3c583ee41ff5a819954433c 100644
--- a/helpers/context.py
+++ b/helpers/context.py
@@ -1,3 +1,46 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:755e57bc8686ac4be08dc50dfa4f8d4c386dea7d56c77c2b828a2e8ac1257827
-size 1269
+from contextvars import ContextVar
+from typing import Any, TypeVar, cast, Optional, Dict
+
+T = TypeVar("T")
+
+# no mutable default — None is safe
+_context_data: ContextVar[Optional[Dict[str, Any]]] = ContextVar("_context_data", default=None)
+
+
+def _ensure_context() -> Dict[str, Any]:
+ """Make sure a context dict exists, and return it."""
+ data = _context_data.get()
+ if data is None:
+ data = {}
+ _context_data.set(data)
+ return data
+
+
+def set_context_data(key: str, value: Any):
+ """Set context data for the current async/task context."""
+ data = _ensure_context()
+ if data.get(key) == value:
+ return
+ data[key] = value
+ _context_data.set(data)
+
+
+def delete_context_data(key: str):
+ """Delete a key from the current async/task context."""
+ data = _ensure_context()
+ if key in data:
+ del data[key]
+ _context_data.set(data)
+
+
+def get_context_data(key: Optional[str] = None, default: T = None) -> T:
+ """Get a key from the current context, or the full dict if key is None."""
+ data = _ensure_context()
+ if key is None:
+ return cast(T, data)
+ return cast(T, data.get(key, default))
+
+
+def clear_context_data():
+ """Completely clear the context dict."""
+ _context_data.set({})
diff --git a/helpers/context.py.dox.md b/helpers/context.py.dox.md
index b6011413606b3a30900faaa3a7a7a5332bd37e46..8b6e76a510080d1f105b7be411f24a0d24fb2659 100644
--- a/helpers/context.py.dox.md
+++ b/helpers/context.py.dox.md
@@ -1,3 +1,54 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9ce49ffd9b194a03fb418e1c55abbcf32a40786e6bed53e66f236685c45168d5
-size 2562
+# context.py DOX
+
+## Purpose
+
+- Own the `context.py` helper module.
+- This module provides compatibility helpers for reading and writing `AgentContext` data.
+- Keep this file-level DOX profile synchronized with `context.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `context.py` owns the runtime implementation.
+- `context.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `_ensure_context() -> Dict[str, Any]`: Make sure a context dict exists, and return it.
+- `set_context_data(key: str, value: Any)`: Set context data for the current async/task context.
+- `delete_context_data(key: str)`: Delete a key from the current async/task context.
+- `get_context_data(key: Optional[str]=..., default: T=...) -> T`: Get a key from the current context, or the full dict if key is None.
+- `clear_context_data()`: Completely clear the context dict.
+- Notable constants/configuration names: `T`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem deletion, scheduler state.
+- Imported dependency areas include: `contextvars`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `TypeVar`, `ContextVar`, `_ensure_context`, `cast`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_a0_connector_prompt_gating.py`
+ - `tests/test_api_chat_lifetime.py`
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_document_query_plugin.py`
+ - `tests/test_error_retry_plugin.py`
+ - `tests/test_extensions_stress.py`
+ - `tests/test_file_tree_visualize.py`
+ - `tests/test_history_compression_wait.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/context_utils.py b/helpers/context_utils.py
index 41e8d96b910148ad9b8c5e9c7e1b64e4df822a23..bb48d7cbde92a1e39d428fb93eb6d3283b9b6930 100644
--- a/helpers/context_utils.py
+++ b/helpers/context_utils.py
@@ -1,3 +1,36 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:278cc3e0fa95dcddee3ad3283f9852f43d180422b9a6240a50c3713dda3a223e
-size 1214
+"""Shared context helper used by both ApiHandler and WsHandler."""
+
+import threading
+from typing import Union
+
+ThreadLockType = Union[threading.Lock, threading.RLock]
+
+
+def use_context(lock: ThreadLockType, ctxid: str, create_if_not_exists: bool = True):
+ from agent import AgentContext
+ from helpers import projects
+ from initialize import initialize_agent
+
+ with lock:
+ if not ctxid:
+ first = AgentContext.first()
+ if first:
+ AgentContext.use(first.id)
+ return first
+ context = AgentContext(config=initialize_agent(), set_current=True)
+ projects.reconcile_agent_profile(
+ context, projects.get_context_project_name(context)
+ )
+ return context
+ got = AgentContext.use(ctxid)
+ if got:
+ return got
+ if create_if_not_exists:
+ context = AgentContext(
+ config=initialize_agent(), id=ctxid, set_current=True
+ )
+ projects.reconcile_agent_profile(
+ context, projects.get_context_project_name(context)
+ )
+ return context
+ raise Exception(f"Context {ctxid} not found")
diff --git a/helpers/context_utils.py.dox.md b/helpers/context_utils.py.dox.md
index aaf84870b0618c4fb14b56de4358e63de5b4ca85..da640f1a0e5aad1cff12954c22bd56520c41d1fd 100644
--- a/helpers/context_utils.py.dox.md
+++ b/helpers/context_utils.py.dox.md
@@ -1,3 +1,45 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:4e82222db0fe90289da9863bae6978db2b7357718097c2848901331ce3d1242f
-size 2220
+# context_utils.py DOX
+
+## Purpose
+
+- Own the `context_utils.py` helper module.
+- This module provides context managers/utilities for current context binding.
+- Keep this file-level DOX profile synchronized with `context_utils.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `context_utils.py` owns the runtime implementation.
+- `context_utils.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `use_context(lock: ThreadLockType, ctxid: str, create_if_not_exists: bool=...)`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: WebSocket state, settings/state persistence.
+- Imported dependency areas include: `threading`, `typing`.
+
+## Key Concepts
+
+- Newly constructed contexts reconcile their active profile against Global
+ availability before returning. Existing context lookups return the stored
+ context unchanged; explicit profile and project transitions own any repair.
+- Important called helpers/classes observed in the source: `AgentContext.use`, `AgentContext.first`, `AgentContext`, `Exception`, `initialize_agent`, `projects.get_context_project_name`, `projects.reconcile_agent_profile`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- `tests/test_projects.py` verifies that lookup is read-only while context
+ creation reconciles once.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/crypto.py b/helpers/crypto.py
index c0efbd24d8e020e415eb849d7e37652a500858ba..a4fe8f209540ae1af97d6b7e1e7acfeb20ca6de5 100644
--- a/helpers/crypto.py
+++ b/helpers/crypto.py
@@ -1,3 +1,66 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5a334715303820916f5c92e9b7cf7a2150f39a120b6d1e2f89bef2ede9862f55
-size 1885
+import hashlib
+import hmac
+from cryptography.hazmat.primitives.asymmetric import rsa, padding
+from cryptography.hazmat.primitives import serialization, hashes
+import os
+
+
+def hash_data(data: str, password: str):
+ return hmac.new(password.encode(), data.encode(), hashlib.sha256).hexdigest()
+
+
+def verify_data(data: str, hash: str, password: str):
+ return hash_data(data, password) == hash
+
+
+def _generate_private_key():
+ return rsa.generate_private_key(
+ public_exponent=65537,
+ key_size=2048,
+ )
+
+
+def _generate_public_key(private_key: rsa.RSAPrivateKey):
+ return (
+ private_key.public_key()
+ .public_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
+ )
+ .hex()
+ )
+
+def _decode_public_key(public_key: str) -> rsa.RSAPublicKey:
+ # Decode hex string back to bytes
+ pem_bytes = bytes.fromhex(public_key)
+ # Load the PEM public key
+ key = serialization.load_pem_public_key(pem_bytes)
+ if not isinstance(key, rsa.RSAPublicKey):
+ raise TypeError("The provided key is not an RSAPublicKey")
+ return key
+
+def encrypt_data(data: str, public_key_pem: str):
+ return _encrypt_data(data.encode("utf-8"), _decode_public_key(public_key_pem))
+
+def _encrypt_data(data: bytes, public_key: rsa.RSAPublicKey):
+ b = public_key.encrypt(
+ data,
+ padding.OAEP(
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
+ algorithm=hashes.SHA256(),
+ label=None,
+ ),
+ )
+ return b.hex()
+
+def decrypt_data(data: str, private_key: rsa.RSAPrivateKey):
+ b = private_key.decrypt(
+ bytes.fromhex(data),
+ padding.OAEP(
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
+ algorithm=hashes.SHA256(),
+ label=None,
+ ),
+ )
+ return b.decode("utf-8")
+
diff --git a/helpers/crypto.py.dox.md b/helpers/crypto.py.dox.md
index 2319b767548cb2d1ccf6b1ea7b0ebe22dc68df5c..a66e730cafae3dd9f708f6906d06fd26adfa965e 100644
--- a/helpers/crypto.py.dox.md
+++ b/helpers/crypto.py.dox.md
@@ -1,3 +1,48 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1ccac5d8269c09bf96d2a936e2a0a4cf0b69f084bb756542fd043599e7ed4dea
-size 2617
+# crypto.py DOX
+
+## Purpose
+
+- Own the `crypto.py` helper module.
+- This module hashes, verifies, encrypts, and decrypts data for signed or protected payloads.
+- Keep this file-level DOX profile synchronized with `crypto.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `crypto.py` owns the runtime implementation.
+- `crypto.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `hash_data(data: str, password: str)`
+- `verify_data(data: str, hash: str, password: str)`
+- `_generate_private_key()`
+- `_generate_public_key(private_key: rsa.RSAPrivateKey)`
+- `_decode_public_key(public_key: str) -> rsa.RSAPublicKey`
+- `encrypt_data(data: str, public_key_pem: str)`
+- `_encrypt_data(data: bytes, public_key: rsa.RSAPublicKey)`
+- `decrypt_data(data: str, private_key: rsa.RSAPrivateKey)`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: secret handling.
+- Imported dependency areas include: `cryptography.hazmat.primitives`, `cryptography.hazmat.primitives.asymmetric`, `hashlib`, `hmac`, `os`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `hmac.new.hexdigest`, `rsa.generate_private_key`, `private_key.public_key.public_bytes.hex`, `bytes.fromhex`, `serialization.load_pem_public_key`, `_encrypt_data`, `public_key.encrypt`, `b.hex`, `private_key.decrypt`, `b.decode`, `hash_data`, `TypeError`, `data.encode`, `_decode_public_key`, `padding.OAEP`, `hmac.new`, `private_key.public_key.public_bytes`, `password.encode`, `padding.MGF1`, `hashes.SHA256`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/defer.py b/helpers/defer.py
index c928b138c018060786b4503ad757219de7d3c5b4..07118f9a4c426b7ef974c643af7ec5de28d33bfd 100644
--- a/helpers/defer.py
+++ b/helpers/defer.py
@@ -1,3 +1,267 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:2d4298b5ada8692a65b632160168b22da4b41509bce51bfed2b9735324326a05
-size 9002
+import asyncio
+from dataclasses import dataclass
+import threading
+from concurrent.futures import Future, InvalidStateError
+from typing import Any, Callable, Optional, Coroutine, TypeVar, Awaitable
+
+T = TypeVar("T")
+
+THREAD_BACKGROUND = "Background"
+
+
+class EventLoopThread:
+ _instances: dict[str, "EventLoopThread"] = {}
+ _lock = threading.Lock()
+
+ def __init__(self, thread_name: str = THREAD_BACKGROUND) -> None:
+ """Initialize the event loop thread."""
+ self.thread_name = thread_name
+ self._start()
+
+ def __new__(cls, thread_name: str = THREAD_BACKGROUND):
+ with cls._lock:
+ if thread_name not in cls._instances:
+ instance = super(EventLoopThread, cls).__new__(cls)
+ cls._instances[thread_name] = instance
+ return cls._instances[thread_name]
+
+ def _start(self):
+ if not hasattr(self, "loop") or not self.loop:
+ self.loop = asyncio.new_event_loop()
+ if not hasattr(self, "thread") or not self.thread:
+ self.thread = threading.Thread(
+ target=self._run_event_loop, daemon=True, name=self.thread_name
+ )
+ self.thread.start()
+
+ def _run_event_loop(self):
+ if not self.loop:
+ raise RuntimeError("Event loop is not initialized")
+ asyncio.set_event_loop(self.loop)
+ self.loop.run_forever()
+
+ def terminate(self):
+ loop = getattr(self, "loop", None)
+ thread = getattr(self, "thread", None)
+
+ if not loop:
+ return
+
+ if loop.is_running():
+ if thread and thread is threading.current_thread():
+ loop.stop()
+ else:
+ loop.call_soon_threadsafe(loop.stop)
+ if thread:
+ thread.join()
+ elif thread and thread.is_alive() and thread is not threading.current_thread():
+ thread.join()
+
+ if not loop.is_closed():
+ loop.close()
+
+ with self.__class__._lock:
+ if self.thread_name in self.__class__._instances:
+ del self.__class__._instances[self.thread_name]
+
+ self.loop = None
+ self.thread = None
+
+ def run_coroutine(self, coro):
+ self._start()
+ if not self.loop:
+ raise RuntimeError("Event loop is not initialized")
+ return asyncio.run_coroutine_threadsafe(coro, self.loop)
+
+
+@dataclass
+class ChildTask:
+ task: "DeferredTask"
+ terminate_thread: bool
+
+
+class DeferredTask:
+ def __init__(
+ self,
+ thread_name: str = THREAD_BACKGROUND,
+ ):
+ self.event_loop_thread = EventLoopThread(thread_name)
+ self._future: Optional[Future] = None
+ self.children: list[ChildTask] = []
+ self.func: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None
+ self.args: tuple[Any, ...] = ()
+ self.kwargs: dict[str, Any] = {}
+
+ def start_task(
+ self, func: Callable[..., Coroutine[Any, Any, Any]], *args: Any, **kwargs: Any
+ ):
+ self.func = func
+ self.args = args
+ self.kwargs = kwargs
+ self._start_task()
+ return self
+
+ def add_done_callback(self, callback: Callable[[Future], Any]) -> None:
+ if not self._future:
+ raise RuntimeError("Task hasn't been started")
+ self._future.add_done_callback(callback)
+
+ def __del__(self):
+ self.kill()
+
+ def _start_task(self):
+ if self.func is None:
+ raise RuntimeError("Task callable is no longer available")
+
+ self._future = self.event_loop_thread.run_coroutine(
+ self._run(self.func, self.args, self.kwargs)
+ )
+ if self._future:
+ self._future.add_done_callback(self._on_task_done)
+
+ def _on_task_done(self, future: Future):
+ # Ensure child background tasks are always cleaned up once the parent finishes
+ if future is self._future:
+ self.kill_children()
+ self._clear_call()
+
+ def _clear_call(self) -> None:
+ self.func = None
+ self.args = ()
+ self.kwargs = {}
+
+ @staticmethod
+ async def _run(func, args, kwargs):
+ return await func(*args, **kwargs)
+
+ def is_ready(self) -> bool:
+ return self._future.done() if self._future else False
+
+ def result_sync(self, timeout: Optional[float] = None) -> Any:
+ if not self._future:
+ raise RuntimeError("Task hasn't been started")
+ try:
+ return self._future.result(timeout)
+ except TimeoutError:
+ raise TimeoutError(
+ "The task did not complete within the specified timeout."
+ )
+
+ async def result(self, timeout: Optional[float] = None) -> Any:
+ if not self._future:
+ raise RuntimeError("Task hasn't been started")
+
+ loop = asyncio.get_running_loop()
+
+ def _get_result():
+ try:
+ result = self._future.result(timeout) # type: ignore
+ # self.kill()
+ return result
+ except TimeoutError:
+ raise TimeoutError(
+ "The task did not complete within the specified timeout."
+ )
+
+ return await loop.run_in_executor(None, _get_result)
+
+ def kill(self, terminate_thread: bool = False) -> None:
+ """Kill the task and optionally terminate its thread."""
+ self.kill_children()
+ if self._future and not self._future.done():
+ self._future.cancel()
+ self._clear_call()
+
+ if terminate_thread and self.event_loop_thread.loop:
+ if self.event_loop_thread.loop.is_running():
+ try:
+ cleanup_future = asyncio.run_coroutine_threadsafe(
+ self._drain_event_loop_tasks(), self.event_loop_thread.loop
+ )
+ cleanup_future.result()
+ except Exception:
+ pass
+
+ self.event_loop_thread.terminate()
+
+ def kill_children(self) -> None:
+ for child in self.children:
+ child.task.kill(terminate_thread=child.terminate_thread)
+ self.children = []
+
+ def is_alive(self) -> bool:
+ return self._future and not self._future.done() # type: ignore
+
+ def restart(self, terminate_thread: bool = False) -> None:
+ if self.func is None:
+ raise RuntimeError("Completed task cannot be restarted")
+ func, args, kwargs = self.func, self.args, self.kwargs
+ self.kill(terminate_thread=terminate_thread)
+ self.start_task(func, *args, **kwargs)
+
+ def add_child_task(
+ self, task: "DeferredTask", terminate_thread: bool = False
+ ) -> None:
+ self.children.append(ChildTask(task, terminate_thread))
+
+ async def _execute_in_task_context(
+ self, func: Callable[..., T], *args, **kwargs
+ ) -> T:
+ """Execute a function in the task's context and return its result."""
+ result = func(*args, **kwargs)
+ if asyncio.iscoroutine(result):
+ return await result
+ return result
+
+ def execute_inside(self, func: Callable[..., T], *args, **kwargs) -> Awaitable[T]:
+ if not self.event_loop_thread.loop:
+ raise RuntimeError("Event loop is not initialized")
+
+ future: Future = Future()
+
+ def set_result(result: Any) -> None:
+ try:
+ future.set_result(result)
+ except InvalidStateError:
+ pass
+
+ def set_exception(exception: BaseException) -> None:
+ try:
+ future.set_exception(exception)
+ except InvalidStateError:
+ pass
+
+ async def wrapped():
+ if not self.event_loop_thread.loop:
+ raise RuntimeError("Event loop is not initialized")
+ try:
+ result = await self._execute_in_task_context(func, *args, **kwargs)
+ # Keep awaiting until we get a concrete value
+ while isinstance(result, Awaitable):
+ result = await result
+ self.event_loop_thread.loop.call_soon_threadsafe(
+ set_result, result
+ )
+ except Exception as e:
+ self.event_loop_thread.loop.call_soon_threadsafe(
+ set_exception, e
+ )
+
+ asyncio.run_coroutine_threadsafe(wrapped(), self.event_loop_thread.loop)
+ return asyncio.wrap_future(future)
+
+ @staticmethod
+ async def _drain_event_loop_tasks():
+ """Cancel and await all pending tasks on the current event loop."""
+ loop = asyncio.get_running_loop()
+ current_task = asyncio.current_task(loop=loop)
+ pending = [
+ task
+ for task in asyncio.all_tasks(loop=loop)
+ if task is not current_task
+ ]
+ if not pending:
+ return
+ for task in pending:
+ task.cancel()
+ await asyncio.gather(*pending, return_exceptions=True)
diff --git a/helpers/defer.py.dox.md b/helpers/defer.py.dox.md
index ca67ce3b35040627d005078a02edd1bcc1f32e09..103435d10406fb99be84222a38e213dd6c0f368f 100644
--- a/helpers/defer.py.dox.md
+++ b/helpers/defer.py.dox.md
@@ -1,3 +1,59 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:97d82c246c3b0f014201a29e5fe526e6ef8b2fd81d64ae47c04fc3f23a996685
-size 3422
+# defer.py DOX
+
+## Purpose
+
+- Own the `defer.py` helper module.
+- This module runs deferred or child async tasks on managed event-loop threads.
+- Keep this file-level DOX profile synchronized with `defer.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `defer.py` owns the runtime implementation.
+- `defer.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `EventLoopThread` (no explicit base class)
+ - `terminate(self)`
+ - `run_coroutine(self, coro)`
+- `ChildTask` (no explicit base class)
+- `DeferredTask` (no explicit base class)
+ - `start_task(self, func: Callable[..., Coroutine[Any, Any, Any]], *args, **kwargs)`
+ - `add_done_callback(self, callback: Callable[[Future], Any]) -> None`
+ - `is_ready(self) -> bool`
+ - `result_sync(self, timeout: Optional[float]=...) -> Any`
+ - `async result(self, timeout: Optional[float]=...) -> Any`
+ - `kill(self, terminate_thread: bool=...) -> None`
+ - `kill_children(self) -> None`
+ - `is_alive(self) -> bool`
+ - `restart(self, terminate_thread: bool=...) -> None`
+- Notable constants/configuration names: `T`, `THREAD_BACKGROUND`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- `DeferredTask` retains its callable and arguments only while an invocation is active; completion and `kill()` clear those references after the running coroutine has taken its own snapshot.
+- `add_done_callback()` forwards to the current invocation's concurrent future and rejects calls before `start_task()`; callbacks observe `is_alive() == False` and must remain lightweight.
+- Task results remain available after completion. `restart()` can restart an active invocation, but a completed invocation has no retained call recipe and must be started again explicitly.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: scheduler state.
+- Imported dependency areas include: `asyncio`, `concurrent.futures`, `dataclasses`, `threading`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `TypeVar`, `threading.Lock`, `self._start`, `asyncio.set_event_loop`, `self.loop.run_forever`, `loop.is_running`, `asyncio.run_coroutine_threadsafe`, `EventLoopThread`, `self._start_task`, `self.kill`, `self.event_loop_thread.run_coroutine`, `self.kill_children`, `asyncio.get_running_loop`, `func`, `asyncio.iscoroutine`, `Future`, `asyncio.wrap_future`, `asyncio.current_task`, `asyncio.new_event_loop`, `threading.Thread`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_office_document_store.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/dirty_json.py b/helpers/dirty_json.py
index b25c8bfc6b52f48ea53fbc8ba02b0412634a502a..8e7b210124bce1649bd44cd0e3a735e6913d92aa 100644
--- a/helpers/dirty_json.py
+++ b/helpers/dirty_json.py
@@ -1,3 +1,421 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:69c67ca445ba8bd9f6a217d0304fca50418459c06cf9fa5a15cca992637570d1
-size 14539
+import json
+
+def try_parse(json_string: str):
+ try:
+ return json.loads(json_string)
+ except json.JSONDecodeError:
+ return DirtyJson.parse_string(json_string)
+
+
+def parse(json_string: str):
+ return DirtyJson.parse_string(json_string)
+
+
+def stringify(obj, **kwargs):
+ return json.dumps(obj, ensure_ascii=False, **kwargs)
+
+
+class DirtyJson:
+ def __init__(self):
+ self._reset()
+
+ def _reset(self):
+ self.json_string = ""
+ self.index = 0
+ self.current_char = None
+ self.result = None
+ self.stack = []
+ self.completed = False
+ self._parsing_started = False
+
+ def _pop_stack(self, root_closed: bool = False):
+ """Pop from the parsing stack and mark completed only on an explicit root close."""
+ self.stack.pop()
+ if root_closed and self._parsing_started and not self.stack:
+ self.completed = True
+
+ @staticmethod
+ def parse_string(json_string):
+ parser = DirtyJson()
+ return parser.parse(json_string)
+
+ def parse(self, json_string):
+ self._reset()
+ self.json_string = json_string
+
+ # Add bounds checking to prevent IndexError
+ if not json_string:
+ # Return None for empty strings
+ return None
+
+ self.index = self.get_start_pos(self.json_string)
+
+ # Ensure index is within bounds
+ if self.index >= len(self.json_string):
+ # If start position is beyond string length, return None
+ return None
+
+ self.current_char = self.json_string[self.index]
+ self._parse()
+ return self.result
+
+ def feed(self, chunk):
+ self.json_string += chunk
+ if not self.current_char and self.json_string:
+ self.current_char = self.json_string[0]
+ self._parse()
+ return self.result
+
+ def _advance(self, count=1):
+ self.index += count
+ if self.index < len(self.json_string):
+ self.current_char = self.json_string[self.index]
+ else:
+ self.current_char = None
+
+ def _skip_whitespace(self):
+ while self.current_char is not None:
+ if self.current_char.isspace():
+ self._advance()
+ elif (
+ self.current_char == "/" and self._peek(1) == "/"
+ ): # Single-line comment
+ self._skip_single_line_comment()
+ elif (
+ self.current_char == "/" and self._peek(1) == "*"
+ ): # Multi-line comment
+ self._skip_multi_line_comment()
+ else:
+ break
+
+ def _skip_single_line_comment(self):
+ while self.current_char is not None and self.current_char != "\n":
+ self._advance()
+ if self.current_char == "\n":
+ self._advance()
+
+ def _skip_multi_line_comment(self):
+ self._advance(2) # Skip /*
+ while self.current_char is not None:
+ if self.current_char == "*" and self._peek(1) == "/":
+ self._advance(2) # Skip */
+ break
+ self._advance()
+
+ def _parse(self):
+ if self.completed and not self.stack:
+ return
+ if self.result is None:
+ self.result = self._parse_value()
+ else:
+ self._continue_parsing()
+
+ def _continue_parsing(self):
+ while self.current_char is not None:
+ if self.completed and not self.stack:
+ return
+ if isinstance(self.result, dict):
+ self._parse_object_content()
+ elif isinstance(self.result, list):
+ self._parse_array_content()
+ elif isinstance(self.result, str):
+ self.result = self._parse_string()
+ else:
+ break
+
+ def _parse_value(self):
+ self._skip_whitespace()
+ if self.current_char == "{":
+ # Only treat doubled braces as a wrapper at the root; nested objects
+ # must keep their closing braces paired correctly.
+ if not self.stack and self._peek(1) == "{": # Handle {{
+ self._advance(2)
+ return self._parse_object()
+ elif self.current_char == "[":
+ return self._parse_array()
+ elif self.current_char in ['"', "'", "`"]:
+ if self._peek(2) == self.current_char * 2: # type: ignore
+ return self._parse_multiline_string()
+ return self._parse_string()
+ elif self.current_char and (
+ self.current_char.isdigit() or self.current_char in ["-", "+"]
+ ):
+ return self._parse_number()
+ elif self._match("true"):
+ return True
+ elif self._match("false"):
+ return False
+ elif self._match("null") or self._match("undefined"):
+ return None
+ elif self.current_char:
+ return self._parse_unquoted_string()
+ return None
+
+ def _match(self, text: str) -> bool:
+ # first char should match current char
+ if not self.current_char or self.current_char.lower() != text[0].lower():
+ return False
+
+ # peek remaining chars
+ remaining = len(text) - 1
+ if self._peek(remaining).lower() == text[1:].lower():
+ self._advance(len(text))
+ return True
+ return False
+
+ def _parse_object(self):
+ obj = {}
+ self._advance() # Skip opening brace
+ self.stack.append(obj)
+ self._parsing_started = True
+ self._parse_object_content()
+ return obj
+
+ def _parse_object_content(self):
+ while self.current_char is not None:
+ self._skip_whitespace()
+ if self.current_char == "}":
+ # Root-level wrapper outputs may end in "}}"; nested objects must
+ # still close one brace at a time.
+ if len(self.stack) == 1 and self._peek(1) == "}": # Handle }}
+ self._advance(2)
+ else:
+ self._advance()
+ self._pop_stack(root_closed=True)
+ return
+ if self.current_char is None:
+ self._pop_stack()
+ return # End of input reached while parsing object
+
+ key = self._parse_key()
+ value = None
+ self._skip_whitespace()
+
+ if self.current_char == ":":
+ self._advance()
+ value = self._parse_value()
+ elif self.current_char is None:
+ value = None # End of input reached after key
+ else:
+ value = self._parse_value()
+
+ self.stack[-1][key] = value
+
+ self._skip_whitespace()
+ if self.current_char == ",":
+ self._advance()
+ continue
+ elif self.current_char != "}":
+ if self.current_char is None:
+ self._pop_stack()
+ return # End of input reached after value
+ continue
+
+ def _parse_key(self):
+ self._skip_whitespace()
+ if self.current_char in ['"', "'"]:
+ return self._parse_string(is_key=True)
+ else:
+ return self._parse_unquoted_key()
+
+ def _parse_unquoted_key(self):
+ result = ""
+ while (
+ self.current_char is not None
+ and not self.current_char.isspace()
+ and self.current_char not in [":", ",", "}", "]"]
+ ):
+ result += self.current_char
+ self._advance()
+ return result
+
+ def _parse_array(self):
+ arr = []
+ self._advance() # Skip opening bracket
+ self.stack.append(arr)
+ self._parsing_started = True
+ self._parse_array_content()
+ return arr
+
+ def _parse_array_content(self):
+ while self.current_char is not None:
+ self._skip_whitespace()
+ if self.current_char == "]":
+ self._advance()
+ self._pop_stack(root_closed=True)
+ return
+ value = self._parse_value()
+ self.stack[-1].append(value)
+ self._skip_whitespace()
+ if self.current_char == ",":
+ self._advance()
+ # handle trailing commas, end of array
+ self._skip_whitespace()
+ if self.current_char is None or self.current_char == "]":
+ if self.current_char == "]":
+ self._advance()
+ self._pop_stack(root_closed=True)
+ return
+ elif self.current_char != "]":
+ self._pop_stack()
+ return
+
+ def _parse_string(self, is_key: bool = False):
+ result = ""
+ quote_char = self.current_char
+ self._advance() # Skip opening quote
+ while self.current_char is not None:
+ if self.current_char == quote_char:
+ if self._is_closing_quote(is_key):
+ break
+ result += self.current_char
+ self._advance()
+ continue
+
+ if self.current_char == "\\":
+ self._advance()
+ if self.current_char in ['"', "'", "\\", "/", "b", "f", "n", "r", "t"]:
+ result += {
+ "b": "\b",
+ "f": "\f",
+ "n": "\n",
+ "r": "\r",
+ "t": "\t",
+ }.get(self.current_char, self.current_char)
+ elif self.current_char == "u":
+ self._advance() # Skip 'u'
+ unicode_char = ""
+ # Try to collect exactly 4 hex digits
+ for _ in range(4):
+ if self.current_char is None or not self.current_char.isalnum():
+ # If we can't get 4 hex digits, treat it as a literal '\u' followed by whatever we got
+ return result + "\\u" + unicode_char
+ unicode_char += self.current_char
+ self._advance()
+ try:
+ result += chr(int(unicode_char, 16))
+ except ValueError:
+ # If invalid hex value, treat as literal
+ result += "\\u" + unicode_char
+ continue
+ else:
+ result += self.current_char
+ self._advance()
+ if self.current_char == quote_char:
+ self._advance() # Skip closing quote
+ return result
+
+ def _is_closing_quote(self, is_key: bool) -> bool:
+ next_index = self._skip_padding_from(self.index + 1)
+ if next_index >= len(self.json_string):
+ return True
+
+ next_char = self.json_string[next_index]
+ if is_key:
+ return next_char in [":", ",", "}", "]"]
+
+ if next_char in [",", "}", "]"]:
+ return True
+
+ return self._looks_like_missing_comma_before_key(next_index)
+
+ def _looks_like_missing_comma_before_key(self, index: int) -> bool:
+ if not self.stack or not isinstance(self.stack[-1], dict):
+ return False
+ if index >= len(self.json_string) or self.json_string[index] not in ['"', "'"]:
+ return False
+
+ quote_char = self.json_string[index]
+ index += 1
+ while index < len(self.json_string):
+ char = self.json_string[index]
+ if char == "\\":
+ index += 2
+ continue
+ if char == quote_char:
+ next_index = self._skip_padding_from(index + 1)
+ return (
+ next_index < len(self.json_string)
+ and self.json_string[next_index] == ":"
+ )
+ if char in ["\n", "\r", "{", "}", "[", "]", ","]:
+ return False
+ index += 1
+
+ return False
+
+ def _skip_padding_from(self, index: int) -> int:
+ while index < len(self.json_string):
+ char = self.json_string[index]
+ if char.isspace():
+ index += 1
+ elif char == "/" and index + 1 < len(self.json_string):
+ next_char = self.json_string[index + 1]
+ if next_char == "/":
+ index += 2
+ while index < len(self.json_string) and self.json_string[index] != "\n":
+ index += 1
+ elif next_char == "*":
+ end = self.json_string.find("*/", index + 2)
+ if end == -1:
+ return len(self.json_string)
+ index = end + 2
+ else:
+ break
+ else:
+ break
+ return index
+
+ def _parse_multiline_string(self):
+ result = ""
+ quote_char = self.current_char
+ self._advance(3) # Skip first quote
+ while self.current_char is not None:
+ if self.current_char == quote_char and self._peek(2) == quote_char * 2: # type: ignore
+ self._advance(3) # Skip first quote
+ break
+ result += self.current_char
+ self._advance()
+ return result.strip()
+
+ def _parse_number(self):
+ number_str = ""
+ while self.current_char is not None and (
+ self.current_char.isdigit()
+ or self.current_char in ["-", "+", ".", "e", "E"]
+ ):
+ number_str += self.current_char
+ self._advance()
+ try:
+ return int(number_str)
+ except ValueError:
+ return float(number_str)
+
+ def _parse_unquoted_string(self):
+ result = ""
+ while self.current_char is not None and self.current_char not in [
+ ":",
+ ",",
+ "}",
+ "]",
+ ]:
+ result += self.current_char
+ self._advance()
+ self._advance()
+ return result.strip()
+
+ def _peek(self, n):
+ peek_index = self.index + 1
+ result = ""
+ for _ in range(n):
+ if peek_index < len(self.json_string):
+ result += self.json_string[peek_index]
+ peek_index += 1
+ else:
+ break
+ return result
+
+ def get_start_pos(self, input_str: str) -> int:
+ chars = ["{", "[", '"']
+ indices = [input_str.find(char) for char in chars if input_str.find(char) != -1]
+ return min(indices) if indices else 0
diff --git a/helpers/dirty_json.py.dox.md b/helpers/dirty_json.py.dox.md
index c653cf568bc07bd9b68a06aea18f0cae55998677..030a5c652fbb490351c1968904571b079e4968a9 100644
--- a/helpers/dirty_json.py.dox.md
+++ b/helpers/dirty_json.py.dox.md
@@ -1,3 +1,51 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:cd5dc466655fe9f3f1de10b745148fb37d1377f155e64c235f004bafd36ccc29
-size 2421
+# dirty_json.py DOX
+
+## Purpose
+
+- Own the `dirty_json.py` helper module.
+- This module parses and serializes relaxed JSON-like model output.
+- Keep this file-level DOX profile synchronized with `dirty_json.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `dirty_json.py` owns the runtime implementation.
+- `dirty_json.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `DirtyJson` (no explicit base class)
+ - `parse_string(json_string)`
+ - `parse(self, json_string)`
+ - `feed(self, chunk)`
+ - `get_start_pos(self, input_str: str) -> int`
+- Top-level functions:
+- `try_parse(json_string: str)`
+- `parse(json_string: str)`
+- `stringify(obj, **kwargs)`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem writes, settings/state persistence.
+- Imported dependency areas include: `json`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `DirtyJson.parse_string`, `json.dumps`, `json.loads`, `self._reset`, `self.stack.pop`, `DirtyJson`, `parser.parse`, `self.get_start_pos`, `self._parse`, `self._advance`, `self._skip_whitespace`, `self._parse_object_content`, `self._parse_array_content`, `self._skip_padding_from`, `self._looks_like_missing_comma_before_key`, `result.strip`, `self.current_char.isspace`, `self._parse_value`, `self._continue_parsing`, `self._parse_object`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_dirty_json.py`
+ - `tests/test_projects.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/docker.py b/helpers/docker.py
index c1ca6533054f8d27425c90d0f2d32fb0f261932d..a95aaee37e529b6586142ff85b94324800d9217a 100644
--- a/helpers/docker.py
+++ b/helpers/docker.py
@@ -1,3 +1,99 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6af4b56429c61f45a65a09f339ef6783d4b63ec476a6de89c18dc79f70ccf48a
-size 4790
+import time
+import docker
+import atexit
+from typing import Optional
+from helpers.files import get_abs_path
+from helpers.errors import format_error
+from helpers.print_style import PrintStyle
+from helpers.log import Log
+
+class DockerContainerManager:
+ def __init__(self, image: str, name: str, ports: Optional[dict[str, int]] = None, volumes: Optional[dict[str, dict[str, str]]] = None,logger: Log|None=None):
+ self.logger = logger
+ self.image = image
+ self.name = name
+ self.ports = ports
+ self.volumes = volumes
+ self.init_docker()
+
+ def init_docker(self):
+ self.client = None
+ while not self.client:
+ try:
+ self.client = docker.from_env()
+ self.container = None
+ except Exception as e:
+ err = format_error(e)
+ if ("ConnectionRefusedError(61," in err or "Error while fetching server API version" in err):
+ PrintStyle.hint("Connection to Docker failed. Is docker or Docker Desktop running?") # hint for user
+ if self.logger:self.logger.log(type="hint", content="Connection to Docker failed. Is docker or Docker Desktop running?")
+ PrintStyle.error(err)
+ if self.logger:self.logger.log(type="error", content=err)
+ time.sleep(5) # try again in 5 seconds
+ else: raise
+ return self.client
+
+ def cleanup_container(self) -> None:
+ if self.container:
+ try:
+ self.container.stop()
+ self.container.remove()
+ PrintStyle.standard(f"Stopped and removed the container: {self.container.id}")
+ if self.logger: self.logger.log(type="info", content=f"Stopped and removed the container: {self.container.id}")
+ except Exception as e:
+ PrintStyle.error(f"Failed to stop and remove the container: {e}")
+ if self.logger: self.logger.log(type="error", content=f"Failed to stop and remove the container: {e}")
+
+ def get_image_containers(self):
+ if not self.client: self.client = self.init_docker()
+ containers = self.client.containers.list(all=True, filters={"ancestor": self.image})
+ infos = []
+ for container in containers:
+ infos.append({
+ "id": container.id,
+ "name": container.name,
+ "status": container.status,
+ "image": container.image,
+ "ports": container.ports,
+ "web_port": (container.ports.get("80/tcp") or [{}])[0].get("HostPort"),
+ "ssh_port": (container.ports.get("22/tcp") or [{}])[0].get("HostPort"),
+ # "volumes": container.volumes,
+ # "data_folder": container.volumes["/a0"],
+ })
+ return infos
+
+ def start_container(self) -> None:
+ if not self.client: self.client = self.init_docker()
+ existing_container = None
+ for container in self.client.containers.list(all=True):
+ if container.name == self.name:
+ existing_container = container
+ break
+
+ if existing_container:
+ if existing_container.status != 'running':
+ PrintStyle.standard(f"Starting existing container: {self.name} for safe code execution...")
+ if self.logger: self.logger.log(type="info", content=f"Starting existing container: {self.name} for safe code execution...")
+
+ existing_container.start()
+ self.container = existing_container
+ time.sleep(2) # this helps to get SSH ready
+
+ else:
+ self.container = existing_container
+ # PrintStyle.standard(f"Container with name '{self.name}' is already running with ID: {existing_container.id}")
+ else:
+ PrintStyle.standard(f"Initializing docker container {self.name} for safe code execution...")
+ if self.logger: self.logger.log(type="info", content=f"Initializing docker container {self.name} for safe code execution...")
+
+ self.container = self.client.containers.run(
+ self.image,
+ detach=True,
+ ports=self.ports, # type: ignore
+ name=self.name,
+ volumes=self.volumes, # type: ignore
+ )
+ # atexit.register(self.cleanup_container)
+ PrintStyle.standard(f"Started container with ID: {self.container.id}")
+ if self.logger: self.logger.log(type="info", content=f"Started container with ID: {self.container.id}")
+ time.sleep(5) # this helps to get SSH ready
diff --git a/helpers/docker.py.dox.md b/helpers/docker.py.dox.md
index 69f47ff3db8a97d1ad3bee1bc799d0ebed10a1df..e932e7bcc2584982350375ba3f99e27ba448569a 100644
--- a/helpers/docker.py.dox.md
+++ b/helpers/docker.py.dox.md
@@ -1,3 +1,53 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:e07431962c8e5840f6817f5bb8c59fedbc2478484702d61e50734c217612467b
-size 2528
+# docker.py DOX
+
+## Purpose
+
+- Own the `docker.py` helper module.
+- This module manages Docker container operations used by runtime helpers.
+- Keep this file-level DOX profile synchronized with `docker.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `docker.py` owns the runtime implementation.
+- `docker.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `DockerContainerManager` (no explicit base class)
+ - `init_docker(self)`
+ - `cleanup_container(self) -> None`
+ - `get_image_containers(self)`
+ - `start_container(self) -> None`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem deletion, subprocess/runtime control, WebSocket state.
+- Imported dependency areas include: `atexit`, `docker`, `helpers.errors`, `helpers.files`, `helpers.log`, `helpers.print_style`, `time`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `self.init_docker`, `PrintStyle.standard`, `self.client.containers.run`, `time.sleep`, `docker.from_env`, `self.container.stop`, `self.container.remove`, `existing_container.start`, `self.logger.log`, `format_error`, `PrintStyle.error`, `PrintStyle.hint`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_default_prompt_budget.py`
+ - `tests/test_docker_release_plan.py`
+ - `tests/test_document_query_plugin.py`
+ - `tests/test_host_browser_connector.py`
+ - `tests/test_model_search.py`
+ - `tests/test_office_canvas_setup.py`
+ - `tests/test_office_document_store.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/document_query.py b/helpers/document_query.py
index 49f0af528beadd10b8baa9c234b67f3a31aae5b1..938b66b68b783d8579dc0a8f7df1b1f3c1381ee9 100644
--- a/helpers/document_query.py
+++ b/helpers/document_query.py
@@ -1,3 +1,13 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:766b7cbc8a9938f4a76580ebad845d3eaf093ce4d2c2fa6bb6ea2cb8479cda34
-size 310
+"""Compatibility shim for the document_query plugin extraction."""
+
+from plugins._document_query.helpers.document_query import (
+ DEFAULT_SEARCH_THRESHOLD,
+ DocumentQueryHelper,
+ DocumentQueryStore,
+)
+
+__all__ = [
+ "DEFAULT_SEARCH_THRESHOLD",
+ "DocumentQueryHelper",
+ "DocumentQueryStore",
+]
diff --git a/helpers/document_query.py.dox.md b/helpers/document_query.py.dox.md
index 95387d69a5d29221188a65469c74e50b7a023042..7d205d98805870d51140abf5219ff26542cd0365 100644
--- a/helpers/document_query.py.dox.md
+++ b/helpers/document_query.py.dox.md
@@ -1,3 +1,41 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1669a1aa1f554a9a909625d54250fed5b1dfc9a2e433259a9f2f00a7959cc810
-size 1780
+# document_query.py DOX
+
+## Purpose
+
+- Own the `document_query.py` helper module.
+- This module provides compatibility exports for document query behavior.
+- Keep this file-level DOX profile synchronized with `document_query.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `document_query.py` owns the runtime implementation.
+- `document_query.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: plugin state.
+- Imported dependency areas include: `plugins._document_query.helpers.document_query`.
+
+## Key Concepts
+
+- This module is primarily declarative or delegates behavior through classes/imported objects.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_document_query_fallback.py`
+ - `tests/test_document_query_plugin.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/dotenv.py b/helpers/dotenv.py
index e32e8ebaca1109dc7a6ee303dc27398f27a0f40c..ebb027b7a01b4998fb18a0be98ea44dadd73852c 100644
--- a/helpers/dotenv.py
+++ b/helpers/dotenv.py
@@ -1,3 +1,44 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:016ca7cdb362b806657a94bb8a25133f61e1a192dce365c23cd586a67ff458f6
-size 1211
+import os
+import re
+from typing import Any
+
+from .files import get_abs_path
+from dotenv import load_dotenv as _load_dotenv
+
+KEY_AUTH_LOGIN = "AUTH_LOGIN"
+KEY_AUTH_PASSWORD = "AUTH_PASSWORD"
+KEY_RFC_PASSWORD = "RFC_PASSWORD"
+KEY_ROOT_PASSWORD = "ROOT_PASSWORD"
+
+def load_dotenv():
+ _load_dotenv(get_dotenv_file_path(), override=True)
+
+
+def get_dotenv_file_path():
+ return get_abs_path("usr/.env")
+
+def get_dotenv_value(key: str, default: Any = None):
+ # load_dotenv()
+ return os.getenv(key, default)
+
+def save_dotenv_value(key: str, value: str, reload_env: bool = True):
+ if value is None:
+ value = ""
+ dotenv_path = get_dotenv_file_path()
+ if not os.path.isfile(dotenv_path):
+ with open(dotenv_path, "w") as f:
+ f.write("")
+ with open(dotenv_path, "r+") as f:
+ lines = f.readlines()
+ found = False
+ for i, line in enumerate(lines):
+ if re.match(rf"^\s*{key}\s*=", line):
+ lines[i] = f"{key}={value}\n"
+ found = True
+ if not found:
+ lines.append(f"\n{key}={value}\n")
+ f.seek(0)
+ f.writelines(lines)
+ f.truncate()
+ if reload_env:
+ load_dotenv()
diff --git a/helpers/dotenv.py.dox.md b/helpers/dotenv.py.dox.md
index afde572386581ccf8de46631252476e76f08e112..375f5d95d8d19d6ef610fe94790a571ee145ab97 100644
--- a/helpers/dotenv.py.dox.md
+++ b/helpers/dotenv.py.dox.md
@@ -1,3 +1,49 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1f475145490ae8d7a123574aac6f035dbb89433b4dd62929ade1b8c4786a94f2
-size 2358
+# dotenv.py DOX
+
+## Purpose
+
+- Own the `dotenv.py` helper module.
+- This module loads and updates `.env` configuration values.
+- Keep this file-level DOX profile synchronized with `dotenv.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `dotenv.py` owns the runtime implementation.
+- `dotenv.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `load_dotenv()`
+- `get_dotenv_file_path()`
+- `get_dotenv_value(key: str, default: Any=...)`
+- `save_dotenv_value(key: str, value: str, reload_env: bool=...)`
+- Notable constants/configuration names: `KEY_AUTH_LOGIN`, `KEY_AUTH_PASSWORD`, `KEY_RFC_PASSWORD`, `KEY_ROOT_PASSWORD`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem writes, secret handling.
+- Imported dependency areas include: `dotenv`, `files`, `os`, `re`, `typing`.
+- `save_dotenv_value(..., reload_env=False)` updates the persisted file without changing the running process environment.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `_load_dotenv`, `get_abs_path`, `os.getenv`, `get_dotenv_file_path`, `load_dotenv`, `os.path.isfile`, `f.readlines`, `f.seek`, `f.writelines`, `f.truncate`, `f.write`, `re.match`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/email_parser_test.py`
+ - `tests/test_model_config_api_keys.py`
+ - `tests/test_timezone_regressions.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/duckduckgo_search.py b/helpers/duckduckgo_search.py
index 1d87d3a59b96bce372ae8767818e79746af8394a..b97497c27929d6342018a740c9083adf6b5193b0 100644
--- a/helpers/duckduckgo_search.py
+++ b/helpers/duckduckgo_search.py
@@ -1,3 +1,30 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:fc473ee3d75e38801080f037acb2da74d9c9dfd51453ca9f6fe9c75daa720aaa
-size 1063
+# from langchain_community.utilities import DuckDuckGoSearchAPIWrapper
+
+# def search(query: str, results = 5, region = "wt-wt", time="y") -> str:
+# # Create an instance with custom parameters
+# api = DuckDuckGoSearchAPIWrapper(
+# region=region, # Set the region for search results
+# safesearch="off", # Set safesearch level (options: strict, moderate, off)
+# time=time, # Set time range (options: d, w, m, y)
+# max_results=results # Set maximum number of results to return
+# )
+# # Perform a search
+# result = api.run(query)
+# return result
+
+from duckduckgo_search import DDGS
+
+def search(query: str, results = 5, region = "wt-wt", time="y") -> list[str]:
+
+ ddgs = DDGS()
+ src = ddgs.text(
+ query,
+ region=region, # Specify region
+ safesearch="off", # SafeSearch setting
+ timelimit=time, # Time limit (y = past year)
+ max_results=results # Number of results to return
+ )
+ results = []
+ for s in src:
+ results.append(str(s))
+ return results
\ No newline at end of file
diff --git a/helpers/duckduckgo_search.py.dox.md b/helpers/duckduckgo_search.py.dox.md
index 64ab20de60875c66f60f08e38937acee0c1d6bae..5090f50420ff22cadc605aac06faceed5b086c73 100644
--- a/helpers/duckduckgo_search.py.dox.md
+++ b/helpers/duckduckgo_search.py.dox.md
@@ -1,3 +1,40 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:f7089a553f4781c9cfc65280c1911869db571e72b4ef9ed40204d2144a12a8c5
-size 1796
+# duckduckgo_search.py DOX
+
+## Purpose
+
+- Own the `duckduckgo_search.py` helper module.
+- This module queries DuckDuckGo search through the configured helper path.
+- Keep this file-level DOX profile synchronized with `duckduckgo_search.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `duckduckgo_search.py` owns the runtime implementation.
+- `duckduckgo_search.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `search(query: str, results=..., region=..., time=...) -> list[str]`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Imported dependency areas include: `duckduckgo_search`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `DDGS`, `ddgs.text`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/email_client.py b/helpers/email_client.py
index 69280acf5d47e42feda560a3730cbd7fd34ae768..96744d6cc05242cba617fe7776afba3e869d1456 100644
--- a/helpers/email_client.py
+++ b/helpers/email_client.py
@@ -1,3 +1,587 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:e4f9f6c0ef7f3120cb74e0b623a8d28b780b889947c5339908d0788ffb1be749
-size 22333
+# import asyncio
+# import email
+# import os
+# import re
+# import uuid
+# from dataclasses import dataclass
+# from email.header import decode_header
+# from email.message import Message as EmailMessage
+# from fnmatch import fnmatch
+# from typing import Any, Dict, List, Optional, Tuple
+
+# import html2text
+# from bs4 import BeautifulSoup
+# from imapclient import IMAPClient
+
+# from helpers import files
+# from helpers.errors import RepairableException, format_error
+# from helpers.print_style import PrintStyle
+
+
+# @dataclass
+# class Message:
+# """Email message representation with sender, subject, body, and attachments."""
+# sender: str
+# subject: str
+# body: str
+# attachments: List[str]
+
+
+# class EmailClient:
+# """
+# Async email client for reading messages from IMAP and Exchange servers.
+
+# """
+
+# def __init__(
+# self,
+# account_type: str = "imap",
+# server: str = "",
+# port: int = 993,
+# username: str = "",
+# password: str = "",
+# options: Optional[Dict[str, Any]] = None,
+# ):
+# """
+# Initialize email client with connection parameters.
+
+# Args:
+# account_type: Type of account - "imap" or "exchange"
+# server: Mail server address (e.g., "imap.gmail.com")
+# port: Server port (default 993 for IMAP SSL)
+# username: Email account username
+# password: Email account password
+# options: Optional configuration dict with keys:
+# - ssl: Use SSL/TLS (default: True)
+# - timeout: Connection timeout in seconds (default: 30)
+# """
+# self.account_type = account_type.lower()
+# self.server = server
+# self.port = port
+# self.username = username
+# self.password = password
+# self.options = options or {}
+
+# # Default options
+# self.ssl = self.options.get("ssl", True)
+# self.timeout = self.options.get("timeout", 30)
+
+# self.client: Optional[IMAPClient] = None
+# self.exchange_account = None
+
+# async def connect(self) -> None:
+# """Establish connection to email server."""
+# try:
+# if self.account_type == "imap":
+# await self._connect_imap()
+# elif self.account_type == "exchange":
+# await self._connect_exchange()
+# else:
+# raise RepairableException(
+# f"Unsupported account type: {self.account_type}. "
+# "Supported types: 'imap', 'exchange'"
+# )
+# except Exception as e:
+# err = format_error(e)
+# PrintStyle.error(f"Failed to connect to email server: {err}")
+# raise RepairableException(f"Email connection failed: {err}") from e
+
+# async def _connect_imap(self) -> None:
+# """Establish IMAP connection."""
+# loop = asyncio.get_event_loop()
+
+# def _sync_connect():
+# client = IMAPClient(self.server, port=self.port, ssl=self.ssl, timeout=self.timeout)
+# # Increase line length limit to handle large emails (default is 10000)
+# # This fixes "line too long" errors for emails with large headers or embedded content
+# client._imap._maxline = 100000
+# client.login(self.username, self.password)
+# return client
+
+# self.client = await loop.run_in_executor(None, _sync_connect)
+# PrintStyle.standard(f"Connected to IMAP server: {self.server}")
+
+# async def _connect_exchange(self) -> None:
+# """Establish Exchange connection."""
+# try:
+# from exchangelib import Account, Configuration, Credentials, DELEGATE
+
+# loop = asyncio.get_event_loop()
+
+# def _sync_connect():
+# creds = Credentials(username=self.username, password=self.password)
+# config = Configuration(server=self.server, credentials=creds)
+# return Account(
+# primary_smtp_address=self.username,
+# config=config,
+# autodiscover=False,
+# access_type=DELEGATE
+# )
+
+# self.exchange_account = await loop.run_in_executor(None, _sync_connect)
+# PrintStyle.standard(f"Connected to Exchange server: {self.server}")
+# except ImportError as e:
+# raise RepairableException(
+# "exchangelib not installed. Install with: pip install exchangelib>=5.4.3"
+# ) from e
+
+# async def disconnect(self) -> None:
+# """Clean up connection."""
+# try:
+# if self.client:
+# loop = asyncio.get_event_loop()
+# await loop.run_in_executor(None, self.client.logout)
+# self.client = None
+# PrintStyle.standard("Disconnected from IMAP server")
+# elif self.exchange_account:
+# self.exchange_account = None
+# PrintStyle.standard("Disconnected from Exchange server")
+# except Exception as e:
+# PrintStyle.error(f"Error during disconnect: {format_error(e)}")
+
+# async def read_messages(
+# self,
+# download_folder: str,
+# filter: Optional[Dict[str, Any]] = None,
+# ) -> List[Message]:
+# """
+# Read messages based on filter criteria.
+
+# Args:
+# download_folder: Folder to save attachments (relative to /a0/)
+# filter: Filter criteria dict with keys:
+# - unread: Boolean to filter unread messages (default: True)
+# - sender: Sender pattern with wildcards (e.g., "*@company.com")
+# - subject: Subject pattern with wildcards (e.g., "*invoice*")
+# - since_date: Optional datetime for date filtering
+
+# Returns:
+# List of Message objects with attachments saved to download_folder
+# """
+# filter = filter or {}
+
+# if self.account_type == "imap":
+# return await self._fetch_imap_messages(download_folder, filter)
+# elif self.account_type == "exchange":
+# return await self._fetch_exchange_messages(download_folder, filter)
+# else:
+# raise RepairableException(f"Unsupported account type: {self.account_type}")
+
+# async def _fetch_imap_messages(
+# self,
+# download_folder: str,
+# filter: Dict[str, Any],
+# ) -> List[Message]:
+# """Fetch messages from IMAP server."""
+# if not self.client:
+# raise RepairableException("IMAP client not connected. Call connect() first.")
+
+# loop = asyncio.get_event_loop()
+# messages: List[Message] = []
+
+# def _sync_fetch():
+# # Select inbox
+# self.client.select_folder("INBOX")
+
+# # Build search criteria
+# search_criteria = []
+# if filter.get("unread", True):
+# search_criteria.append("UNSEEN")
+
+# if filter.get("since_date"):
+# since_date = filter["since_date"]
+# search_criteria.append(["SINCE", since_date])
+
+# # Search for messages
+# if not search_criteria:
+# search_criteria = ["ALL"]
+
+# message_ids = self.client.search(search_criteria)
+# return message_ids
+
+# message_ids = await loop.run_in_executor(None, _sync_fetch)
+
+# if not message_ids:
+# PrintStyle.hint("No messages found matching criteria")
+# return messages
+
+# PrintStyle.standard(f"Found {len(message_ids)} messages")
+
+# # Fetch and process messages
+# for msg_id in message_ids:
+# try:
+# msg = await self._fetch_and_parse_imap_message(msg_id, download_folder, filter)
+# if msg:
+# messages.append(msg)
+# except Exception as e:
+# PrintStyle.error(f"Error processing message {msg_id}: {format_error(e)}")
+# continue
+
+# return messages
+
+# async def _fetch_and_parse_imap_message(
+# self,
+# msg_id: int,
+# download_folder: str,
+# filter: Dict[str, Any],
+# ) -> Optional[Message]:
+# """Fetch and parse a single IMAP message with retry logic for large messages."""
+# loop = asyncio.get_event_loop()
+
+# def _sync_fetch():
+# try:
+# # Try standard RFC822 fetch first
+# return self.client.fetch([msg_id], ["RFC822"])[msg_id]
+# except Exception as e:
+# error_msg = str(e).lower()
+# # If "line too long" error, try fetching in parts
+# if "line too long" in error_msg or "fetch_failed" in error_msg:
+# PrintStyle.warning(f"Message {msg_id} too large for standard fetch, trying alternative method")
+# # Fetch headers and body separately to avoid line length issues
+# try:
+# envelope = self.client.fetch([msg_id], ["BODY.PEEK[]"])[msg_id]
+# return envelope
+# except Exception as e2:
+# PrintStyle.error(f"Alternative fetch also failed for message {msg_id}: {format_error(e2)}")
+# raise
+# raise
+
+# try:
+# raw_msg = await loop.run_in_executor(None, _sync_fetch)
+
+# # Extract email data from response
+# if b"RFC822" in raw_msg:
+# email_data = raw_msg[b"RFC822"]
+# elif b"BODY[]" in raw_msg:
+# email_data = raw_msg[b"BODY[]"]
+# else:
+# PrintStyle.error(f"Unexpected response format for message {msg_id}")
+# return None
+
+# email_msg = email.message_from_bytes(email_data)
+
+# # Apply sender filter
+# sender = self._decode_header(email_msg.get("From", ""))
+# if filter.get("sender") and not fnmatch(sender, filter["sender"]):
+# return None
+
+# # Apply subject filter
+# subject = self._decode_header(email_msg.get("Subject", ""))
+# if filter.get("subject") and not fnmatch(subject, filter["subject"]):
+# return None
+
+# # Parse message
+# return await self._parse_message(email_msg, download_folder)
+
+# except Exception as e:
+# PrintStyle.error(f"Failed to fetch/parse message {msg_id}: {format_error(e)}")
+# return None
+
+# async def _fetch_exchange_messages(
+# self,
+# download_folder: str,
+# filter: Dict[str, Any],
+# ) -> List[Message]:
+# """Fetch messages from Exchange server."""
+# if not self.exchange_account:
+# raise RepairableException("Exchange account not connected. Call connect() first.")
+
+# from exchangelib import Q
+
+# loop = asyncio.get_event_loop()
+# messages: List[Message] = []
+
+# def _sync_fetch():
+# # Build query
+# query = None
+# if filter.get("unread", True):
+# query = Q(is_read=False)
+
+# if filter.get("sender"):
+# sender_pattern = filter["sender"].replace("*", "")
+# sender_q = Q(sender__contains=sender_pattern)
+# query = query & sender_q if query else sender_q
+
+# if filter.get("subject"):
+# subject_pattern = filter["subject"].replace("*", "")
+# subject_q = Q(subject__contains=subject_pattern)
+# query = query & subject_q if query else subject_q
+
+# # Fetch messages from inbox
+# inbox = self.exchange_account.inbox
+# items = inbox.filter(query) if query else inbox.all()
+# return list(items)
+
+# exchange_messages = await loop.run_in_executor(None, _sync_fetch)
+
+# PrintStyle.standard(f"Found {len(exchange_messages)} Exchange messages")
+
+# # Process messages
+# for ex_msg in exchange_messages:
+# try:
+# msg = await self._parse_exchange_message(ex_msg, download_folder)
+# if msg:
+# messages.append(msg)
+# except Exception as e:
+# PrintStyle.error(f"Error processing Exchange message: {format_error(e)}")
+# continue
+
+# return messages
+
+# async def _parse_exchange_message(
+# self,
+# ex_msg,
+# download_folder: str,
+# ) -> Message:
+# """Parse an Exchange message."""
+# loop = asyncio.get_event_loop()
+
+# def _get_body():
+# return str(ex_msg.text_body or ex_msg.body or "")
+
+# body = await loop.run_in_executor(None, _get_body)
+
+# # Process HTML if present
+# if ex_msg.body and str(ex_msg.body).strip().startswith("<"):
+# body = self._html_to_text(str(ex_msg.body))
+
+# # Save attachments
+# attachment_paths = []
+# if ex_msg.attachments:
+# for attachment in ex_msg.attachments:
+# if hasattr(attachment, "content"):
+# path = await self._save_attachment_bytes(
+# attachment.name,
+# attachment.content,
+# download_folder
+# )
+# attachment_paths.append(path)
+
+# return Message(
+# sender=str(ex_msg.sender.email_address) if ex_msg.sender else "",
+# subject=str(ex_msg.subject or ""),
+# body=body,
+# attachments=attachment_paths
+# )
+
+# async def _parse_message(
+# self,
+# email_msg: EmailMessage,
+# download_folder: str,
+# ) -> Message:
+# """
+# Parse email message and extract content with inline attachments.
+
+# Processes multipart messages, converts HTML to text, and maintains
+# positional context for inline attachments.
+# """
+# sender = self._decode_header(email_msg.get("From", ""))
+# subject = self._decode_header(email_msg.get("Subject", ""))
+
+# # Extract body and attachments
+# body = ""
+# attachment_paths: List[str] = []
+# cid_map: Dict[str, str] = {} # Map Content-ID to file paths
+# body_parts: List[str] = [] # Track parts in order
+
+# if email_msg.is_multipart():
+# # Process parts in order to maintain attachment positions
+# for part in email_msg.walk():
+# content_type = part.get_content_type()
+# content_disposition = str(part.get("Content-Disposition", ""))
+
+# # Skip multipart containers
+# if part.get_content_maintype() == "multipart":
+# continue
+
+# # Handle attachments
+# if "attachment" in content_disposition or part.get("Content-ID"):
+# filename = part.get_filename()
+# if filename:
+# filename = self._decode_header(filename)
+# content = part.get_payload(decode=True)
+# if content:
+# path = await self._save_attachment_bytes(
+# filename, content, download_folder
+# )
+# attachment_paths.append(path)
+
+# # Map Content-ID for inline images
+# cid = part.get("Content-ID")
+# if cid:
+# cid = cid.strip("<>")
+# cid_map[cid] = path
+
+# # Add positional marker for non-cid attachments
+# # (cid attachments are positioned via HTML references)
+# if not cid and body_parts:
+# body_parts.append(f"\n[file://{path}]\n")
+
+# # Handle body text
+# elif content_type == "text/plain":
+# if not body: # Use first text/plain as primary body
+# charset = part.get_content_charset() or "utf-8"
+# body = part.get_payload(decode=True).decode(charset, errors="ignore")
+# body_parts.append(body)
+
+# elif content_type == "text/html":
+# if not body: # Use first text/html as primary body if no text/plain
+# charset = part.get_content_charset() or "utf-8"
+# html_content = part.get_payload(decode=True).decode(charset, errors="ignore")
+# body = self._html_to_text(html_content, cid_map)
+# body_parts.append(body)
+
+# # Combine body parts if we built them up
+# if len(body_parts) > 1:
+# body = "".join(body_parts)
+# else:
+# # Single part message
+# content_type = email_msg.get_content_type()
+# charset = email_msg.get_content_charset() or "utf-8"
+# content = email_msg.get_payload(decode=True)
+# if content:
+# if content_type == "text/html":
+# body = self._html_to_text(content.decode(charset, errors="ignore"), cid_map)
+# else:
+# body = content.decode(charset, errors="ignore")
+
+# return Message(
+# sender=sender,
+# subject=subject,
+# body=body,
+# attachments=attachment_paths
+# )
+
+# def _html_to_text(self, html_content: str, cid_map: Optional[Dict[str, str]] = None) -> str:
+# """
+# Convert HTML to plain text with inline attachment references.
+
+# Replaces inline images with [file:///a0/...] markers to maintain
+# positional context.
+# """
+# cid_map = cid_map or {}
+
+# # Replace cid: references with file paths before conversion
+# if cid_map:
+# soup = BeautifulSoup(html_content, "html.parser")
+# for img in soup.find_all("img"):
+# src = img.get("src", "")
+# if src.startswith("cid:"):
+# cid = src[4:] # Remove "cid:" prefix
+# if cid in cid_map:
+# # Replace with file path marker
+# file_marker = f"[file://{cid_map[cid]}]"
+# img.replace_with(soup.new_string(file_marker))
+# html_content = str(soup)
+
+# # Convert HTML to text
+# h = html2text.HTML2Text()
+# h.ignore_links = False
+# h.ignore_images = False
+# h.ignore_emphasis = False
+# h.body_width = 0 # Don't wrap lines
+
+# text = h.handle(html_content)
+
+# # Clean up extra whitespace
+# text = re.sub(r"\n{3,}", "\n\n", text) # Max 2 consecutive newlines
+# text = text.strip()
+
+# return text
+
+# async def _save_attachment_bytes(
+# self,
+# filename: str,
+# content: bytes,
+# download_folder: str,
+# ) -> str:
+# """
+# Save attachment to disk and return absolute path.
+
+# Uses Agent Zero's file helpers for path management.
+# """
+# # Sanitize filename
+# filename = files.safe_file_name(filename)
+
+# # Generate unique filename if needed
+# unique_id = uuid.uuid4().hex[:8]
+# name, ext = os.path.splitext(filename)
+# unique_filename = f"{name}_{unique_id}{ext}"
+
+# # Build relative path and save
+# relative_path = os.path.join(download_folder, unique_filename)
+# files.write_file_bin(relative_path, content)
+
+# # Return absolute path
+# abs_path = files.get_abs_path(relative_path)
+# return abs_path
+
+# def _decode_header(self, header: str) -> str:
+# """Decode email header handling various encodings."""
+# if not header:
+# return ""
+
+# decoded_parts = []
+# for part, encoding in decode_header(header):
+# if isinstance(part, bytes):
+# decoded_parts.append(part.decode(encoding or "utf-8", errors="ignore"))
+# else:
+# decoded_parts.append(str(part))
+
+# return " ".join(decoded_parts)
+
+
+# async def read_messages(
+# account_type: str = "imap",
+# server: str = "",
+# port: int = 993,
+# username: str = "",
+# password: str = "",
+# download_folder: str = "usr/email",
+# options: Optional[Dict[str, Any]] = None,
+# filter: Optional[Dict[str, Any]] = None,
+# ) -> List[Message]:
+# """
+# Convenience wrapper for reading email messages.
+
+# Automatically handles connection and disconnection.
+
+# Args:
+# account_type: "imap" or "exchange"
+# server: Mail server address
+# port: Server port (default 993 for IMAP SSL)
+# username: Email username
+# password: Email password
+# download_folder: Folder to save attachments (relative to /a0/)
+# options: Optional configuration dict
+# filter: Filter criteria dict
+
+# Returns:
+# List of Message objects
+
+# Example:
+# from helpers.email_client import read_messages
+# messages = await read_messages(
+# server="imap.gmail.com",
+# port=993,
+# username=secrets.get("EMAIL_USER"),
+# password=secrets.get("EMAIL_PASSWORD"),
+# download_folder="tmp/email/inbox",
+# filter={"unread": True, "sender": "*@company.com"}
+# )
+# """
+# client = EmailClient(
+# account_type=account_type,
+# server=server,
+# port=port,
+# username=username,
+# password=password,
+# options=options,
+# )
+
+# try:
+# await client.connect()
+# messages = await client.read_messages(download_folder, filter)
+# return messages
+# finally:
+# await client.disconnect()
diff --git a/helpers/email_client.py.dox.md b/helpers/email_client.py.dox.md
index add26329a2aed2fe8ac69766cd3258f23252b911..976e32cd932496b7d32660b23173ad51ecae2b72 100644
--- a/helpers/email_client.py.dox.md
+++ b/helpers/email_client.py.dox.md
@@ -1,3 +1,39 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:2de3a5b72fe5a69cf27463df093bce3d608b163a4ffa47ac6b64967546877601
-size 1737
+# email_client.py DOX
+
+## Purpose
+
+- Own the `email_client.py` helper module.
+- This module provides shared email client plumbing for mail integrations.
+- Keep this file-level DOX profile synchronized with `email_client.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `email_client.py` owns the runtime implementation.
+- `email_client.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, settings/state persistence, secret handling.
+
+## Key Concepts
+
+- This module is primarily declarative or delegates behavior through classes/imported objects.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/email_parser_test.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/ephemeral_images.py b/helpers/ephemeral_images.py
index 1f649e8146fbc1df099c9f54b431d24058e548f1..6d42c21d2a8cfff5931bdebb5f3460c7b3e92328 100644
--- a/helpers/ephemeral_images.py
+++ b/helpers/ephemeral_images.py
@@ -1,3 +1,150 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:0ac568de4790bcdc2d0e62e6cfe8dcc5842c76d24d6528e931433c2beef09535
-size 3834
+from __future__ import annotations
+
+import base64
+import threading
+import time
+import uuid
+from dataclasses import dataclass
+
+
+REF_PREFIX = "a0-ephemeral-image://"
+DEFAULT_TTL_SECONDS = 15 * 60
+
+
+@dataclass(frozen=True)
+class EphemeralImage:
+ ref: str
+ context_id: str
+ mime: str
+ data: str
+ name: str
+ created_at: float
+ expires_at: float
+
+ @property
+ def data_url(self) -> str:
+ return f"data:{self.mime};base64,{self.data}"
+
+ @property
+ def display_name(self) -> str:
+ return self.name or display_ref(self.ref)
+
+
+_store: dict[str, EphemeralImage] = {}
+_lock = threading.RLock()
+
+
+def put_image_bytes(
+ *,
+ context_id: str,
+ mime: str,
+ payload: bytes,
+ name: str = "",
+ ttl_seconds: float = DEFAULT_TTL_SECONDS,
+) -> str:
+ data = base64.b64encode(bytes(payload or b"")).decode("ascii")
+ return put_image(
+ context_id=context_id,
+ mime=mime,
+ data=data,
+ name=name,
+ ttl_seconds=ttl_seconds,
+ )
+
+
+def put_image(
+ *,
+ context_id: str,
+ mime: str,
+ data: str,
+ name: str = "",
+ ttl_seconds: float = DEFAULT_TTL_SECONDS,
+) -> str:
+ compact_data = _compact_base64(data)
+ if not compact_data:
+ raise ValueError("ephemeral image data is empty")
+ base64.b64decode(compact_data, validate=True)
+
+ normalized_mime = _normalize_mime(mime)
+ now = time.time()
+ ref = f"{REF_PREFIX}{uuid.uuid4().hex}"
+ image = EphemeralImage(
+ ref=ref,
+ context_id=str(context_id or "").strip(),
+ mime=normalized_mime,
+ data=compact_data,
+ name=str(name or "").strip(),
+ created_at=now,
+ expires_at=now + max(1.0, float(ttl_seconds or DEFAULT_TTL_SECONDS)),
+ )
+ with _lock:
+ _prune_expired_locked(now)
+ _store[ref] = image
+ return ref
+
+
+def is_ref(value: object) -> bool:
+ return str(value or "").strip().startswith(REF_PREFIX)
+
+
+def display_ref(ref: str) -> str:
+ value = str(ref or "").strip()
+ if not is_ref(value):
+ return value
+ return f"{REF_PREFIX}"
+
+
+def get_image(ref: str, *, context_id: str = "") -> EphemeralImage | None:
+ return _resolve_image(ref, context_id=context_id, consume=False)
+
+
+def consume_image(ref: str, *, context_id: str = "") -> EphemeralImage | None:
+ return _resolve_image(ref, context_id=context_id, consume=True)
+
+
+def delete_image(ref: str) -> None:
+ with _lock:
+ _store.pop(str(ref or "").strip(), None)
+
+
+def clear_context(context_id: str) -> None:
+ normalized_context = str(context_id or "").strip()
+ with _lock:
+ for ref, image in list(_store.items()):
+ if image.context_id == normalized_context:
+ _store.pop(ref, None)
+
+
+def _resolve_image(ref: str, *, context_id: str = "", consume: bool) -> EphemeralImage | None:
+ value = str(ref or "").strip()
+ if not is_ref(value):
+ return None
+
+ now = time.time()
+ with _lock:
+ _prune_expired_locked(now)
+ image = _store.get(value)
+ if image is None:
+ return None
+ requested_context = str(context_id or "").strip()
+ if requested_context and image.context_id and image.context_id != requested_context:
+ return None
+ if consume:
+ _store.pop(value, None)
+ return image
+
+
+def _compact_base64(data: str) -> str:
+ return "".join(char for char in str(data or "") if not char.isspace())
+
+
+def _normalize_mime(mime: str) -> str:
+ value = str(mime or "").strip().lower()
+ return value if value.startswith("image/") else "image/jpeg"
+
+
+def _prune_expired_locked(now: float | None = None) -> None:
+ current = time.time() if now is None else float(now)
+ for ref, image in list(_store.items()):
+ if image.expires_at <= current:
+ _store.pop(ref, None)
diff --git a/helpers/ephemeral_images.py.dox.md b/helpers/ephemeral_images.py.dox.md
index 4ff818a64fbfb035001e2317d44be421e864a194..62b7a36a05bc1c3cf96b9ad03acb5ca14d70bf47 100644
--- a/helpers/ephemeral_images.py.dox.md
+++ b/helpers/ephemeral_images.py.dox.md
@@ -1,3 +1,58 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:22fcfb8da444bf0300e5c1cd0dbe59399990fc9b0d36af83748128bd0b33b971
-size 3031
+# ephemeral_images.py DOX
+
+## Purpose
+
+- Own the `ephemeral_images.py` helper module.
+- This module stores temporary image payloads by reference for safe short-lived access.
+- Keep this file-level DOX profile synchronized with `ephemeral_images.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `ephemeral_images.py` owns the runtime implementation.
+- `ephemeral_images.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `EphemeralImage` (no explicit base class)
+ - `data_url(self) -> str`
+ - `display_name(self) -> str`
+- Top-level functions:
+- `put_image_bytes(context_id: str, mime: str, payload: bytes, name: str=..., ttl_seconds: float=...) -> str`
+- `put_image(context_id: str, mime: str, data: str, name: str=..., ttl_seconds: float=...) -> str`
+- `is_ref(value: object) -> bool`
+- `display_ref(ref: str) -> str`
+- `get_image(ref: str, context_id: str=...) -> EphemeralImage | None`
+- `consume_image(ref: str, context_id: str=...) -> EphemeralImage | None`
+- `delete_image(ref: str) -> None`
+- `clear_context(context_id: str) -> None`
+- `_resolve_image(ref: str, context_id: str=..., consume: bool) -> EphemeralImage | None`
+- `_compact_base64(data: str) -> str`
+- `_normalize_mime(mime: str) -> str`
+- `_prune_expired_locked(now: float | None=...) -> None`
+- Notable constants/configuration names: `REF_PREFIX`, `DEFAULT_TTL_SECONDS`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem deletion.
+- Imported dependency areas include: `__future__`, `base64`, `dataclasses`, `threading`, `time`, `uuid`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `dataclass`, `threading.RLock`, `base64.b64encode.decode`, `put_image`, `_compact_base64`, `base64.b64decode`, `_normalize_mime`, `time.time`, `EphemeralImage`, `str.strip.startswith`, `str.strip`, `_resolve_image`, `join`, `str.strip.lower`, `ValueError`, `_prune_expired_locked`, `is_ref`, `_store.pop`, `value.startswith`, `display_ref`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_browser_agent_regressions.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/errors.py b/helpers/errors.py
index 205e9a5bd626c8aaea01706b4dfa4603c203a8a1..be56263d940941d7a5dbbeebbaa1b5c08b3d5c80 100644
--- a/helpers/errors.py
+++ b/helpers/errors.py
@@ -1,3 +1,96 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:87aadfeddc98f269eb7d3be0b48a227dadebfa7b86b8986be551fe69772a7f7f
-size 3131
+import re
+import traceback
+import asyncio
+from typing import Literal
+
+
+def handle_error(e: Exception):
+ # if asyncio.CancelledError, re-raise
+ if isinstance(e, asyncio.CancelledError):
+ raise e
+
+
+def error_text(e: Exception):
+ return str(e)
+
+
+def format_error(
+ e: Exception,
+ start_entries=20,
+ end_entries=15,
+ error_message_position: Literal["top", "bottom", "none"] = "top",
+):
+ # format traceback from the provided exception instead of the most recent one
+ traceback_text = "".join(traceback.format_exception(type(e), e, e.__traceback__))
+ # Split the traceback into lines
+ lines = traceback_text.split("\n")
+
+ if not start_entries and not end_entries:
+ trimmed_lines = []
+ else:
+
+ # Find all "File" lines
+ file_indices = [
+ i for i, line in enumerate(lines) if line.strip().startswith("File ")
+ ]
+
+ # If we found at least one "File" line, trim the middle if there are more than start_entries+end_entries lines
+ if len(file_indices) > start_entries + end_entries:
+ start_index = max(0, len(file_indices) - start_entries - end_entries)
+ trimmed_lines = (
+ lines[: file_indices[start_index]]
+ + [
+ f"\n>>> {len(file_indices) - start_entries - end_entries} stack lines skipped <<<\n"
+ ]
+ + lines[file_indices[start_index + end_entries] :]
+ )
+ else:
+ # If no "File" lines found, or not enough to trim, just return the original traceback
+ trimmed_lines = lines
+
+ # Find the error message at the end
+ error_message = ""
+ for line in reversed(lines):
+ # match both simple errors and module.path.Error patterns
+ if re.match(r"[\w\.]+Error:\s*", line):
+ error_message = line
+ break
+
+ if error_message and error_message_position in ("top", "bottom", "none"):
+ for i in range(len(trimmed_lines) - 1, -1, -1):
+ if trimmed_lines[i].strip() == error_message.strip():
+ trimmed_lines = trimmed_lines[:i] + trimmed_lines[i + 1 :]
+ break
+
+ # Combine the trimmed traceback with the error message
+ if not trimmed_lines:
+ result = "" if error_message_position == "none" else error_message
+ else:
+ result = "Traceback (most recent call last):\n" + "\n".join(trimmed_lines)
+
+ if error_message and error_message_position == "top":
+ result = f"{error_message}\n\n{result}" if result else error_message
+ elif error_message and error_message_position == "bottom":
+ result = f"{result}\n\n{error_message}" if result else error_message
+
+ # at least something
+ if not result:
+ result = str(e)
+
+ return result
+
+
+class RepairableException(Exception):
+ """An exception type indicating errors that can be surfaced to the LLM for potential self-repair."""
+
+ pass
+
+
+class InterventionException(Exception):
+ """An exception type raised on user intervention, skipping rest of message loop iteration."""
+
+ pass
+
+
+class HandledException(Exception):
+ pass
diff --git a/helpers/errors.py.dox.md b/helpers/errors.py.dox.md
index bb2805ff60ca414fb9bbb0915e48130160e4c143..c551fc8d57008c21d2f8acedc4353c7ed16c6bba 100644
--- a/helpers/errors.py.dox.md
+++ b/helpers/errors.py.dox.md
@@ -1,3 +1,54 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c03bf1325509845fc165838e32fafac392f099bf25fefcd2dd5071e64c1c9adb
-size 2377
+# errors.py DOX
+
+## Purpose
+
+- Own the `errors.py` helper module.
+- This module defines framework exceptions and safe error formatting.
+- Keep this file-level DOX profile synchronized with `errors.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `errors.py` owns the runtime implementation.
+- `errors.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `RepairableException` (`Exception`)
+- `InterventionException` (`Exception`)
+- `HandledException` (`Exception`)
+- Top-level functions:
+- `handle_error(e: Exception)`
+- `error_text(e: Exception)`
+- `format_error(e: Exception, start_entries=..., end_entries=..., error_message_position: Literal['top', 'bottom', 'none']=...)`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Imported dependency areas include: `asyncio`, `re`, `traceback`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `join`, `traceback_text.split`, `traceback.format_exception`, `re.match`, `type`, `line.strip.startswith`, `trimmed_lines.strip`, `error_message.strip`, `line.strip`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_fasta2a_client.py`
+ - `tests/test_host_browser_connector.py`
+ - `tests/test_office_desktop_state.py`
+ - `tests/test_office_document_store.py`
+ - `tests/test_skills_runtime.py`
+ - `tests/test_speech_plugin_split.py`
+ - `tests/test_time_travel.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/extension.py b/helpers/extension.py
index f3efd1f6592f5803e9321d0d58fe08c902e4e675..84b1e980998e6ce9d6ec657019a6a030c88f1e1a 100644
--- a/helpers/extension.py
+++ b/helpers/extension.py
@@ -1,3 +1,407 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:aaad4454947a431dc257240f54cdf9ded4a05d7cbb0810bef9d6bf5a643a4fe1
-size 13015
+from abc import abstractmethod
+from typing import Any, Awaitable, Type, cast
+from helpers import modules, files
+from helpers import cache
+from typing import TYPE_CHECKING
+from functools import wraps
+import inspect
+import os
+
+from helpers.print_style import PrintStyle
+
+if TYPE_CHECKING:
+ from agent import Agent
+
+
+DEFAULT_EXTENSIONS_FOLDER = "python/extensions"
+USER_EXTENSIONS_FOLDER = "usr/extensions"
+
+_EXTENSIONS_CACHE_AREA = "extension_folder_classes(extensions)"
+_CLASSES_CACHE_AREA = "extension_classes(extensions)"
+_WEBUI_MANIFEST_CACHE_AREA = "webui_extension_manifest(extensions)(plugins)"
+_WEBUI_MANIFEST_SUFFIXES = {
+ "html": (".html", ".htm", ".xhtml"),
+ "js": (".js", ".mjs"),
+}
+# cache.toggle_area(_EXTENSIONS_CACHE_AREA, False)
+# cache.toggle_area(_CLASSES_CACHE_AREA, False)
+
+
+class _Unset:
+ pass
+
+
+_UNSET = _Unset()
+_EXTENSIONS_LOG_COUNTS: dict[str, int] = {}
+
+
+# debug - extensions call counter
+def _log_extension_call(name: str):
+ try:
+ every = int(os.getenv("EXTENSIONS_LOG", "0"))
+ except ValueError:
+ return
+
+ if every <= 0:
+ return
+
+ _EXTENSIONS_LOG_COUNTS[name] = _EXTENSIONS_LOG_COUNTS.get(name, 0) + 1
+ _EXTENSIONS_LOG_COUNTS["_total"] = _EXTENSIONS_LOG_COUNTS.get("_total", 0) + 1
+
+ if _EXTENSIONS_LOG_COUNTS["_total"] % every == 0:
+ for key, count in _EXTENSIONS_LOG_COUNTS.items():
+ print(f"{str(count):<6} {key}")
+
+
+# decorator to enable implicit extension points in existing functions
+def extensible(func):
+ """Make a function emit two implicit extension points around its execution.
+
+ The decorator derives two extension point folder paths from the wrapped
+ function:
+
+ - ``_functions///start``
+ - ``_functions///end``
+
+ Module path segments come from ``func.__module__`` split by ``.``.
+ Qualname path segments come from the full nested ``func.__qualname__`` split
+ by ``.``, excluding ````.
+
+ Example:
+
+ - module ``helpers.something``
+ - qualname ``Outer.Inner.__init__``
+
+ becomes:
+
+ - ``_functions/helpers/something/Outer/Inner/__init__/start``
+ - ``_functions/helpers/something/Outer/Inner/__init__/end``
+
+ When the wrapped function is called, the decorator builds a mutable ``data``
+ payload and passes it to both extension points:
+
+ - ``data["args"]``: positional args (extensions may replace/mutate)
+ - ``data["kwargs"]``: keyword args (extensions may replace/mutate)
+ - ``data["result"]``: initialized to an internal sentinel; extensions may set
+ this to short-circuit the wrapped function
+ - ``data["exception"]``: initialized to an internal sentinel; extensions may
+ set this to a ``BaseException`` instance to force-raise
+
+ Sync functions call ``call_extensions_sync``. Async functions call
+ ``call_extensions_async``.
+
+ Behavior:
+
+ - ``start`` extensions run first and may mutate inputs or set
+ ``data["result"]`` / ``data["exception"]``.
+ - If ``data["result"]`` is still unset, the decorator calls the wrapped
+ function using the possibly modified ``data["args"]`` / ``data["kwargs"]``.
+ - ``end`` extensions run last and may rewrite ``data["result"]`` or replace /
+ clear ``data["exception"]``.
+
+ Finally, if ``data["exception"]`` contains an exception it is raised;
+ otherwise ``data["result"]`` is returned.
+ """
+
+ def _get_agent(args, kwargs):
+ from agent import Agent
+
+ candidate = kwargs.get("agent")
+ if isinstance(candidate, Agent) and bool(getattr(candidate, "__dict__", None)):
+ return candidate
+
+ for a in args:
+ if isinstance(a, Agent) and bool(getattr(a, "__dict__", None)):
+ return a
+
+ return None
+
+ def _prepare_inputs(args, kwargs):
+ module_name = getattr(func, "__module__", "")
+ qual_name = getattr(func, "__qualname__", "")
+ if not module_name or not qual_name:
+ return None
+
+ module_parts = [part for part in module_name.split(".") if part]
+ qual_parts = [part for part in qual_name.split(".") if part and part != ""]
+ if not module_parts or not qual_parts:
+ return None
+
+ base_path = os.path.join("_functions", *module_parts, *qual_parts)
+ start_point = os.path.join(base_path, "start")
+ end_point = os.path.join(base_path, "end")
+
+ agent = _get_agent(args, kwargs)
+
+ data = {
+ "args": args,
+ "kwargs": kwargs,
+ "result": _UNSET,
+ "exception": None,
+ }
+
+ return start_point, end_point, agent, data
+
+ def _process_result(data):
+ exc = data.get("exception")
+ if isinstance(exc, BaseException):
+ raise exc
+
+ return data.get("result")
+
+ def _call_original(data):
+ call_args = data.get("args")
+ call_kwargs = data.get("kwargs")
+
+ if not isinstance(call_args, tuple):
+ call_args = (call_args,)
+ if not isinstance(call_kwargs, dict):
+ call_kwargs = {}
+
+ try:
+ data["result"] = func(*call_args, **call_kwargs)
+ except Exception as e:
+ data["exception"] = e
+ return _UNSET
+
+ async def _run_async(*args, **kwargs):
+ prepared = _prepare_inputs(args, kwargs)
+ if prepared is None:
+ return await func(*args, **kwargs)
+
+ start_point, end_point, agent, data = prepared
+
+ # call pre-extensions
+ await call_extensions_async(start_point, agent=agent, data=data)
+
+ # call the original if pre-extensions don't return a result
+ if (result := _process_result(data)) is _UNSET:
+ _call_original(data)
+ try:
+ data["result"] = await data["result"]
+ except Exception as e:
+ data["exception"] = e
+
+ # call post-extensions
+ await call_extensions_async(end_point, agent=agent, data=data)
+
+ result = _process_result(data)
+ return None if result is _UNSET else result
+
+ def _run_sync(*args, **kwargs):
+ prepared = _prepare_inputs(args, kwargs)
+ if prepared is None:
+ return func(*args, **kwargs)
+
+ start_point, end_point, agent, data = prepared
+
+ # call pre-extensions
+ call_extensions_sync(start_point, agent=agent, data=data)
+
+ # call the original if pre-extensions don't return a result
+ if (result := _process_result(data)) is _UNSET:
+ _call_original(data)
+
+ # call post-extensions
+ call_extensions_sync(end_point, agent=agent, data=data)
+
+ result = _process_result(data)
+ return None if result is _UNSET else result
+
+ if inspect.iscoroutinefunction(func):
+ return wraps(func)(_run_async)
+
+ return wraps(func)(_run_sync)
+
+
+class Extension:
+
+ def __init__(self, agent: "Agent|None", **kwargs):
+ self.agent: "Agent|None" = agent
+ self.kwargs = kwargs
+
+ @abstractmethod
+ def execute(self, **kwargs) -> None | Awaitable[None]:
+ pass
+
+
+async def call_extensions_async(
+ extension_point: str, agent: "Agent|None" = None, **kwargs
+):
+ _log_extension_call(extension_point)
+
+ # fetch classes for this extension point and agent
+ classes = _get_extension_classes(extension_point, agent=agent, **kwargs)
+
+ # execute unique extensions
+ for cls in classes:
+ result = cls(agent=agent).execute(**kwargs)
+ if isinstance(result, Awaitable):
+ await result
+
+
+def call_extensions_sync(extension_point: str, agent: "Agent|None" = None, **kwargs):
+ _log_extension_call(extension_point)
+
+ # fetch classes for this extension point and agent
+ classes = _get_extension_classes(extension_point, agent=agent, **kwargs)
+
+ # execute unique extensions
+ for cls in classes:
+ result = cls(agent=agent).execute(**kwargs)
+ if isinstance(result, Awaitable):
+ raise ValueError(
+ f"Extension {cls.__name__} returned awaitable in sync mode"
+ )
+
+
+def get_webui_extensions(
+ agent: "Agent | None", extension_point: str, filters: list[str] | None = None
+):
+ from helpers import subagents
+
+ entries: list[str] = []
+ effective_filters = filters or ["*"]
+
+ # search for extension folders in all agent's paths
+ folders = subagents.get_paths(
+ agent,
+ "extensions/webui",
+ extension_point,
+ )
+
+ extensions = []
+
+ for folder in folders:
+ for filter in effective_filters:
+ pattern = files.get_abs_path(folder, filter)
+ extensions.extend(files.find_existing_paths_by_pattern(pattern))
+
+ for extension in extensions:
+ rel_path = files.deabsolute_path(extension)
+ entries.append(rel_path)
+
+ return entries
+
+
+def get_webui_extension_manifest(
+ agent: "Agent | None",
+) -> dict[str, dict[str, list[str]]]:
+ """Return every WebUI extension URL grouped by asset type and extension point."""
+ from helpers import subagents
+
+ cache_key = cache.determine_cache_key(agent)
+ cached = cache.get(_WEBUI_MANIFEST_CACHE_AREA, cache_key)
+ if cached is not None:
+ return cached
+
+ manifest: dict[str, dict[str, list[str]]] = {
+ asset_type: {} for asset_type in _WEBUI_MANIFEST_SUFFIXES
+ }
+ roots = subagents.get_paths(agent, "extensions/webui")
+ for root in roots:
+ relative_files = sorted(files.list_files_in_dir_recursively(root))
+ for asset_type, suffixes in _WEBUI_MANIFEST_SUFFIXES.items():
+ for suffix in suffixes:
+ for relative_file in relative_files:
+ if not relative_file.lower().endswith(suffix):
+ continue
+ extension_point = os.path.dirname(relative_file).replace(
+ os.sep, "/"
+ )
+ if not extension_point or extension_point == ".":
+ continue
+ absolute_path = files.get_abs_path(root, relative_file)
+ relative_path = files.deabsolute_path(absolute_path).replace(
+ os.sep, "/"
+ )
+ manifest[asset_type].setdefault(extension_point, []).append(
+ "/" + relative_path.lstrip("/")
+ )
+
+ cache.add(_WEBUI_MANIFEST_CACHE_AREA, cache_key, manifest)
+ return manifest
+
+
+def _get_extension_classes(
+ extension_point: str, agent: "Agent|None" = None, **kwargs
+) -> list[Type[Extension]]:
+ from helpers import subagents
+
+ cache_key = cache.determine_cache_key(agent, extension_point)
+ cached = cache.get(_CLASSES_CACHE_AREA, cache_key)
+ if cached is not None:
+ return cached
+
+ # search for extension folders in all agent's paths
+ paths = subagents.get_paths(agent, "extensions/python", extension_point)
+
+ all_exts = [cls for path in paths for cls in _get_extensions(path)]
+
+ # merge: first ocurrence of file name is the override
+ unique = {}
+ for cls in all_exts:
+ file = _get_file_from_module(cls.__module__)
+ if file not in unique:
+ unique[file] = cls
+ classes = sorted(
+ unique.values(), key=lambda cls: _get_file_from_module(cls.__module__)
+ )
+ cache.add(_CLASSES_CACHE_AREA, cache_key, classes)
+ return classes
+
+
+def _get_file_from_module(module_name: str) -> str:
+ return module_name.split(".")[-1]
+
+
+def _get_extensions(folder: str):
+ folder = files.get_abs_path(folder)
+ cached = cache.get(_EXTENSIONS_CACHE_AREA, folder)
+ if cached is not None:
+ return cached
+
+ if not files.exists(folder):
+ return []
+
+ classes = modules.load_classes_from_folder(folder, "*", Extension)
+ cache.add(_EXTENSIONS_CACHE_AREA, folder, classes)
+ return classes
+
+
+def register_extensions_watchdogs():
+ from helpers import watchdog, projects
+
+ def extensions_changed(items: list[watchdog.WatchItem]):
+ cache.clear(_EXTENSIONS_CACHE_AREA)
+ cache.clear(_CLASSES_CACHE_AREA)
+ PrintStyle.debug("Extensions watchdog triggered:", items)
+
+ # extensions and usr/extensions
+ watchdog.add_watchdog(
+ id="extensions_base",
+ roots=[
+ files.get_abs_path(files.EXTENSIONS_DIR),
+ files.get_abs_path(files.USER_DIR, files.EXTENSIONS_DIR),
+ ],
+ handler=extensions_changed,
+ )
+
+ # usr/projects/**/extensions
+ watchdog.add_watchdog(
+ id="extensions_projects",
+ roots=[projects.PROJECTS_PARENT_DIR],
+ patterns=[f"*/{projects.PROJECT_META_DIR}/**/{files.EXTENSIONS_DIR}/**/*"],
+ handler=extensions_changed,
+ )
+
+ # agents and usr/agents
+ watchdog.add_watchdog(
+ id="extensions_agents",
+ roots=[
+ files.get_abs_path(files.AGENTS_DIR),
+ files.get_abs_path(files.USER_DIR, files.AGENTS_DIR),
+ ],
+ patterns=[f"*/{files.EXTENSIONS_DIR}/**/*"],
+ handler=extensions_changed,
+ )
diff --git a/helpers/extension.py.dox.md b/helpers/extension.py.dox.md
index 7b774324576b2ab023edbfff226b5a9730045f84..01b94c733f385a330c6e9ed80c7cb39919530404 100644
--- a/helpers/extension.py.dox.md
+++ b/helpers/extension.py.dox.md
@@ -1,3 +1,65 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:682493e9dc8e33f23ef951207384496fbbbb2ebe8c7fd8922d48dc029b795694
-size 3874
+# extension.py DOX
+
+## Purpose
+
+- Own the `extension.py` helper module.
+- This module discovers and dispatches Python and WebUI extension hooks.
+- Keep this file-level DOX profile synchronized with `extension.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `extension.py` owns the runtime implementation.
+- `extension.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `_Unset` (no explicit base class)
+- `Extension` (no explicit base class)
+ - `execute(self, **kwargs) -> None | Awaitable[None]`
+- Top-level functions:
+- `_log_extension_call(name: str)`
+- `extensible(func)`: Make a function emit two implicit extension points around its execution.
+- `async call_extensions_async(extension_point: str, agent: 'Agent|None'=..., **kwargs)`
+- `call_extensions_sync(extension_point: str, agent: 'Agent|None'=..., **kwargs)`
+- `get_webui_extensions(agent: 'Agent | None', extension_point: str, filters: list[str] | None=...)`
+- `get_webui_extension_manifest(agent: 'Agent | None') -> dict[str, dict[str, list[str]]]`
+- `_get_extension_classes(extension_point: str, agent: 'Agent|None'=..., **kwargs) -> list[Type[Extension]]`
+- `_get_file_from_module(module_name: str) -> str`
+- `_get_extensions(folder: str)`
+- `register_extensions_watchdogs()`
+- Notable constants/configuration names: `DEFAULT_EXTENSIONS_FOLDER`, `USER_EXTENSIONS_FOLDER`, `_EXTENSIONS_CACHE_AREA`, `_CLASSES_CACHE_AREA`, `_WEBUI_MANIFEST_CACHE_AREA`, `_WEBUI_MANIFEST_SUFFIXES`, `_UNSET`, `_EXTENSIONS_LOG_COUNTS`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- `Extension` defines `execute(...)`.
+- Observed side-effect areas: filesystem reads, WebSocket state, plugin state.
+- Imported dependency areas include: `abc`, `functools`, `helpers`, `helpers.print_style`, `inspect`, `os`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `_Unset`, `inspect.iscoroutinefunction`, `wraps`, `_log_extension_call`, `_get_extension_classes`, `subagents.get_paths`, `cache.determine_cache_key`, `cache.add`, `files.get_abs_path`, `modules.load_classes_from_folder`, `watchdog.add_watchdog`, `os.path.join`, `_get_agent`, `_prepare_inputs`, `_process_result`, `call_extensions_sync`, `cls.execute`, `files.deabsolute_path`, `_get_file_from_module`, `module_name.split`.
+- `get_webui_extension_manifest()` scans enabled WebUI extension roots once, preserves root/filter ordering, groups URLs into `html` and `js` maps by extension point, and caches under extension/plugin invalidation scopes for injection into the rendered index.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_a0_connector_prompt_gating.py`
+ - `tests/test_api_chat_lifetime.py`
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_error_retry_plugin.py`
+ - `tests/test_extensions_stress.py`
+ - `tests/test_history_compression_wait.py`
+ - `tests/test_model_config_api_keys.py`
+ - `tests/test_oauth_codex.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/extract_tools.py b/helpers/extract_tools.py
index cbaa6f70fccdd11ff6491a96668e482b834e7d6a..3cca6a95cd70348b3fc9b9a5c79f0b755a85f812 100644
--- a/helpers/extract_tools.py
+++ b/helpers/extract_tools.py
@@ -1,3 +1,248 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:8a493731d8ab4cd3200c59cd31957cd94d876c659a652cad0b420061308daedb
-size 7718
+
+from .dirty_json import DirtyJson
+import regex, re
+from helpers.modules import load_classes_from_file, load_classes_from_folder # keep here for backwards compatibility
+from typing import Any
+
+def json_parse_dirty(json: str) -> dict[str, Any] | None:
+ if not json or not isinstance(json, str):
+ return None
+
+ first_data: dict[str, Any] | None = None
+ for ext_json in extract_json_root_strings(json.strip()):
+ data = _parse_json_root_object(ext_json)
+ if data is None:
+ continue
+ if first_data is None:
+ first_data = data
+ if _is_tool_request(data):
+ return data
+ return first_data
+
+
+def extract_tool_request(content: str) -> dict[str, Any] | None:
+ if not content or not isinstance(content, str):
+ return None
+
+ content = content.strip()
+ if not (content.startswith("{") and content.endswith("}")):
+ return None
+
+ root = extract_json_root_string(content)
+ if root != content:
+ return None
+
+ request = _parse_json_root_object(root)
+ return request if request is not None and _is_tool_request(request) else None
+
+
+def is_misformatted_tool_request(content: str) -> bool:
+ if not content or not isinstance(content, str):
+ return False
+
+ content = content.strip()
+ roots = extract_json_root_strings(content)
+ if (
+ len(roots) > 1
+ and content.startswith("{")
+ and content.endswith("}")
+ and any(extract_tool_request(root) is not None for root in roots)
+ ):
+ return True
+
+ for fenced_content in re.findall(
+ r"```(?:json)?\s*(.*?)```", content, flags=re.IGNORECASE | re.DOTALL
+ ):
+ request = json_parse_dirty(fenced_content)
+ if isinstance(request, dict) and _is_tool_request(request):
+ return True
+
+ if (
+ not content.endswith("}")
+ or re.match(r'^\{\s*"thoughts"\s*:', content) is None
+ ):
+ return False
+
+ request = json_parse_dirty(content)
+ thoughts = request.get("thoughts") if isinstance(request, dict) else None
+ thoughts_text = (
+ "\n".join(thought for thought in thoughts if isinstance(thought, str))
+ if isinstance(thoughts, list)
+ else ""
+ )
+ return (
+ isinstance(thoughts, list)
+ and all(
+ f'{field}\":' in thoughts_text
+ for field in ("headline", "tool_name", "tool_args")
+ )
+ )
+
+
+def normalize_tool_request(tool_request: Any) -> tuple[str, dict]:
+ if not isinstance(tool_request, dict):
+ raise ValueError("Tool request must be a dictionary")
+ if (
+ not tool_request.get("tool_name")
+ and not tool_request.get("tool")
+ and "actions" in tool_request
+ ):
+ actions = tool_request["actions"]
+ # Text tool calls allow one request per turn; do not silently discard extras.
+ if (
+ not isinstance(actions, list)
+ or len(actions) != 1
+ or not isinstance(actions[0], dict)
+ ):
+ raise ValueError(
+ "Tool request actions wrapper must contain exactly one dictionary"
+ )
+ tool_request = actions[0]
+
+ tool_name = tool_request.get("tool_name")
+ if not tool_name or not isinstance(tool_name, str):
+ tool_name = tool_request.get("tool")
+ if (
+ (not tool_name or not isinstance(tool_name, str))
+ and tool_request.get("type") == "function"
+ ):
+ tool_name = tool_request.get("name")
+ if not tool_name or not isinstance(tool_name, str):
+ raise ValueError("Tool request must have a tool_name (type string) field")
+ tool_args = tool_request.get("tool_args")
+ if not isinstance(tool_args, dict):
+ tool_args = tool_request.get("args")
+ if not isinstance(tool_args, dict) and tool_request.get("type") == "function":
+ tool_args = tool_request.get("parameters")
+ if not isinstance(tool_args, dict):
+ raise ValueError("Tool request must have a tool_args (type dictionary) field")
+ tool_args = dict(tool_args)
+ if ":" in tool_name:
+ tool_name, action = tool_name.split(":", 1)
+ if not tool_name or not action:
+ raise ValueError("tool_name method suffix must include tool and action")
+ tool_args.setdefault("action", action)
+ method = tool_args.get("method")
+ if "action" not in tool_args and isinstance(method, str) and method:
+ tool_args["action"] = method
+ return tool_name, tool_args
+
+
+def extract_json_root_string(content: str) -> str | None:
+ first_root: str | None = None
+ for root in extract_json_root_strings(content):
+ if first_root is None:
+ first_root = root
+ data = _parse_json_root_object(root)
+ if data is not None and _is_tool_request(data):
+ return root
+ return first_root
+
+
+def extract_json_root_strings(content: str) -> list[str]:
+ if not content or not isinstance(content, str):
+ return []
+
+ if content.lstrip().startswith("["):
+ return []
+
+ roots: list[str] = []
+ for start in _json_root_object_starts(content):
+ parser = DirtyJson()
+ try:
+ parser.parse(content[start:])
+ except Exception:
+ continue
+
+ if not parser.completed:
+ continue
+
+ roots.append(content[start : start + parser.index])
+ return roots
+
+
+def _json_root_object_starts(content: str) -> list[int]:
+ starts: list[int] = []
+ depth = 0
+ quote: str | None = None
+ escaped = False
+
+ for index, char in enumerate(content):
+ if quote:
+ if escaped:
+ escaped = False
+ elif char == "\\":
+ escaped = True
+ elif char == quote:
+ quote = None
+ continue
+
+ if depth and char in ['"', "'", "`"]:
+ quote = char
+ elif char == "{":
+ if depth == 0:
+ starts.append(index)
+ depth += 1
+ elif depth and char == "[":
+ depth += 1
+ elif depth and char in ["}", "]"]:
+ depth -= 1
+
+ return starts
+
+
+def _parse_json_root_object(root: str) -> dict[str, Any] | None:
+ try:
+ data = DirtyJson.parse_string(root)
+ except Exception:
+ return None
+ return data if isinstance(data, dict) else None
+
+
+def _is_tool_request(data: dict[str, Any]) -> bool:
+ try:
+ normalize_tool_request(data)
+ except ValueError:
+ return False
+ return True
+
+
+def extract_json_object_string(content):
+ start = content.find("{")
+ if start == -1:
+ return ""
+
+ # Find the first '{'
+ end = content.rfind("}")
+ if end == -1:
+ # If there's no closing '}', return from start to the end
+ return content[start:]
+ else:
+ # If there's a closing '}', return the substring from start to end
+ return content[start : end + 1]
+
+
+def extract_json_string(content):
+ # Regular expression pattern to match a JSON object
+ pattern = r'\{(?:[^{}]|(?R))*\}|\[(?:[^\[\]]|(?R))*\]|"(?:\\.|[^"\\])*"|true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?'
+
+ # Search for the pattern in the content
+ match = regex.search(pattern, content)
+
+ if match:
+ # Return the matched JSON string
+ return match.group(0)
+ else:
+ return ""
+
+
+def fix_json_string(json_string):
+ # Function to replace unescaped line breaks within JSON string values
+ def replace_unescaped_newlines(match):
+ return match.group(0).replace("\n", "\\n")
+
+ # Use regex to find string values and apply the replacement function
+ fixed_string = re.sub(
+ r'(?<=: ")(.*?)(?=")', replace_unescaped_newlines, json_string, flags=re.DOTALL
+ )
+ return fixed_string
diff --git a/helpers/extract_tools.py.dox.md b/helpers/extract_tools.py.dox.md
index 815251eebf0578fef74e8674d0cc495c056448f9..ad3333ea32adeb70a1f6e79c888c108e16c78635 100644
--- a/helpers/extract_tools.py.dox.md
+++ b/helpers/extract_tools.py.dox.md
@@ -1,3 +1,58 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3995106a952987248691bd480a1be65b53bcf022ff8966e936a015934f443872
-size 4014
+# extract_tools.py DOX
+
+## Purpose
+
+- Own the `extract_tools.py` helper module.
+- This module normalizes and repairs model-emitted tool-call JSON.
+- Keep this file-level DOX profile synchronized with `extract_tools.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `extract_tools.py` owns the runtime implementation.
+- `extract_tools.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `json_parse_dirty(json: str) -> dict[str, Any] | None`
+- `extract_tool_request(content: str) -> dict[str, Any] | None`
+- `is_misformatted_tool_request(content: str) -> bool`
+- `normalize_tool_request(tool_request: Any) -> tuple[str, dict]`
+- `extract_json_root_string(content: str) -> str | None`
+- `extract_json_root_strings(content: str) -> list[str]`
+- `extract_json_object_string(content)`
+- `extract_json_string(content)`
+- `fix_json_string(json_string)`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: settings/state persistence.
+- Dirty parsing scans complete JSON object roots in prose and prefers the first object that normalizes as a valid tool request for permissive repair and legacy callers.
+ Normalization accepts canonical `tool_name`/`tool_args`, legacy `tool`/`args`, native `type="function"` `name`/`parameters`, and a single-item `actions` wrapper; malformed or multi-action wrappers are rejected.
+- `extract_tool_request` is the execution boundary: it accepts a request only when the complete trimmed content is one valid tool object. Plain text, ordinary JSON, and tool-shaped JSON embedded in prose remain final text.
+- `extract_tool_request` rejects content that does not have complete object boundaries before invoking the dirty root scanner. This keeps incomplete streaming prefixes cheap without changing which complete canonical tool objects are accepted.
+- `is_misformatted_tool_request` identifies a tool request wrapped in a JSON code fence, concatenated complete roots containing tool intent, or a complete Agent Zero envelope that starts with `thoughts` and whose dirty parser has absorbed `headline`, `tool_name`, and `tool_args` into that list. It routes that output to the existing repair prompt without executing it.
+- Streaming tool snapshots use `extract_tool_request`; the permissive root helpers remain available for repair and legacy callers, not tool execution.
+- Root extraction ignores objects nested inside an open parent object, so streamed wrapper tools such as `parallel` cannot stop early on the first nested `tool_calls` item.
+- Imported dependency areas include: `dirty_json`, `helpers.modules`, `re`, `regex`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `extract_json_object_string`, `content.find`, `DirtyJson`, `content.rfind`, `regex.search`, `re.sub`, `json.strip`, `ValueError`, `tool_name.split`, `parser.parse`, `match.group`, `match.group.replace`, `DirtyJson.parse_string`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_stream_tool_early_stop.py`
+ - `tests/test_tool_request_normalization.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/faiss_monkey_patch.py b/helpers/faiss_monkey_patch.py
index 2614a87fb49046082f11edfa071616616c2d5085..5704b84f307ceb503af6385337ad831ec9095e57 100644
--- a/helpers/faiss_monkey_patch.py
+++ b/helpers/faiss_monkey_patch.py
@@ -1,3 +1,39 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c0ef3b78da4933fd7f26afbfd9601c4298abff18b7d86fcf47e0989ac3a78da6
-size 1181
+# import sys
+# from types import ModuleType, SimpleNamespace
+
+# import numpy # real numpy
+
+# # for python 3.12 on arm, faiss needs a fake cpuinfo module
+
+
+# """ This disgusting hack was brought to you by:
+# https://github.com/facebookresearch/faiss/issues/3936
+# """
+
+# faiss_monkey_patch.py – import this before faiss -----------------
+import sys, types, numpy as np
+from types import SimpleNamespace
+
+# fake numpy.distutils and numpy.distutils.cpuinfo packages
+dist = types.ModuleType("numpy.distutils")
+cpuinfo = types.ModuleType("numpy.distutils.cpuinfo")
+
+# cpu attribute that looks like the real one
+cpuinfo.cpu = SimpleNamespace( # type: ignore
+ # FAISS only does .info[0].get('Features', '')
+ info=[{}]
+)
+
+# register in sys.modules
+dist.cpuinfo = cpuinfo # type: ignore
+sys.modules["numpy.distutils"] = dist
+sys.modules["numpy.distutils.cpuinfo"] = cpuinfo
+
+# crucial: expose it as an *attribute* of the already-imported numpy package
+np.distutils = dist # type: ignore
+# -------------------------------------------------------------------
+
+import warnings
+with warnings.catch_warnings():
+ warnings.simplefilter("ignore", DeprecationWarning)
+ import faiss
\ No newline at end of file
diff --git a/helpers/faiss_monkey_patch.py.dox.md b/helpers/faiss_monkey_patch.py.dox.md
index 4e1580de39c125fed6d8ac06aadf43887eab848c..bfbacf50af37c6b388fe7a80d84a1ac1a96eb9ae 100644
--- a/helpers/faiss_monkey_patch.py.dox.md
+++ b/helpers/faiss_monkey_patch.py.dox.md
@@ -1,3 +1,38 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:740d1b0db6504475ae3deed2e203c645a22c4a6242d116df7d46083467d9234d
-size 1781
+# faiss_monkey_patch.py DOX
+
+## Purpose
+
+- Own the `faiss_monkey_patch.py` helper module.
+- This module applies compatibility patches for FAISS behavior.
+- Keep this file-level DOX profile synchronized with `faiss_monkey_patch.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `faiss_monkey_patch.py` owns the runtime implementation.
+- `faiss_monkey_patch.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Imported dependency areas include: `numpy`, `sys`, `types`, `warnings`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `types.ModuleType`, `SimpleNamespace`, `warnings.catch_warnings`, `warnings.simplefilter`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/fasta2a_client.py b/helpers/fasta2a_client.py
index eafa13b709b3d670acb2885e5af2f7ecc86082d8..2a8752b55d348f804ae28b12c423cc7b1a2eef9f 100644
--- a/helpers/fasta2a_client.py
+++ b/helpers/fasta2a_client.py
@@ -1,3 +1,209 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:68bd09c085ea59742b0e8b6b1f236978ec0950752bbb45a6c43da3a74172fb68
-size 8027
+import uuid
+from typing import Any, Dict, List, Optional
+from helpers.print_style import PrintStyle
+
+try:
+ from fasta2a.client import A2AClient # type: ignore
+ import httpx # type: ignore
+ FASTA2A_CLIENT_AVAILABLE = True
+except ImportError:
+ FASTA2A_CLIENT_AVAILABLE = False
+ PrintStyle.warning("FastA2A client not available. Agent-to-agent communication disabled.")
+
+_PRINTER = PrintStyle(italic=True, font_color="cyan", padding=False)
+
+
+class AgentConnection:
+ """Helper class for connecting to and communicating with other Agent Zero instances via FastA2A."""
+
+ def __init__(self, agent_url: str, timeout: int = 30, token: Optional[str] = None):
+ """Initialize connection to an agent.
+
+ Args:
+ agent_url: The base URL of the agent (e.g., "https://agent.example.com")
+ timeout: Request timeout in seconds
+ """
+ if not FASTA2A_CLIENT_AVAILABLE:
+ raise RuntimeError("FastA2A client not available")
+
+ # Ensure scheme is present
+ if not agent_url.startswith(('http://', 'https://')):
+ agent_url = 'http://' + agent_url
+
+ self.agent_url = agent_url.rstrip('/')
+ self.timeout = timeout
+ # Auth headers
+ if token is None:
+ import os
+ token = os.getenv("A2A_TOKEN")
+ headers = {}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ headers["X-API-KEY"] = token
+ self._http_client = httpx.AsyncClient(timeout=timeout, headers=headers) # type: ignore
+ self._a2a_client = A2AClient(base_url=self.agent_url, http_client=self._http_client) # type: ignore
+ self._agent_card: Optional[Dict[str, Any]] = None
+ # Track conversation context automatically
+ self._context_id: Optional[str] = None
+
+ async def get_agent_card(self) -> Dict[str, Any]:
+ """Retrieve the agent card from the remote agent."""
+ if self._agent_card is None:
+ try:
+ response = await self._http_client.get(f"{self.agent_url}/.well-known/agent.json")
+ response.raise_for_status()
+ self._agent_card = response.json()
+ _PRINTER.print(f"Retrieved agent card from {self.agent_url}")
+ _PRINTER.print(f"Agent: {self._agent_card.get('name', 'Unknown')}") # type: ignore
+ _PRINTER.print(f"Description: {self._agent_card.get('description', 'No description')}") # type: ignore
+ except Exception as e:
+ # Fallback: if URL contains '/a2a', try root path without it
+ if "/a2a" in self.agent_url:
+ root_url = self.agent_url.split("/a2a", 1)[0]
+ try:
+ response = await self._http_client.get(f"{root_url}/.well-known/agent.json")
+ response.raise_for_status()
+ self._agent_card = response.json()
+ _PRINTER.print(f"Retrieved agent card from {root_url}")
+ except Exception:
+ pass # swallow, will re-raise below
+ _PRINTER.print(f"[!] Could not connect to {self.agent_url}\n → Ensure the server is running and reachable.\n → Full error: {e}")
+ raise RuntimeError(f"Could not retrieve agent card: {e}")
+
+ return self._agent_card # type: ignore
+
+ async def send_message(
+ self,
+ message: str,
+ attachments: Optional[List[str]] = None,
+ context_id: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None
+ ) -> Dict[str, Any]:
+ """Send a message to the remote agent and return task response."""
+ if not self._agent_card:
+ await self.get_agent_card()
+
+ # Re-use context automatically if caller did not supply one
+ if context_id is None:
+ context_id = self._context_id
+
+ # Build message parts
+ parts = [{'kind': 'text', 'text': message}]
+
+ if attachments:
+ for attachment in attachments:
+ file_part = {'kind': 'file', 'file': {'uri': attachment}}
+ parts.append(file_part) # type: ignore
+
+ # Construct A2A message
+ a2a_message = {
+ 'role': 'user',
+ 'parts': parts,
+ 'kind': 'message',
+ 'message_id': str(uuid.uuid4())
+ }
+
+ if context_id is not None:
+ a2a_message['context_id'] = context_id
+
+ # Send using the message/send method (not send_task)
+ try:
+ response = await self._a2a_client.send_message(
+ message=a2a_message, # type: ignore
+ metadata=metadata,
+ configuration={'accepted_output_modes': ['application/json', 'text/plain'], 'blocking': True} # type: ignore
+ )
+
+ # Persist context id for subsequent calls
+ try:
+ ctx = response.get('result', {}).get('context_id') # type: ignore[index]
+ if isinstance(ctx, str):
+ self._context_id = ctx
+ except Exception:
+ pass # ignore if structure differs
+ return response # type: ignore
+ except Exception as e:
+ _PRINTER.print(f"[A2A] Error sending message: {e}")
+ raise
+
+ async def get_task(self, task_id: str) -> Dict[str, Any]:
+ """Get the status and results of a task.
+
+ Args:
+ task_id: The ID of the task to query
+
+ Returns:
+ Dictionary containing the task information
+ """
+ try:
+ response = await self._a2a_client.get_task(task_id) # type: ignore
+ return response # type: ignore
+ except Exception as e:
+ _PRINTER.print(f"Failed to get task {task_id}: {e}")
+ raise RuntimeError(f"Failed to get task: {e}")
+
+ async def wait_for_completion(self, task_id: str, poll_interval: int = 2, max_wait: int = 300) -> Dict[str, Any]:
+ """Wait for a task to complete and return the final result.
+
+ Args:
+ task_id: The ID of the task to wait for
+ poll_interval: How often to check task status (seconds)
+ max_wait: Maximum time to wait (seconds)
+
+ Returns:
+ Dictionary containing the completed task information
+ """
+ import asyncio
+
+ waited = 0
+ while waited < max_wait:
+ task_info = await self.get_task(task_id)
+
+ if 'result' in task_info:
+ task = task_info['result']
+ status = task.get('status', {})
+ state = status.get('state', 'unknown')
+
+ if state in ['completed', 'failed', 'canceled']:
+ _PRINTER.print(f"Task {task_id} finished with state: {state}")
+ return task_info
+ else:
+ _PRINTER.print(f"Task {task_id} status: {state}")
+
+ await asyncio.sleep(poll_interval)
+ waited += poll_interval
+
+ raise TimeoutError(f"Task {task_id} did not complete within {max_wait} seconds")
+
+ async def close(self):
+ """Close the HTTP client connection."""
+ await self._http_client.aclose()
+
+ async def __aenter__(self):
+ """Async context manager entry."""
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ """Async context manager exit."""
+ await self.close()
+
+
+async def connect_to_agent(agent_url: str, timeout: int = 30) -> AgentConnection:
+ """Create a connection to a remote agent.
+
+ Args:
+ agent_url: The base URL of the agent
+ timeout: Request timeout in seconds
+
+ Returns:
+ AgentConnection instance
+ """
+ connection = AgentConnection(agent_url, timeout)
+ # Verify connection by retrieving agent card
+ await connection.get_agent_card()
+ return connection
+
+
+def is_client_available() -> bool:
+ """Check if FastA2A client is available."""
+ return FASTA2A_CLIENT_AVAILABLE
diff --git a/helpers/fasta2a_client.py.dox.md b/helpers/fasta2a_client.py.dox.md
index 5cc69d7fa0ff0295da79ed53b301f565c34ab76a..e5ef5b6a8ac29a0faa58e2d40c367d871fd6c2ad 100644
--- a/helpers/fasta2a_client.py.dox.md
+++ b/helpers/fasta2a_client.py.dox.md
@@ -1,3 +1,50 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:f7ed2ea55dcdbb9a85ae7c0ce506b25c404166724da3a8de913a794c64c68b25
-size 2966
+# fasta2a_client.py DOX
+
+## Purpose
+
+- Own the `fasta2a_client.py` helper module.
+- This module connects Agent Zero to external A2A agents.
+- Keep this file-level DOX profile synchronized with `fasta2a_client.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `fasta2a_client.py` owns the runtime implementation.
+- `fasta2a_client.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `AgentConnection` (no explicit base class)
+ - `async get_agent_card(self) -> Dict[str, Any]`
+ - `async send_message(self, message: str, attachments: Optional[List[str]]=..., context_id: Optional[str]=..., metadata: Optional[Dict[str, Any]]=...) -> Dict[str, Any]`
+ - `async get_task(self, task_id: str) -> Dict[str, Any]`
+ - `async wait_for_completion(self, task_id: str, poll_interval: int=..., max_wait: int=...) -> Dict[str, Any]`
+ - `async close(self)`
+- Top-level functions:
+- `async connect_to_agent(agent_url: str, timeout: int=...) -> AgentConnection`: Create a connection to a remote agent.
+- `is_client_available() -> bool`: Check if FastA2A client is available.
+- Notable constants/configuration names: `_PRINTER`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem writes, network calls, WebSocket state, settings/state persistence, secret handling, scheduler state.
+- Imported dependency areas include: `helpers.print_style`, `typing`, `uuid`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `PrintStyle`, `AgentConnection`, `PrintStyle.warning`, `agent_url.rstrip`, `httpx.AsyncClient`, `A2AClient`, `TimeoutError`, `connection.get_agent_card`, `RuntimeError`, `agent_url.startswith`, `os.getenv`, `self._http_client.aclose`, `self.close`, `response.raise_for_status`, `response.json`, `self.get_agent_card`, `uuid.uuid4`, `self._a2a_client.send_message`, `self._a2a_client.get_task`, `self.get_task`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/fasta2a_server.py b/helpers/fasta2a_server.py
index b1d6443dccdd84f7306afd6a3ba79c415c2c1b43..dfac9da24dd0e3d8eaca13dbbb33b84b8f13aefa 100644
--- a/helpers/fasta2a_server.py
+++ b/helpers/fasta2a_server.py
@@ -1,3 +1,604 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1c4341eb068c1c5ae4515e8db3212966c5ef288e714abe973d30da3b6cba232e
-size 23466
+# noqa: D401 (docstrings) – internal helper
+import asyncio
+import uuid
+import atexit
+import json
+from typing import Any, List
+import contextlib
+import threading
+
+from helpers import settings, projects
+from starlette.responses import Response as StarletteResponse
+from starlette.requests import Request
+
+# Local imports
+from helpers.print_style import PrintStyle
+from agent import AgentContext, UserMessage, AgentContextType
+from initialize import initialize_agent
+from helpers.persist_chat import remove_chat
+
+# Import FastA2A
+try:
+ from fasta2a import Worker, FastA2A # type: ignore
+ from fasta2a.broker import InMemoryBroker # type: ignore
+ from fasta2a.storage import InMemoryStorage # type: ignore
+ from fasta2a.schema import Message, Artifact, AgentProvider, Skill # type: ignore
+ FASTA2A_AVAILABLE = True
+except ImportError: # pragma: no cover – library not installed
+ FASTA2A_AVAILABLE = False
+ # Minimal stubs for type checkers when FastA2A is not available
+
+ class Worker: # type: ignore
+ def __init__(self, **kwargs):
+ pass
+
+ async def run_task(self, params):
+ pass
+
+ async def cancel_task(self, params):
+ pass
+
+ def build_message_history(self, history):
+ return []
+
+ def build_artifacts(self, result):
+ return []
+
+ class FastA2A: # type: ignore
+ def __init__(self, **kwargs):
+ pass
+
+ async def __call__(self, scope, receive, send):
+ pass
+
+ class InMemoryBroker: # type: ignore
+ pass
+
+ class InMemoryStorage: # type: ignore
+ async def update_task(self, **kwargs):
+ pass
+
+ Message = Artifact = AgentProvider = Skill = Any # type: ignore
+
+_PRINTER = PrintStyle(italic=True, font_color="purple", padding=False)
+
+
+def _enable_streaming_capability(agent_card_body: bytes) -> bytes:
+ """Return an agent-card JSON body with A2A streaming enabled."""
+ agent_card = json.loads(agent_card_body)
+ capabilities = agent_card.get("capabilities")
+ if not isinstance(capabilities, dict):
+ capabilities = {}
+ agent_card["capabilities"] = capabilities
+ capabilities["streaming"] = True
+ return json.dumps(agent_card, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
+
+
+class AgentZeroFastA2A(FastA2A): # type: ignore[misc]
+ """FastA2A app with Agent Zero defaults layered over library defaults."""
+
+ async def _agent_card_endpoint(self, request: Request) -> StarletteResponse:
+ response = await super()._agent_card_endpoint(request)
+ body = _enable_streaming_capability(response.body)
+ self._agent_card_json_schema = body
+ return StarletteResponse(content=body, media_type="application/json")
+
+
+class AgentZeroWorker(Worker): # type: ignore[misc]
+ """Agent Zero implementation of FastA2A Worker."""
+
+ def __init__(self, broker, storage):
+ super().__init__(broker=broker, storage=storage)
+ self.storage = storage
+
+ async def run_task(self, params: Any) -> None: # params: TaskSendParams
+ """Execute a task by processing the message through Agent Zero."""
+ context = None
+ try:
+ task_id = params['id']
+ message = params['message']
+
+ _PRINTER.print(f"[A2A] Processing task {task_id} with new temporary context")
+
+ # Convert A2A message to Agent Zero format
+ agent_message = self._convert_message(message)
+
+ # Always create new temporary context for this A2A conversation
+ cfg = initialize_agent()
+ context = AgentContext(cfg, type=AgentContextType.BACKGROUND)
+
+ # Retrieve project from message.metadata (standard A2A pattern)
+ metadata = message.get('metadata', {}) or {}
+ project_name = metadata.get('project')
+
+ # Activate project if specified
+ if project_name:
+ projects.activate_project(context.id, project_name)
+
+ # Log user message so it appears instantly in UI chat window
+ context.log.log(
+ type="user", # type: ignore[arg-type]
+ heading="Remote user message",
+ content=agent_message.message,
+ kvps={"from": "A2A"},
+ )
+
+ # Process message through Agent Zero (includes response)
+ task = context.communicate(agent_message)
+ result_text = await task.result()
+
+ # Build A2A message from result
+ response_message: Message = { # type: ignore
+ 'role': 'agent',
+ 'parts': [{'kind': 'text', 'text': str(result_text)}],
+ 'kind': 'message',
+ 'message_id': str(uuid.uuid4())
+ }
+
+ await self.storage.update_task( # type: ignore[attr-defined]
+ task_id=task_id,
+ state='completed',
+ new_messages=[response_message]
+ )
+
+ # Clean up context like non-persistent MCP chats
+ context.reset()
+ AgentContext.remove(context.id)
+ remove_chat(context.id)
+
+ _PRINTER.print(f"[A2A] Completed task {task_id} and cleaned up context")
+
+ except Exception as e:
+ _PRINTER.print(f"[A2A] Error processing task {params.get('id', 'unknown')}: {e}")
+ await self.storage.update_task(
+ task_id=params.get('id', 'unknown'),
+ state='failed'
+ )
+
+ # Clean up context even on failure to prevent resource leaks
+ if context:
+ context.reset()
+ AgentContext.remove(context.id)
+ remove_chat(context.id)
+ _PRINTER.print(f"[A2A] Cleaned up failed context {context.id}")
+
+ async def cancel_task(self, params: Any) -> None: # params: TaskIdParams
+ """Cancel a running task."""
+ task_id = params['id']
+ _PRINTER.print(f"[A2A] Cancelling task {task_id}")
+ await self.storage.update_task(task_id=task_id, state='canceled') # type: ignore[attr-defined]
+
+ # Note: No context cleanup needed since contexts are always temporary and cleaned up in run_task
+
+ def build_message_history(self, history: List[Any]) -> List[Message]: # type: ignore
+ # Not used in this simplified implementation
+ return []
+
+ def build_artifacts(self, result: Any) -> List[Artifact]: # type: ignore
+ # No artifacts for now
+ return []
+
+ def _convert_message(self, a2a_message: Message) -> UserMessage: # type: ignore
+ """Convert A2A message to Agent Zero UserMessage."""
+ # Extract text from message parts
+ text_parts = [part.get('text', '') for part in a2a_message.get('parts', []) if part.get('kind') == 'text']
+ message_text = '\n'.join(text_parts)
+
+ # Extract file attachments
+ attachments = []
+ for part in a2a_message.get('parts', []):
+ if part.get('kind') == 'file':
+ file_info = part.get('file', {})
+ if 'uri' in file_info:
+ attachments.append(file_info['uri'])
+
+ return UserMessage(
+ message=message_text,
+ attachments=attachments
+ )
+
+
+class DynamicA2AProxy:
+ """Dynamic proxy for FastA2A server that allows reconfiguration."""
+
+ _instance = None
+
+ def __init__(self):
+ self.app = None
+ self.token = ""
+ self._lock = threading.Lock() # Use threading.Lock instead of asyncio.Lock
+ self._startup_done: bool = False
+ self._worker_bg_task: asyncio.Task | None = None
+ self._reconfigure_needed: bool = False # Flag for deferred reconfiguration
+
+ if FASTA2A_AVAILABLE:
+ # Initialize with default token
+ cfg = settings.get_settings()
+ self.token = cfg.get("mcp_server_token", "")
+ self._configure()
+ self._register_shutdown()
+ else:
+ _PRINTER.print("[A2A] FastA2A not available, server will return 503")
+
+ @staticmethod
+ def get_instance():
+ if DynamicA2AProxy._instance is None:
+ DynamicA2AProxy._instance = DynamicA2AProxy()
+ return DynamicA2AProxy._instance
+
+ def reconfigure(self, token: str):
+ """Reconfigure the FastA2A server with new token."""
+ self.token = token
+ if FASTA2A_AVAILABLE:
+ with self._lock:
+ # Mark that reconfiguration is needed - will be done on next request
+ self._reconfigure_needed = True
+ self._startup_done = False # Force restart on next request
+ _PRINTER.print("[A2A] Reconfiguration scheduled for next request")
+
+ def _configure(self):
+ """Configure the FastA2A application with Agent Zero integration."""
+ try:
+ storage = InMemoryStorage() # type: ignore[arg-type]
+ broker = InMemoryBroker() # type: ignore[arg-type]
+
+ # Define Agent Zero's skills
+ skills: List[Skill] = [{ # type: ignore
+ "id": "general_assistance",
+ "name": "General AI Assistant",
+ "description": "Provides general AI assistance including code execution, file management, web browsing, and problem solving",
+ "tags": ["ai", "assistant", "code", "files", "web", "automation"],
+ "examples": [
+ "Write and execute Python code",
+ "Manage files and directories",
+ "Browse the web and extract information",
+ "Solve complex problems step by step",
+ "Install software and manage systems"
+ ],
+ "input_modes": ["text/plain", "application/octet-stream"],
+ "output_modes": ["text/plain", "application/json"]
+ }]
+
+ provider: AgentProvider = { # type: ignore
+ "organization": "Agent Zero",
+ "url": "https://github.com/frdel/agent-zero"
+ }
+
+ # Create new FastA2A app with proper thread safety
+ new_app = AgentZeroFastA2A( # type: ignore
+ storage=storage,
+ broker=broker,
+ name="Agent Zero",
+ description=(
+ "A general AI assistant that can execute code, manage files, browse the web, and "
+ "solve complex problems in an isolated Linux environment."
+ ),
+ version="1.0.0",
+ provider=provider,
+ skills=skills,
+ lifespan=None, # We manage lifespan manually
+ middleware=[], # No middleware - we handle auth in wrapper
+ )
+
+ # Store for later lazy startup (needs active event-loop)
+ self._storage = storage # type: ignore[attr-defined]
+ self._broker = broker # type: ignore[attr-defined]
+ self._worker = AgentZeroWorker(broker=broker, storage=storage) # type: ignore[attr-defined]
+
+ # Atomic update of the app
+ self.app = new_app
+
+ # _PRINTER.print("[A2A] FastA2A server configured successfully")
+
+ except Exception as e:
+ _PRINTER.print(f"[A2A] Failed to configure FastA2A server: {e}")
+ self.app = None
+ raise
+
+ # ---------------------------------------------------------------------
+ # Shutdown handling
+ # ---------------------------------------------------------------------
+
+ def _register_shutdown(self):
+ """Register an atexit hook to gracefully stop worker & task manager."""
+
+ def _sync_shutdown():
+ try:
+ if not self._startup_done or not FASTA2A_AVAILABLE:
+ return
+ loop = asyncio.new_event_loop()
+ loop.run_until_complete(self._async_shutdown())
+ loop.close()
+ except Exception:
+ pass # ignore errors during interpreter shutdown
+
+ atexit.register(_sync_shutdown)
+
+ async def _async_shutdown(self):
+ """Async shutdown: cancel worker task & close task manager."""
+ if self._worker_bg_task and not self._worker_bg_task.done():
+ self._worker_bg_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await self._worker_bg_task
+ try:
+ if hasattr(self, 'app') and self.app:
+ await self.app.task_manager.__aexit__(None, None, None) # type: ignore[attr-defined]
+ except Exception:
+ pass
+
+ async def _async_reconfigure(self):
+ """Perform async reconfiguration with proper lifecycle management."""
+ _PRINTER.print("[A2A] Starting async reconfiguration")
+
+ # Shutdown existing components
+ await self._async_shutdown()
+
+ # Reset startup state
+ self._startup_done = False
+ self._worker_bg_task = None
+
+ # Reconfigure with new token
+ self._configure()
+
+ # Restart components
+ await self._startup()
+
+ # Clear reconfiguration flag
+ self._reconfigure_needed = False
+
+ _PRINTER.print("[A2A] Async reconfiguration completed")
+
+ async def _startup(self):
+ """Ensure TaskManager and Worker are running inside current event-loop."""
+ if self._startup_done or not FASTA2A_AVAILABLE:
+ return
+ self._startup_done = True
+
+ # Start task manager
+ await self.app.task_manager.__aenter__() # type: ignore[attr-defined]
+
+ async def _worker_loop():
+ async with self._worker.run(): # type: ignore[attr-defined]
+ await asyncio.Event().wait()
+
+ # fire-and-forget background task – keep reference
+ self._worker_bg_task = asyncio.create_task(_worker_loop())
+ _PRINTER.print("[A2A] Worker & TaskManager started")
+
+ async def __call__(self, scope, receive, send):
+ """ASGI application interface with token-based routing."""
+ if not FASTA2A_AVAILABLE:
+ # FastA2A not available, return 503
+ response = b'HTTP/1.1 503 Service Unavailable\r\n\r\nFastA2A not available'
+ await send({
+ 'type': 'http.response.start',
+ 'status': 503,
+ 'headers': [[b'content-type', b'text/plain']],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': response,
+ })
+ return
+
+ from helpers import settings
+ cfg = settings.get_settings()
+ if not cfg["a2a_server_enabled"]:
+ response = b'HTTP/1.1 403 Forbidden\r\n\r\nA2A server is disabled'
+ await send({
+ 'type': 'http.response.start',
+ 'status': 403,
+ 'headers': [[b'content-type', b'text/plain']],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': response,
+ })
+ return
+
+ # Check if reconfiguration is needed
+ if self._reconfigure_needed:
+ try:
+ await self._async_reconfigure()
+ except Exception as e:
+ _PRINTER.print(f"[A2A] Error during reconfiguration: {e}")
+ # Return 503 if reconfiguration failed
+ await send({
+ 'type': 'http.response.start',
+ 'status': 503,
+ 'headers': [[b'content-type', b'text/plain']],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'FastA2A reconfiguration failed',
+ })
+ return
+
+ if self.app is None:
+ # FastA2A not configured, return 503
+ response = b'HTTP/1.1 503 Service Unavailable\r\n\r\nFastA2A not configured'
+ await send({
+ 'type': 'http.response.start',
+ 'status': 503,
+ 'headers': [[b'content-type', b'text/plain']],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': response,
+ })
+ return
+
+ # Lazy-start background components the first time we get a request
+ if not self._startup_done:
+ try:
+ _PRINTER.print("[A2A] Starting up FastA2A components")
+ await self._startup()
+ except Exception as e:
+ _PRINTER.print(f"[A2A] Error during startup: {e}")
+ # Return 503 if startup failed
+ await send({
+ 'type': 'http.response.start',
+ 'status': 503,
+ 'headers': [[b'content-type', b'text/plain']],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'FastA2A startup failed',
+ })
+ return
+
+ # Handle token-based routing: /a2a/t-{token}/... or /t-{token}/...
+ path = scope.get('path', '')
+
+ # Strip /a2a prefix if present (DispatcherMiddleware doesn't always strip it)
+ if path.startswith('/a2a'):
+ path = path[4:] # Remove '/a2a' prefix
+
+ # Initialize project name
+ project_name = None
+
+ # Check if path matches token pattern /t-{token}/
+ if path.startswith('/t-'):
+ # Extract token from path
+ if '/' in path[3:]:
+ path_parts = path[3:].split('/', 1) # Remove '/t-' prefix
+ request_token = path_parts[0]
+ remaining_path = '/' + path_parts[1] if len(path_parts) > 1 else '/'
+
+ # Check for project pattern /p-{project}/
+ if remaining_path.startswith('/p-'):
+ project_parts = remaining_path[3:].split('/', 1)
+ if project_parts[0]:
+ project_name = project_parts[0]
+ remaining_path = '/' + project_parts[1] if len(project_parts) > 1 else '/'
+ _PRINTER.print(f"[A2A] Extracted project from URL: {project_name}")
+ else:
+ request_token = path[3:]
+ remaining_path = '/'
+
+ # Validate token
+ cfg = settings.get_settings()
+ expected_token = cfg.get("mcp_server_token")
+
+ if expected_token and request_token != expected_token:
+ # Invalid token, return 401
+ await send({
+ 'type': 'http.response.start',
+ 'status': 401,
+ 'headers': [[b'content-type', b'text/plain']],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'Unauthorized',
+ })
+ return
+
+ # If project specified, inject it into the request payload
+ if project_name:
+ # Buffer messages and modify before returning the complete body
+ received_messages = []
+ body_modified = False
+ original_receive = receive
+
+ async def receive_wrapper():
+ nonlocal body_modified
+
+ # Receive and buffer the next message
+ message = await original_receive()
+ received_messages.append(message)
+
+ # When we get the complete body, inject project into JSON
+ if message['type'] == 'http.request' and not message.get('more_body', False) and not body_modified:
+ body_modified = True
+ try:
+ import json
+ # Reconstruct full body from all buffered messages
+ body_parts = [msg.get('body', b'') for msg in received_messages if msg['type'] == 'http.request']
+ full_body = b''.join(body_parts)
+ data = json.loads(full_body)
+
+ # INJECT project into message.metadata (standard A2A pattern)
+ if 'params' in data and 'message' in data['params']:
+ msg_data = data['params']['message']
+ # Initialize metadata if it doesn't exist
+ if 'metadata' not in msg_data or msg_data['metadata'] is None:
+ msg_data['metadata'] = {}
+ msg_data['metadata']['project'] = project_name
+
+ # Serialize back to JSON
+ modified_body = json.dumps(data).encode('utf-8')
+
+ # Return modified message IMMEDIATELY (before FastA2A processes it)
+ return {
+ 'type': 'http.request',
+ 'body': modified_body,
+ 'more_body': False
+ }
+ except Exception as e:
+ _PRINTER.print(f"[A2A] Failed to inject project into payload: {e}")
+
+ return message
+
+ receive = receive_wrapper
+
+ # Update scope with cleaned path
+ scope = dict(scope)
+ scope['path'] = remaining_path
+ else:
+ # No token in path, check other auth methods
+ request = Request(scope, receive=receive)
+
+ cfg = settings.get_settings()
+ expected = cfg.get("mcp_server_token")
+
+ if expected:
+ auth_header = request.headers.get("Authorization", "")
+ api_key = request.headers.get("X-API-KEY") or request.query_params.get("api_key")
+
+ is_authorized = (
+ (auth_header.startswith("Bearer ") and auth_header.split(" ", 1)[1] == expected) or
+ (api_key == expected)
+ )
+
+ if not is_authorized:
+ # No valid auth, return 401
+ await send({
+ 'type': 'http.response.start',
+ 'status': 401,
+ 'headers': [[b'content-type', b'text/plain']],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'Unauthorized',
+ })
+ return
+ else:
+ _PRINTER.print("[A2A] No expected token found in settings")
+
+ # Delegate to FastA2A app with cleaned scope
+ with self._lock:
+ app = self.app
+ if app:
+ await app(scope, receive, send)
+ else:
+ # App not configured, return 503
+ await send({
+ 'type': 'http.response.start',
+ 'status': 503,
+ 'headers': [[b'content-type', b'text/plain']],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'FastA2A app not configured',
+ })
+ return
+
+
+def is_available():
+ """Check if FastA2A is available and properly configured."""
+ return FASTA2A_AVAILABLE and DynamicA2AProxy.get_instance().app is not None
+
+
+def get_proxy():
+ """Get the FastA2A proxy instance."""
+ return DynamicA2AProxy.get_instance()
diff --git a/helpers/fasta2a_server.py.dox.md b/helpers/fasta2a_server.py.dox.md
index 862ea5125cadcf98b508c393f11a386e1a5d1d2b..1f2dcba78ef00199a8b18025b0c93f3eccab5f67 100644
--- a/helpers/fasta2a_server.py.dox.md
+++ b/helpers/fasta2a_server.py.dox.md
@@ -1,3 +1,57 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:06381f3ebafe91664b2c1aa6edf52af673ed99b7883b7a84a93d02c250e968bc
-size 3300
+# fasta2a_server.py DOX
+
+## Purpose
+
+- Own the `fasta2a_server.py` helper module.
+- This module serves Agent Zero through a dynamic A2A proxy.
+- Keep this file-level DOX profile synchronized with `fasta2a_server.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `fasta2a_server.py` owns the runtime implementation.
+- `fasta2a_server.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `AgentZeroFastA2A` (`FastA2A`)
+ - `async _agent_card_endpoint(self, request: Request) -> StarletteResponse`
+- `AgentZeroWorker` (`Worker`)
+ - `async run_task(self, params: Any) -> None`
+ - `async cancel_task(self, params: Any) -> None`
+ - `build_message_history(self, history: List[Any]) -> List[Message]`
+ - `build_artifacts(self, result: Any) -> List[Artifact]`
+- `DynamicA2AProxy` (no explicit base class)
+ - `get_instance()`
+ - `reconfigure(self, token: str)`
+- Top-level functions:
+- `_enable_streaming_capability(agent_card_body: bytes) -> bytes`: Ensure the generated A2A agent card advertises streaming support.
+- `is_available()`: Check if FastA2A is available and properly configured.
+- `get_proxy()`: Get the FastA2A proxy instance.
+- Notable constants/configuration names: `_PRINTER`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Agent Zero wraps `FastA2A` with `AgentZeroFastA2A` so the generated agent card sets `capabilities.streaming` to `true` by default.
+- Observed side-effect areas: filesystem writes, filesystem deletion, settings/state persistence, secret handling, scheduler state.
+- Imported dependency areas include: `agent`, `asyncio`, `atexit`, `contextlib`, `helpers`, `helpers.persist_chat`, `helpers.print_style`, `initialize`, `json`, `starlette.requests`, `starlette.responses`, `threading`, `typing`, `uuid`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `PrintStyle`, `DynamicA2AProxy.get_instance`, `json.loads`, `json.dumps`, `super.__init__`, `super._agent_card_endpoint`, `join`, `UserMessage`, `threading.Lock`, `atexit.register`, `self._configure`, `settings.get_settings`, `path.startswith`, `self._convert_message`, `initialize_agent`, `AgentContext`, `context.log.log`, `context.communicate`, `context.reset`, `AgentContext.remove`, `remove_chat`, `self.storage.update_task`, `self._register_shutdown`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests:
+ - `tests/test_fasta2a_server.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/file_browser.py b/helpers/file_browser.py
index 583160fabb3b08b87c6a53138f25df758f3d6945..66ff92b4422cfb0664fe53267591862162cba5a9 100644
--- a/helpers/file_browser.py
+++ b/helpers/file_browser.py
@@ -1,3 +1,424 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:4dc453cb2bebbcbe6bad0dd591b44bff9021ac0d287798724937350607ed597f
-size 16976
+import os
+from pathlib import Path
+import shutil
+import base64
+import subprocess
+from typing import Dict, List, Tuple, Any
+from helpers.security import safe_filename
+from datetime import datetime
+
+from helpers import files
+from helpers.localization import Localization
+from helpers.print_style import PrintStyle
+
+
+class FileBrowser:
+ ALLOWED_EXTENSIONS = {
+ 'image': {'jpg', 'jpeg', 'png', 'bmp'},
+ 'code': {'py', 'js', 'sh', 'html', 'css'},
+ 'document': {'md', 'pdf', 'txt', 'csv', 'json'}
+ }
+
+ MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
+ MAX_TEXT_FILE_SIZE = 1 * 1024 * 1024 # 1MB
+
+ def __init__(self):
+ # if runtime.is_development():
+ # base_dir = files.get_base_dir()
+ # else:
+ # base_dir = "/"
+ base_dir = "/"
+ self.base_dir = Path(base_dir)
+
+ def _check_file_size(self, file) -> bool:
+ try:
+ file.seek(0, os.SEEK_END)
+ size = file.tell()
+ file.seek(0)
+ return size <= self.MAX_FILE_SIZE
+ except (AttributeError, IOError):
+ return False
+
+ def save_file_b64(self, current_path: str, filename: str, base64_content: str):
+ try:
+ # Resolve the target directory path
+ target_file = (self.base_dir / current_path / filename).resolve()
+ if not str(target_file).startswith(str(self.base_dir)):
+ raise ValueError("Invalid target directory")
+
+ os.makedirs(target_file.parent, exist_ok=True)
+ # Save file
+ with open(target_file, "wb") as file:
+ file.write(base64.b64decode(base64_content))
+ return True
+ except Exception as e:
+ PrintStyle.error(f"Error saving file {filename}: {e}")
+ return False
+
+ def save_files(self, files: List, current_path: str = "") -> Tuple[List[str], List[str]]:
+ """Save uploaded files and return successful and failed filenames"""
+ successful = []
+ failed = []
+
+ try:
+ # Resolve the target directory path
+ target_dir = (self.base_dir / current_path).resolve()
+ if not str(target_dir).startswith(str(self.base_dir)):
+ raise ValueError("Invalid target directory")
+
+ os.makedirs(target_dir, exist_ok=True)
+
+ for file in files:
+ try:
+ if file and self._is_allowed_file(file.filename, file):
+ filename = safe_filename(file.filename)
+ if not filename:
+ raise ValueError("Invalid filename")
+ file_path = target_dir / filename
+
+ file.save(str(file_path))
+ successful.append(filename)
+ else:
+ failed.append(file.filename)
+ except Exception as e:
+ PrintStyle.error(f"Error saving file {file.filename}: {e}")
+ failed.append(file.filename)
+
+ return successful, failed
+
+ except Exception as e:
+ PrintStyle.error(f"Error in save_files: {e}")
+ return successful, failed
+
+ def delete_file(self, file_path: str) -> bool:
+ """Delete a file or empty directory"""
+ try:
+ # Resolve the full path while preventing directory traversal
+ full_path = (self.base_dir / file_path).resolve()
+ if not str(full_path).startswith(str(self.base_dir)):
+ raise ValueError("Invalid path")
+
+ if os.path.exists(full_path):
+ if os.path.isfile(full_path):
+ os.remove(full_path)
+ elif os.path.isdir(full_path):
+ shutil.rmtree(full_path)
+ return True
+
+ return False
+
+ except Exception as e:
+ PrintStyle.error(f"Error deleting {file_path}: {e}")
+ return False
+
+ def rename_item(self, file_path: str, new_name: str) -> bool:
+ try:
+ if not new_name or new_name in {".", ".."}:
+ raise ValueError("Invalid new name")
+ if "/" in new_name or "\\" in new_name:
+ raise ValueError("New name cannot include path separators")
+
+ full_path = (self.base_dir / file_path).resolve()
+ if not str(full_path).startswith(str(self.base_dir)):
+ raise ValueError("Invalid path")
+ if not full_path.exists():
+ raise FileNotFoundError("File or folder not found")
+
+ new_path = full_path.with_name(new_name)
+ if not str(new_path).startswith(str(self.base_dir)):
+ raise ValueError("Invalid target path")
+ if full_path == new_path:
+ return True
+ if new_path.exists():
+ raise FileExistsError("Target already exists")
+
+ os.rename(full_path, new_path)
+ return True
+ except Exception as e:
+ PrintStyle.error(f"Error renaming {file_path}: {e}")
+ raise
+
+ def move_items(self, file_paths: List[str], destination_path: str) -> List[str]:
+ if not file_paths:
+ raise ValueError("No items selected")
+
+ base_dir = self.base_dir.resolve()
+ destination = (self.base_dir / destination_path).resolve()
+ if not destination.is_relative_to(base_dir):
+ raise ValueError("Invalid destination path")
+ if not destination.is_dir():
+ raise NotADirectoryError("Destination folder not found")
+
+ moves: List[Tuple[Path, Path]] = []
+ targets: set[Path] = set()
+ for file_path in dict.fromkeys(file_paths):
+ requested = self.base_dir / file_path
+ source = requested.parent.resolve() / requested.name
+ if not source.is_relative_to(base_dir) or source == base_dir:
+ raise ValueError("Invalid source path")
+ if not source.exists() and not source.is_symlink():
+ raise FileNotFoundError(f"Item not found: {source.name}")
+ if source == destination:
+ raise ValueError("A folder cannot be moved into itself")
+ if (
+ source.is_dir()
+ and not source.is_symlink()
+ and destination.is_relative_to(source)
+ ):
+ raise ValueError("A folder cannot be moved into itself")
+
+ target = destination / source.name
+ if target == source:
+ raise ValueError(f"{source.name} is already in this folder")
+ if target.exists() or target.is_symlink():
+ raise FileExistsError(
+ f'An item named "{source.name}" already exists'
+ )
+ if target in targets:
+ raise FileExistsError(f'Multiple items are named "{source.name}"')
+ targets.add(target)
+ moves.append((source, target))
+
+ moved: List[Tuple[Path, Path]] = []
+ try:
+ for source, target in moves:
+ os.rename(source, target)
+ moved.append((source, target))
+ except Exception:
+ for source, target in reversed(moved):
+ try:
+ os.rename(target, source)
+ except Exception as rollback_error:
+ PrintStyle.error(f"Error restoring {source}: {rollback_error}")
+ raise
+
+ return [str(target) for _, target in moved]
+
+ def create_folder(self, parent_path: str, folder_name: str) -> bool:
+ try:
+ if not folder_name or folder_name in {".", ".."}:
+ raise ValueError("Invalid folder name")
+ if "/" in folder_name or "\\" in folder_name:
+ raise ValueError("Folder name cannot include path separators")
+
+ parent_full = (self.base_dir / parent_path).resolve()
+ if not str(parent_full).startswith(str(self.base_dir)):
+ raise ValueError("Invalid parent path")
+
+ target_dir = (parent_full / folder_name).resolve()
+ if not str(target_dir).startswith(str(self.base_dir)):
+ raise ValueError("Invalid target path")
+ if target_dir.exists():
+ raise FileExistsError("Folder already exists")
+
+ os.makedirs(target_dir, exist_ok=False)
+ return True
+ except Exception as e:
+ PrintStyle.error(f"Error creating folder {folder_name}: {e}")
+ raise
+
+ def save_text_file(self, file_path: str, content: str) -> bool:
+ try:
+ if not isinstance(content, str):
+ raise ValueError("Content must be a string")
+ content_size = len(content.encode("utf-8"))
+ if content_size > self.MAX_TEXT_FILE_SIZE:
+ raise ValueError("File exceeds 1 MB and cannot be edited")
+
+ full_path = (self.base_dir / file_path).resolve()
+ if not str(full_path).startswith(str(self.base_dir)):
+ raise ValueError("Invalid path")
+ if full_path.exists() and full_path.is_dir():
+ raise ValueError("Target is a directory")
+
+ os.makedirs(full_path.parent, exist_ok=True)
+ with open(full_path, "w", encoding="utf-8") as file:
+ file.write(content)
+ return True
+ except Exception as e:
+ PrintStyle.error(f"Error saving file {file_path}: {e}")
+ raise
+
+ def _is_allowed_file(self, filename: str, file) -> bool:
+ # allow any file to be uploaded in file browser
+
+ # if not filename:
+ # return False
+ # ext = self._get_file_extension(filename)
+ # all_allowed = set().union(*self.ALLOWED_EXTENSIONS.values())
+ # if ext not in all_allowed:
+ # return False
+
+ return True # Allow the file if it passes the checks
+
+ def _get_file_extension(self, filename: str) -> str:
+ return filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
+
+ def _get_files_via_ls(self, full_path: Path) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
+ """Get files and folders using ls command for better error handling"""
+ files: List[Dict[str, Any]] = []
+ folders: List[Dict[str, Any]] = []
+
+ try:
+ # Use ls command to get directory listing
+ result = subprocess.run(
+ ['ls', '-la', str(full_path)],
+ capture_output=True,
+ text=True,
+ timeout=30
+ )
+
+ if result.returncode != 0:
+ PrintStyle.error(f"ls command failed: {result.stderr}")
+ return files, folders
+
+ # Parse ls output (skip first line which is "total X")
+ lines = result.stdout.strip().split('\n')
+ if len(lines) <= 1:
+ return files, folders
+
+ for line in lines[1:]: # Skip the "total" line
+ try:
+ # Skip current and parent directory entries
+ if line.endswith(' .') or line.endswith(' ..'):
+ continue
+
+ # Parse ls -la output format
+ parts = line.split()
+ if len(parts) < 9:
+ continue
+
+ # Check if this is a symlink (permissions start with 'l')
+ permissions = parts[0]
+ is_symlink = permissions.startswith('l')
+
+ if is_symlink:
+ # For symlinks, extract the name before the '->' arrow
+ full_name_part = ' '.join(parts[8:])
+ if ' -> ' in full_name_part:
+ filename = full_name_part.split(' -> ')[0]
+ symlink_target = full_name_part.split(' -> ')[1]
+ else:
+ filename = full_name_part
+ symlink_target = None
+ else:
+ filename = ' '.join(parts[8:]) # Handle filenames with spaces
+ symlink_target = None
+
+ if not filename:
+ continue
+
+ # Get full path for this entry
+ entry_path = full_path / filename
+
+ try:
+ stat_info = entry_path.stat()
+
+ entry_data: Dict[str, Any] = {
+ "name": filename,
+ "path": str(entry_path.relative_to(self.base_dir)),
+ "modified": datetime.fromtimestamp(
+ stat_info.st_mtime,
+ tz=Localization.get().get_tzinfo(),
+ ).isoformat()
+ }
+
+ # Add symlink information if this is a symlink
+ if is_symlink and symlink_target:
+ entry_data["symlink_target"] = symlink_target
+ entry_data["is_symlink"] = True
+
+ if entry_path.is_file():
+ entry_data.update({
+ "type": self._get_file_type(filename),
+ "size": stat_info.st_size,
+ "is_dir": False
+ })
+ files.append(entry_data)
+ elif entry_path.is_dir():
+ entry_data.update({
+ "type": "folder",
+ "size": 0, # Directories show as 0 bytes
+ "is_dir": True
+ })
+ folders.append(entry_data)
+
+ except (OSError, PermissionError, FileNotFoundError) as e:
+ # Log error but continue with other files
+ PrintStyle.warning(f"No access to {filename}: {e}")
+ continue
+
+ if len(files) + len(folders) > 10000:
+ break
+
+ except Exception as e:
+ # Log error and continue with next line
+ PrintStyle.error(f"Error parsing ls line '{line}': {e}")
+ continue
+
+ except subprocess.TimeoutExpired:
+ PrintStyle.error("ls command timed out")
+ except Exception as e:
+ PrintStyle.error(f"Error running ls command: {e}")
+
+ return files, folders
+
+ def get_files(self, current_path: str = "") -> Dict:
+ try:
+ # Resolve the full path while preventing directory traversal
+ full_path = (self.base_dir / current_path).resolve()
+ if not str(full_path).startswith(str(self.base_dir)):
+ raise ValueError("Invalid path")
+ if not full_path.exists():
+ raise FileNotFoundError("Directory not found")
+ if not full_path.is_dir():
+ raise NotADirectoryError("Path is not a directory")
+
+ # Use ls command instead of os.scandir for better error handling
+ files, folders = self._get_files_via_ls(full_path)
+
+ # Combine folders and files, folders first
+ all_entries = folders + files
+
+ # Get parent directory path if not at root
+ parent_path = ""
+ if current_path:
+ try:
+ # Get the absolute path of current directory
+ current_abs = (self.base_dir / current_path).resolve()
+
+ # parent_path is empty only if we're already at root
+ if str(current_abs) != str(self.base_dir):
+ parent_path = str(Path(current_path).parent)
+
+ except Exception:
+ parent_path = ""
+
+ return {
+ "entries": all_entries,
+ "current_path": current_path,
+ "parent_path": parent_path
+ }
+
+ except Exception as e:
+ PrintStyle.error(f"Error reading directory: {e}")
+ return {
+ "entries": [],
+ "current_path": current_path,
+ "parent_path": "",
+ "error": str(e),
+ }
+
+ def get_full_path(self, file_path: str, allow_dir: bool = False) -> str:
+ """Get full file path if it exists and is within base_dir"""
+ full_path = files.get_abs_path(self.base_dir, file_path)
+ if not files.exists(full_path):
+ raise ValueError(f"File {file_path} not found")
+ return full_path
+
+ def _get_file_type(self, filename: str) -> str:
+ ext = self._get_file_extension(filename)
+ for file_type, extensions in self.ALLOWED_EXTENSIONS.items():
+ if ext in extensions:
+ return file_type
+ return 'unknown'
diff --git a/helpers/file_browser.py.dox.md b/helpers/file_browser.py.dox.md
index 2299cf8d62e28d86f109a5b5f2cd39cfab4585a4..69a0e4c74fa1b802271fa78c7990a04a62f16cd3 100644
--- a/helpers/file_browser.py.dox.md
+++ b/helpers/file_browser.py.dox.md
@@ -1,3 +1,54 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:79d018287e8b52aae8604792524ec002e0f658b2d7b22881e5779e6b055b1da3
-size 3230
+# file_browser.py DOX
+
+## Purpose
+
+- Own the `file_browser.py` helper module.
+- This module builds safe file-browser views over allowed filesystem roots.
+- Keep this file-level DOX profile synchronized with `file_browser.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `file_browser.py` owns the runtime implementation.
+- `file_browser.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `FileBrowser` (no explicit base class)
+ - `save_file_b64(self, current_path: str, filename: str, base64_content: str)`
+ - `save_files(self, files: List, current_path: str=...) -> Tuple[List[str], List[str]]`
+ - `delete_file(self, file_path: str) -> bool`
+ - `rename_item(self, file_path: str, new_name: str) -> bool`
+ - `move_items(self, file_paths: List[str], destination_path: str) -> List[str]`
+ - `create_folder(self, parent_path: str, folder_name: str) -> bool`
+ - `save_text_file(self, file_path: str, content: str) -> bool`
+ - `get_files(self, current_path: str=...) -> Dict`
+ - `get_full_path(self, file_path: str, allow_dir: bool=...) -> str`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, subprocess/runtime control, settings/state persistence.
+- Imported dependency areas include: `base64`, `datetime`, `helpers`, `helpers.localization`, `helpers.print_style`, `helpers.security`, `os`, `pathlib`, `shutil`, `subprocess`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `Path`, `files.get_abs_path`, `self._get_file_extension`, `file.seek`, `file.tell`, `resolve`, `os.makedirs`, `os.path.exists`, `full_path.with_name`, `new_path.exists`, `os.rename`, `target_dir.exists`, `filename.rsplit.lower`, `subprocess.run`, `result.stdout.strip.split`, `self._get_files_via_ls`, `files.exists`, `ValueError`, `str.startswith`, `file.write`.
+- Multi-item moves validate every source and target before renaming, reject collisions and directory self-nesting, preserve symlink objects, and best-effort roll back earlier renames if a later rename fails.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_download_toast_regressions.py`
+ - `tests/test_office_document_store.py`
+ - `tests/test_file_browser_navigation.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/file_tree.py b/helpers/file_tree.py
index 6b08eb21bd8196ed39b8c35c82dea21876be04d3..dda866c0bb81af80c3584a23168a2d1425ce1467 100644
--- a/helpers/file_tree.py
+++ b/helpers/file_tree.py
@@ -1,3 +1,675 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:e40acfe09dea3b17f9c6b60402770383961a51c90ce2d9e2acbe481202f66db2
-size 23422
+from __future__ import annotations
+
+from collections import deque
+from dataclasses import dataclass
+from datetime import datetime
+import os
+from typing import Any, Callable, Iterable, Literal, Optional, Sequence
+
+from pathspec import PathSpec
+
+from helpers import files as files_helper
+from helpers.localization import Localization
+
+SORT_BY_NAME = "name"
+SORT_BY_CREATED = "created"
+SORT_BY_MODIFIED = "modified"
+
+SORT_ASC = "asc"
+SORT_DESC = "desc"
+
+OUTPUT_MODE_STRING = "string"
+OUTPUT_MODE_FLAT = "flat"
+OUTPUT_MODE_NESTED = "nested"
+
+
+def _from_timestamp(timestamp: float) -> datetime:
+ return datetime.fromtimestamp(timestamp, tz=Localization.get().get_tzinfo())
+
+
+def file_tree(
+ relative_path: str,
+ *,
+ max_depth: int = 0,
+ max_lines: int = 0,
+ folders_first: bool = True,
+ max_folders: int = 0,
+ max_files: int = 0,
+ sort: tuple[Literal["name", "created", "modified"], Literal["asc", "desc"]] = ("modified", "desc"),
+ ignore: str | None = None,
+ output_mode: Literal["string", "flat", "nested"] = OUTPUT_MODE_STRING,
+) -> str | list[dict]:
+ """Render a directory tree relative to the repository base path.
+
+ Parameters:
+ relative_path: Base directory (relative to project root) to scan with :func:`get_abs_path`.
+ max_depth: Maximum depth of traversal (0 = unlimited). Depth starts at 1 for root entries.
+ max_lines: Global limit for rendered lines (0 = unlimited). When exceeded, the current depth
+ finishes rendering before deeper levels are skipped.
+ folders_first: When True, folders render before files within each directory.
+ max_folders: Optional per-directory cap (0 = unlimited) on rendered folder entries before adding a
+ ``# N more folders`` comment. When only a single folder exceeds the limit and ``max_folders`` is greater than zero, that folder is rendered
+ directly instead of emitting a summary comment.
+ max_files: Optional per-directory cap (0 = unlimited) on rendered file entries before adding a ``# N more files`` comment.
+ As with folders, a single excess file is rendered when ``max_files`` is greater than zero.
+ sort: Tuple of ``(key, direction)`` where key is one of :data:`SORT_BY_NAME`,
+ :data:`SORT_BY_CREATED`, or :data:`SORT_BY_MODIFIED`; direction is :data:`SORT_ASC`
+ or :data:`SORT_DESC`.
+ ignore: Inline ``.gitignore`` content or ``file:`` reference. Examples::
+
+ ignore=\"\"\"\\n*.pyc\\n__pycache__/\\n!important.py\\n\"\"\"
+ ignore=\"file:.gitignore\" # relative to scan root
+ ignore=\"file://.gitignore\" # URI-style relative path
+ ignore=\"file:/abs/path/.gitignore\"
+ ignore=\"file:///abs/path/.gitignore\"
+
+ output_mode: One of :data:`OUTPUT_MODE_STRING`, :data:`OUTPUT_MODE_FLAT`, or
+ :data:`OUTPUT_MODE_NESTED`.
+
+ Returns:
+ ``OUTPUT_MODE_STRING`` → ``str``: multi-line ASCII tree. The first line is the root banner and
+ uses a dockerized absolute path for display.
+ ``OUTPUT_MODE_FLAT`` → ``list[dict]``: flattened sequence of TreeItem dictionaries, with a
+ synthetic root folder item prepended at index 0 (using a dockerized absolute path for display).
+ ``OUTPUT_MODE_NESTED`` → ``list[dict]``: a single synthetic root folder item (using a dockerized
+ absolute path for display) whose ``items`` contains the nested TreeItem dictionaries.
+
+ Notes:
+ * The utility is synchronous; avoid calling from latency-sensitive async loops.
+ * The ASCII renderer walks the established tree depth-first so connectors reflect parent/child structure,
+ while traversal and limit calculations remain breadth-first by depth. When ``max_lines`` is set, the number
+ of non-comment entries (excluding the root banner) never exceeds that limit; informational summary comments
+ are emitted in addition when necessary.
+ * ``created`` and ``modified`` values in structured outputs are timezone-aware user-local
+ :class:`datetime.datetime` objects::
+
+ item = flat_items[0]
+ iso = item[\"created\"].isoformat()
+ epoch = item[\"created\"].timestamp()
+
+ """
+ abs_root = files_helper.get_abs_path(relative_path)
+ output_root = files_helper.get_abs_path_dockerized(relative_path)
+
+ if not os.path.exists(abs_root):
+ raise FileNotFoundError(f"Path does not exist: {relative_path!r}")
+ if not os.path.isdir(abs_root):
+ raise NotADirectoryError(f"Expected a directory, received: {relative_path!r}")
+
+ sort_key, sort_direction = sort
+ if sort_key not in {SORT_BY_NAME, SORT_BY_CREATED, SORT_BY_MODIFIED}:
+ raise ValueError(f"Unsupported sort key: {sort_key!r}")
+ if sort_direction not in {SORT_ASC, SORT_DESC}:
+ raise ValueError(f"Unsupported sort direction: {sort_direction!r}")
+ if output_mode not in {OUTPUT_MODE_STRING, OUTPUT_MODE_FLAT, OUTPUT_MODE_NESTED}:
+ raise ValueError(f"Unsupported output mode: {output_mode!r}")
+ if max_depth < 0:
+ raise ValueError("max_depth must be >= 0")
+ if max_lines < 0:
+ raise ValueError("max_lines must be >= 0")
+
+ ignore_spec = _resolve_ignore_patterns(ignore, abs_root)
+
+ root_stat = os.stat(abs_root, follow_symlinks=False)
+ root_name = os.path.basename(os.path.normpath(abs_root)) or os.path.basename(abs_root)
+ root_node = _TreeEntry(
+ name=root_name,
+ level=0,
+ item_type="folder",
+ created=_from_timestamp(root_stat.st_ctime),
+ modified=_from_timestamp(root_stat.st_mtime),
+ parent=None,
+ items=[],
+ rel_path="",
+ )
+
+ queue: deque[tuple[_TreeEntry, str, int]] = deque([(root_node, abs_root, 1)])
+ nodes_in_order: list[_TreeEntry] = []
+ rendered_count = 0
+ limit_reached = False
+ visibility_cache: dict[str, bool] = {}
+
+ def make_entry(entry: os.DirEntry, parent: _TreeEntry, level: int, item_type: Literal["file", "folder"]) -> _TreeEntry:
+ stat = entry.stat(follow_symlinks=False)
+ rel_path = os.path.relpath(entry.path, abs_root)
+ rel_posix = _normalize_relative_path(rel_path)
+ return _TreeEntry(
+ name=entry.name,
+ level=level,
+ item_type=item_type,
+ created=_from_timestamp(stat.st_ctime),
+ modified=_from_timestamp(stat.st_mtime),
+ parent=parent,
+ items=[] if item_type == "folder" else None,
+ rel_path=rel_posix,
+ )
+
+ while queue and not limit_reached:
+ parent_node, current_dir, level = queue.popleft()
+
+ if max_depth and level > max_depth:
+ continue
+
+ remaining_depth = max_depth - level if max_depth else -1
+ folders, files = _list_directory_children(
+ current_dir,
+ abs_root,
+ ignore_spec,
+ max_depth_remaining=remaining_depth,
+ cache=visibility_cache,
+ )
+
+ folder_entries = [make_entry(folder, parent_node, level, "folder") for folder in folders]
+ file_entries = [make_entry(file_entry, parent_node, level, "file") for file_entry in files]
+
+ children = _apply_sorting_and_limits(
+ folder_entries,
+ file_entries,
+ folders_first=folders_first,
+ sort=sort,
+ max_folders=max_folders,
+ max_files=max_files,
+ directory_node=parent_node,
+ )
+
+ trimmed_children: list[_TreeEntry] = []
+ hidden_children_local: list[_TreeEntry] = []
+ if max_lines and rendered_count >= max_lines:
+ limit_reached = True
+ hidden_children_local = children
+ else:
+ for index, child in enumerate(children):
+ if max_lines and rendered_count >= max_lines:
+ limit_reached = True
+ hidden_children_local = children[index:]
+ break
+ trimmed_children.append(child)
+ nodes_in_order.append(child)
+ is_global_summary = (
+ child.item_type == "comment"
+ and child.rel_path.endswith("#summary:limit")
+ )
+ if not is_global_summary:
+ rendered_count += 1
+ if limit_reached and hidden_children_local:
+ summary = _create_global_limit_comment(
+ parent_node,
+ hidden_children_local,
+ )
+ trimmed_children.append(summary)
+ nodes_in_order.append(summary)
+
+ parent_node.items = trimmed_children or None
+
+ if limit_reached:
+ break
+
+ for child in trimmed_children:
+ if child.item_type != "folder":
+ continue
+ if max_depth and level >= max_depth:
+ continue
+ child_abs = os.path.join(current_dir, child.name)
+ queue.append((child, child_abs, level + 1))
+
+ remaining_queue = list(queue) if limit_reached else []
+ queue.clear()
+
+ if limit_reached and remaining_queue:
+ for folder_node, folder_path, _ in remaining_queue:
+ summary = _create_folder_unprocessed_comment(
+ folder_node,
+ folder_path,
+ abs_root,
+ ignore_spec,
+ )
+ if summary is None:
+ continue
+ folder_node.items = (folder_node.items or []) + [summary]
+ nodes_in_order.append(summary)
+
+ visible_nodes = nodes_in_order
+
+ visible_ids = {id(node) for node in visible_nodes}
+ if visible_ids:
+ _prune_to_visible(root_node, visible_ids)
+
+ _mark_last_flags(root_node)
+ _refresh_render_metadata(root_node)
+
+ def iter_visible() -> Iterable[_TreeEntry]:
+ for node in _iter_depth_first(root_node.items or []):
+ if not visible_ids or id(node) in visible_ids:
+ yield node
+
+ def make_root_item(items: list[dict] | None) -> dict:
+ root_item = root_node.as_dict()
+ root_item["name"] = output_root
+ root_item["text"] = f"{output_root.rstrip(os.sep)}/"
+ root_item["items"] = items
+ return root_item
+
+ if output_mode == OUTPUT_MODE_STRING:
+ display_name = output_root #relative_path.strip() or root_name
+ root_line = f"{display_name.rstrip(os.sep)}/"
+ lines = [root_line]
+ for node in iter_visible():
+ lines.append(node.text)
+ return "\n".join(lines)
+
+ if output_mode == OUTPUT_MODE_FLAT:
+ return [make_root_item(None)] + _build_tree_items_flat(list(iter_visible()))
+
+ return [make_root_item(_to_nested_structure(root_node.items or []))]
+
+
+@dataclass(slots=True)
+class _TreeEntry:
+ name: str
+ level: int
+ item_type: Literal["file", "folder", "comment"]
+ created: datetime
+ modified: datetime
+ parent: Optional["_TreeEntry"] = None
+ items: Optional[list["_TreeEntry"]] = None
+ is_last: bool = False
+ rel_path: str = ""
+ text: str = ""
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "name": self.name,
+ "level": self.level,
+ "type": self.item_type,
+ "created": self.created,
+ "modified": self.modified,
+ "text": self.text,
+ "items": [child.as_dict() for child in self.items] if self.items is not None else None,
+ }
+
+
+def _normalize_relative_path(path: str) -> str:
+ normalized = path.replace(os.sep, "/")
+ if normalized in {".", ""}:
+ return ""
+ while normalized.startswith("./"):
+ normalized = normalized[2:]
+ return normalized
+
+
+def _directory_has_visible_entries(
+ directory: str,
+ root_abs_path: str,
+ ignore_spec: PathSpec,
+ cache: dict[str, bool],
+ max_depth_remaining: int,
+) -> bool:
+ if max_depth_remaining == 0:
+ return False
+
+ cached = cache.get(directory)
+ if cached is not None:
+ return cached
+
+ try:
+ with os.scandir(directory) as iterator:
+ for entry in iterator:
+ rel_path = os.path.relpath(entry.path, root_abs_path)
+ rel_posix = _normalize_relative_path(rel_path)
+ is_dir = entry.is_dir(follow_symlinks=False)
+
+ if is_dir:
+ ignored = ignore_spec.match_file(rel_posix) or ignore_spec.match_file(f"{rel_posix}/")
+ if ignored:
+ next_depth = max_depth_remaining - 1 if max_depth_remaining > 0 else -1
+ if next_depth == 0:
+ continue
+ if _directory_has_visible_entries(
+ entry.path,
+ root_abs_path,
+ ignore_spec,
+ cache,
+ next_depth,
+ ):
+ cache[directory] = True
+ return True
+ continue
+ else:
+ if ignore_spec.match_file(rel_posix):
+ continue
+
+ cache[directory] = True
+ return True
+ except FileNotFoundError:
+ cache[directory] = False
+ return False
+
+ cache[directory] = False
+ return False
+
+
+def _create_summary_comment(parent: _TreeEntry, noun: str, count: int) -> _TreeEntry:
+ label = noun
+ if count == 1 and noun.endswith("s"):
+ label = noun[:-1]
+ elif count > 1 and not noun.endswith("s"):
+ label = f"{noun}s"
+ return _TreeEntry(
+ name=f"{count} more {label}",
+ level=parent.level + 1,
+ item_type="comment",
+ created=parent.created,
+ modified=parent.modified,
+ parent=parent,
+ items=None,
+ rel_path=f"{parent.rel_path}#summary:{noun}:{count}",
+ )
+
+
+def _create_global_limit_comment(parent: _TreeEntry, hidden_children: Sequence[_TreeEntry]) -> _TreeEntry:
+ folders = sum(1 for child in hidden_children if child.item_type == "folder")
+ files = sum(1 for child in hidden_children if child.item_type == "file")
+ parts: list[str] = []
+ if folders:
+ label = "folder" if folders == 1 else "folders"
+ parts.append(f"{folders} {label}")
+ if files:
+ label = "file" if files == 1 else "files"
+ parts.append(f"{files} {label}")
+ if not parts:
+ remaining = len(hidden_children)
+ label = "item" if remaining == 1 else "items"
+ parts.append(f"{remaining} {label}")
+ label_text = ", ".join(parts)
+ return _TreeEntry(
+ name=f"limit reached – hidden: {label_text}",
+ level=parent.level + 1,
+ item_type="comment",
+ created=parent.created,
+ modified=parent.modified,
+ parent=parent,
+ items=None,
+ rel_path=f"{parent.rel_path}#summary:limit",
+ )
+
+
+def _create_folder_unprocessed_comment(
+ folder_node: _TreeEntry,
+ folder_path: str,
+ abs_root: str,
+ ignore_spec: Optional[PathSpec],
+) -> Optional[_TreeEntry]:
+ try:
+ folders, files = _list_directory_children(
+ folder_path,
+ abs_root,
+ ignore_spec,
+ max_depth_remaining=-1,
+ cache={},
+ )
+ except FileNotFoundError:
+ return None
+
+ hidden_entries: list[_TreeEntry] = []
+ for entry in folders:
+ stat = entry.stat(follow_symlinks=False)
+ hidden_entries.append(
+ _TreeEntry(
+ name=entry.name,
+ level=folder_node.level + 1,
+ item_type="folder",
+ created=_from_timestamp(stat.st_ctime),
+ modified=_from_timestamp(stat.st_mtime),
+ parent=folder_node,
+ items=None,
+ rel_path=os.path.join(folder_node.rel_path, entry.name),
+ )
+ )
+ for entry in files:
+ stat = entry.stat(follow_symlinks=False)
+ hidden_entries.append(
+ _TreeEntry(
+ name=entry.name,
+ level=folder_node.level + 1,
+ item_type="file",
+ created=_from_timestamp(stat.st_ctime),
+ modified=_from_timestamp(stat.st_mtime),
+ parent=folder_node,
+ items=None,
+ rel_path=os.path.join(folder_node.rel_path, entry.name),
+ )
+ )
+
+ if not hidden_entries:
+ return None
+
+ return _create_global_limit_comment(folder_node, hidden_entries)
+
+
+def _prune_to_visible(node: _TreeEntry, visible_ids: set[int]) -> None:
+ if node.items is None:
+ return
+ filtered: list[_TreeEntry] = []
+ for child in node.items:
+ if not visible_ids or id(child) in visible_ids:
+ _prune_to_visible(child, visible_ids)
+ filtered.append(child)
+ node.items = filtered or None
+
+
+def _mark_last_flags(node: _TreeEntry) -> None:
+ if node.items is None:
+ return
+ total = len(node.items)
+ for index, child in enumerate(node.items):
+ child.is_last = index == total - 1
+ _mark_last_flags(child)
+
+
+def _refresh_render_metadata(node: _TreeEntry) -> None:
+ if node.items is None:
+ return
+ for child in node.items:
+ child.text = _format_line(child)
+ _refresh_render_metadata(child)
+
+
+def _resolve_ignore_patterns(ignore: str | None, root_abs_path: str) -> Optional[PathSpec]:
+ if ignore is None:
+ return None
+
+ content: str
+ if ignore.startswith("file:"):
+ reference = ignore[5:]
+ if reference.startswith("///"):
+ reference_path = reference[2:]
+ elif reference.startswith("//"):
+ reference_path = os.path.join(root_abs_path, reference[2:])
+ elif reference.startswith("/"):
+ reference_path = reference
+ else:
+ reference_path = os.path.join(root_abs_path, reference)
+
+ try:
+ with open(reference_path, "r", encoding="utf-8") as handle:
+ content = handle.read()
+ except FileNotFoundError as exc:
+ raise FileNotFoundError(f"Ignore file not found: {reference_path}") from exc
+ else:
+ content = ignore
+
+ lines = [
+ line.strip()
+ for line in content.splitlines()
+ if line.strip() and not line.strip().startswith("#")
+ ]
+
+ if not lines:
+ return None
+
+ return PathSpec.from_lines("gitignore", lines)
+
+
+def _list_directory_children(
+ directory: str,
+ root_abs_path: str,
+ ignore_spec: Optional[PathSpec],
+ *,
+ max_depth_remaining: int,
+ cache: dict[str, bool],
+) -> tuple[list[os.DirEntry], list[os.DirEntry]]:
+ folders: list[os.DirEntry] = []
+ files: list[os.DirEntry] = []
+
+ try:
+ with os.scandir(directory) as iterator:
+ for entry in iterator:
+ if entry.name in (".", ".."):
+ continue
+ rel_path = os.path.relpath(entry.path, root_abs_path)
+ rel_posix = _normalize_relative_path(rel_path)
+ is_directory = entry.is_dir(follow_symlinks=False)
+
+ if ignore_spec:
+ if is_directory:
+ ignored = ignore_spec.match_file(rel_posix) or ignore_spec.match_file(f"{rel_posix}/")
+ if ignored:
+ if _directory_has_visible_entries(
+ entry.path,
+ root_abs_path,
+ ignore_spec,
+ cache,
+ max_depth_remaining - 1,
+ ):
+ folders.append(entry)
+ continue
+ else:
+ if ignore_spec.match_file(rel_posix):
+ continue
+
+ if is_directory:
+ folders.append(entry)
+ else:
+ files.append(entry)
+ except FileNotFoundError:
+ return ([], [])
+
+ return (folders, files)
+
+
+def _apply_sorting_and_limits(
+ folders: list[_TreeEntry],
+ files: list[_TreeEntry],
+ *,
+ folders_first: bool,
+ sort: tuple[str, str],
+ max_folders: int | None,
+ max_files: int | None,
+ directory_node: _TreeEntry,
+) -> list[_TreeEntry]:
+ sort_key, sort_dir = sort
+ reverse = sort_dir == SORT_DESC
+
+ def key_fn(node: _TreeEntry):
+ if sort_key == SORT_BY_NAME:
+ return node.name.casefold()
+ if sort_key == SORT_BY_CREATED:
+ return node.created
+ return node.modified
+
+ folders_sorted = sorted(folders, key=key_fn, reverse=reverse)
+ files_sorted = sorted(files, key=key_fn, reverse=reverse)
+ combined: list[_TreeEntry] = []
+
+ def append_group(group: list[_TreeEntry], limit: int | None, noun: str) -> None:
+ if limit == 0:
+ limit = None
+ if not group:
+ return
+ if limit is None:
+ combined.extend(group)
+ return
+
+ limit = max(limit, 0)
+ visible = group[:limit]
+ combined.extend(visible)
+
+ overflow = group[limit:]
+ if not overflow:
+ return
+
+ combined.append(
+ _create_summary_comment(
+ directory_node,
+ noun,
+ len(overflow),
+ )
+ )
+
+ if folders_first:
+ append_group(folders_sorted, max_folders, "folder")
+ append_group(files_sorted, max_files, "file")
+ else:
+ append_group(files_sorted, max_files, "file")
+ append_group(folders_sorted, max_folders, "folder")
+
+ return combined
+
+
+def _format_line(node: _TreeEntry) -> str:
+ segments: list[str] = []
+ ancestor = node.parent
+ while ancestor and ancestor.parent is not None:
+ segments.append(" " if ancestor.is_last else "│ ")
+ ancestor = ancestor.parent
+ segments.reverse()
+
+ connector = "└── " if node.is_last else "├── "
+ if node.item_type == "folder":
+ label = f"{node.name}/"
+ elif node.item_type == "comment":
+ label = f"# {node.name}"
+ else:
+ label = node.name
+
+ return "".join(segments) + connector + label
+
+
+def _build_tree_items_flat(items: Sequence[_TreeEntry]) -> list[dict]:
+ return [
+ {
+ "name": node.name,
+ "level": node.level,
+ "type": node.item_type,
+ "created": node.created,
+ "modified": node.modified,
+ "text": node.text,
+ "items": None,
+ }
+ for node in items
+ ]
+
+
+def _to_nested_structure(items: Sequence[_TreeEntry]) -> list[dict]:
+ def convert(node: _TreeEntry) -> dict:
+ children = None
+ if node.items is not None:
+ children = [convert(child) for child in node.items]
+ return {
+ "name": node.name,
+ "level": node.level,
+ "type": node.item_type,
+ "created": node.created,
+ "modified": node.modified,
+ "text": node.text,
+ "items": children,
+ }
+
+ return [convert(item) for item in items]
+
+
+def _iter_depth_first(items: Sequence[_TreeEntry]) -> Iterable[_TreeEntry]:
+ for node in items:
+ yield node
+ if node.items:
+ yield from _iter_depth_first(node.items)
diff --git a/helpers/file_tree.py.dox.md b/helpers/file_tree.py.dox.md
index f2d6604e623c400c80a65294833fb74d73640aa4..db3625d70b3d8a4da738f07876b884f3bd23e203 100644
--- a/helpers/file_tree.py.dox.md
+++ b/helpers/file_tree.py.dox.md
@@ -1,3 +1,63 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:0432040b0581e453a9329f9cc2ec219c5ed55baf6bb3e7e60c670db72bd0d65a
-size 4458
+# file_tree.py DOX
+
+## Purpose
+
+- Own the `file_tree.py` helper module.
+- This module renders bounded file-tree summaries for prompts and UI surfaces.
+- Keep this file-level DOX profile synchronized with `file_tree.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `file_tree.py` owns the runtime implementation.
+- `file_tree.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `_TreeEntry` (no explicit base class)
+ - `as_dict(self) -> dict[str, Any]`
+- Top-level functions:
+- `_from_timestamp(timestamp: float) -> datetime`
+- `file_tree(relative_path: str, max_depth: int=..., max_lines: int=..., folders_first: bool=..., max_folders: int=..., max_files: int=..., sort: tuple[Literal['name', 'created', 'modified'], Literal['asc', 'desc']]=..., ignore: str | None=..., output_mode: Literal['string', 'flat', 'nested']=...) -> str | list[dict]`: Render a directory tree relative to the repository base path.
+- `_normalize_relative_path(path: str) -> str`
+- `_directory_has_visible_entries(directory: str, root_abs_path: str, ignore_spec: PathSpec, cache: dict[str, bool], max_depth_remaining: int) -> bool`
+- `_create_summary_comment(parent: _TreeEntry, noun: str, count: int) -> _TreeEntry`
+- `_create_global_limit_comment(parent: _TreeEntry, hidden_children: Sequence[_TreeEntry]) -> _TreeEntry`
+- `_create_folder_unprocessed_comment(folder_node: _TreeEntry, folder_path: str, abs_root: str, ignore_spec: Optional[PathSpec]) -> Optional[_TreeEntry]`
+- `_prune_to_visible(node: _TreeEntry, visible_ids: set[int]) -> None`
+- `_mark_last_flags(node: _TreeEntry) -> None`
+- `_refresh_render_metadata(node: _TreeEntry) -> None`
+- `_resolve_ignore_patterns(ignore: str | None, root_abs_path: str) -> Optional[PathSpec]`
+- `_list_directory_children(directory: str, root_abs_path: str, ignore_spec: Optional[PathSpec], max_depth_remaining: int, cache: dict[str, bool]) -> tuple[list[os.DirEntry], list[os.DirEntry]]`
+- `_apply_sorting_and_limits(folders: list[_TreeEntry], files: list[_TreeEntry], folders_first: bool, sort: tuple[str, str], max_folders: int | None, max_files: int | None, directory_node: _TreeEntry) -> list[_TreeEntry]`
+- `_format_line(node: _TreeEntry) -> str`
+- `_build_tree_items_flat(items: Sequence[_TreeEntry]) -> list[dict]`
+- `_to_nested_structure(items: Sequence[_TreeEntry]) -> list[dict]`
+- `_iter_depth_first(items: Sequence[_TreeEntry]) -> Iterable[_TreeEntry]`
+- Notable constants/configuration names: `SORT_BY_NAME`, `SORT_BY_CREATED`, `SORT_BY_MODIFIED`, `SORT_ASC`, `SORT_DESC`, `OUTPUT_MODE_STRING`, `OUTPUT_MODE_FLAT`, `OUTPUT_MODE_NESTED`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, subprocess/runtime control, WebSocket state.
+- Imported dependency areas include: `__future__`, `collections`, `dataclasses`, `datetime`, `helpers`, `helpers.localization`, `os`, `pathspec`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `dataclass`, `datetime.fromtimestamp`, `files_helper.get_abs_path`, `files_helper.get_abs_path_dockerized`, `_resolve_ignore_patterns`, `os.stat`, `_TreeEntry`, `deque`, `queue.clear`, `_mark_last_flags`, `_refresh_render_metadata`, `path.replace`, `normalized.startswith`, `join`, `_create_global_limit_comment`, `ignore.startswith`, `PathSpec.from_lines`, `segments.reverse`, `os.path.exists`, `FileNotFoundError`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_file_tree_visualize.py`
+ - `tests/test_skills_runtime.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/files.py b/helpers/files.py
index b560fc60ab76f11032d80d00af3bbd2de2c52f44..6fb7643b95b5a1e59a5d93ce7dbd5530be63bfc5 100644
--- a/helpers/files.py
+++ b/helpers/files.py
@@ -1,3 +1,760 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:45f216e7ad9eb5936341bc20e7e230bf2202d6a5054dcf19c6e2494eecbee997
-size 24644
+from abc import ABC, abstractmethod
+from fnmatch import fnmatch
+import json
+import os
+import re
+import base64
+import shutil
+import tempfile
+from typing import Any, Literal
+import zipfile
+import glob
+import mimetypes
+from simpleeval import simple_eval
+from helpers import yaml
+
+AGENTS_DIR = "agents"
+PLUGINS_DIR = "plugins"
+PROJECTS_DIR = "projects"
+EXTENSIONS_DIR = "extensions"
+USER_DIR = "usr"
+TEMP_DIR = "tmp"
+API_DIR = "api"
+_base_dir = os.path.dirname(os.path.abspath(os.path.join(__file__, "../")))
+
+class VariablesPlugin(ABC):
+ @abstractmethod
+ def get_variables(self, file: str, backup_dirs: list[str] | None = None, **kwargs) -> dict[str, Any]: # type: ignore
+ pass
+
+
+def load_plugin_variables(
+ file: str, backup_dirs: list[str] | None = None, **kwargs
+) -> dict[str, Any]:
+ if not file.endswith(".md"):
+ return {}
+
+ if backup_dirs is None:
+ backup_dirs = []
+
+ try:
+ # Create filename and directories list
+ plugin_filename = basename(file, ".md") + ".py"
+ directories = [dirname(file)] + backup_dirs
+ plugin_file = find_file_in_dirs(plugin_filename, directories)
+ except FileNotFoundError:
+ plugin_file = None
+
+ if plugin_file and exists(plugin_file):
+
+ from helpers import modules
+
+ classes = modules.load_classes_from_file(
+ plugin_file, VariablesPlugin, one_per_file=False
+ )
+ for cls in classes:
+ return cls().get_variables(file, backup_dirs, **kwargs) # type: ignore < abstract class here is ok, it is always a subclass
+
+ # load python code and extract variables variables from it
+ # module = None
+ # module_name = dirname(plugin_file).replace("/", ".") + "." + basename(plugin_file, '.py')
+
+ # try:
+ # spec = importlib.util.spec_from_file_location(module_name, plugin_file)
+ # if not spec:
+ # return {}
+ # module = importlib.util.module_from_spec(spec)
+ # sys.modules[spec.name] = module
+ # spec.loader.exec_module(module) # type: ignore
+ # except ImportError:
+ # return {}
+
+ # if module is None:
+ # return {}
+
+ # # Get all classes in the module
+ # class_list = inspect.getmembers(module, inspect.isclass)
+ # # Filter for classes that are subclasses of VariablesPlugin
+ # # iterate backwards to skip imported superclasses
+ # for cls in reversed(class_list):
+ # if cls[1] is not VariablesPlugin and issubclass(cls[1], VariablesPlugin):
+ # return cls[1]().get_variables() # type: ignore
+ return {}
+
+
+from helpers.strings import sanitize_string
+
+
+def parse_file(
+ _filename: str, _directories: list[str] | None = None, _encoding="utf-8", **kwargs
+):
+ if _directories is None:
+ _directories = []
+
+ # Find the file in the directories
+ absolute_path = find_file_in_dirs(_filename, _directories)
+
+ # Read the file content
+ with open(absolute_path, "r", encoding=_encoding) as f:
+ # content = remove_code_fences(f.read())
+ content = f.read()
+
+ is_json = is_full_json_template(content)
+ content = remove_code_fences(content)
+ variables = load_plugin_variables(absolute_path, _directories, **kwargs) or {} # type: ignore
+ variables.update(kwargs)
+ if is_json:
+ content = replace_placeholders_json(content, **variables)
+ obj = json.loads(content)
+ # obj = replace_placeholders_dict(obj, **variables)
+ return obj
+ else:
+ content = replace_placeholders_text(content, **variables)
+ # Process include statements
+ content = process_includes(
+ # here we use kwargs, the plugin variables are not inherited
+ content,
+ _directories,
+ **kwargs,
+ )
+ return content
+
+
+def read_prompt_file(
+ _file: str, _directories: list[str] | None = None, _encoding="utf-8", **kwargs
+):
+ if _directories is None:
+ _directories = []
+
+ # If filename contains folder path, extract it and add to directories
+ if os.path.dirname(_file):
+ folder_path = os.path.dirname(_file)
+ _file = os.path.basename(_file)
+ _directories = [folder_path] + _directories
+
+ # Find the file in the directories
+ absolute_path = find_file_in_dirs(_file, _directories)
+ source_dir = os.path.dirname(absolute_path)
+
+ # Read the file content
+ with open(absolute_path, "r", encoding=_encoding) as f:
+ content = f.read()
+
+ variables = load_plugin_variables(_file, _directories, **kwargs) or {} # type: ignore
+ variables.update(kwargs)
+
+ # evaluate conditions
+ content = evaluate_text_conditions(content, **variables)
+
+ # Replace placeholders with values from kwargs
+ content = replace_placeholders_text(content, **variables)
+
+ # Process include statements (with source tracking for {{include original}})
+ content = process_includes(
+ # here we use kwargs, the plugin variables are not inherited
+ content,
+ _directories,
+ _source_file=_file,
+ _source_dir=source_dir,
+ **kwargs,
+ )
+
+ return content
+
+
+def evaluate_text_conditions(_content: str, **kwargs):
+ # search for {{if ...}} ... {{endif}} blocks and evaluate conditions with nesting support
+ if_pattern = re.compile(r"{{\s*if\s+(.*?)}}", flags=re.DOTALL)
+ token_pattern = re.compile(r"{{\s*(if\b.*?|endif)\s*}}", flags=re.DOTALL)
+
+ def _process(text: str) -> str:
+ m_if = if_pattern.search(text)
+ if not m_if:
+ return text
+
+ depth = 1
+ pos = m_if.end()
+ while True:
+ m = token_pattern.search(text, pos)
+ if not m:
+ # Unterminated if-block, do not modify text
+ return text
+ token = m.group(1)
+ depth += 1 if token.startswith("if ") else -1
+ if depth == 0:
+ break
+ pos = m.end()
+
+ before = text[: m_if.start()]
+ condition = m_if.group(1).strip()
+ inner = text[m_if.end() : m.start()]
+ after = text[m.end() :]
+
+ try:
+ result = simple_eval(condition, names=kwargs)
+ except Exception:
+ # On evaluation error, do not modify this block
+ return text
+
+ if result:
+ # Keep inner content (processed recursively), remove if/endif markers
+ kept = before + _process(inner)
+ else:
+ # Skip entire block, including inner content and markers
+ kept = before
+
+ # Continue processing the remaining text after this block
+ return kept + _process(after)
+
+ return _process(_content)
+
+
+def read_file(relative_path: str, encoding="utf-8"):
+ # Try to get the absolute path for the file from the original directory or backup directories
+ absolute_path = get_abs_path(relative_path)
+
+ # Read the file content
+ with open(absolute_path, "r", encoding=encoding) as f:
+ return f.read()
+
+def read_file_json(relative_path: str, encoding="utf-8"):
+ # Try to get the absolute path for the file from the original directory or backup directories
+ absolute_path = get_abs_path(relative_path)
+
+ # Read the file content
+ with open(absolute_path, "r", encoding=encoding) as f:
+ return json.load(f)
+
+def read_file_yaml(relative_path: str, encoding="utf-8"):
+ absolute_path = get_abs_path(relative_path)
+
+ with open(absolute_path, "r", encoding=encoding) as f:
+ return yaml.loads(f.read())
+
+def read_file_bin(relative_path: str):
+ # Try to get the absolute path for the file from the original directory or backup directories
+ absolute_path = get_abs_path(relative_path)
+
+ # read binary content
+ with open(absolute_path, "rb") as f:
+ return f.read()
+
+
+def read_file_base64(relative_path):
+ # get absolute path
+ absolute_path = get_abs_path(relative_path)
+
+ # read binary content and encode to base64
+ with open(absolute_path, "rb") as f:
+ return base64.b64encode(f.read()).decode("utf-8")
+
+
+def is_probably_binary_bytes(data: bytes, threshold: float = 0.3) -> bool:
+ """
+ Binary detection.
+
+ - Fast path: NUL bytes => binary
+ - Otherwise: treat high ratio of suspicious ASCII control bytes as binary.
+ (We intentionally do NOT treat bytes >= 0x80 as binary to avoid false
+ positives for UTF-8 text.)
+ """
+ if not data:
+ return False
+ if b"\x00" in data:
+ return True
+
+ # Count suspicious control bytes
+ allowed = {8, 9, 10, 12, 13} # \b \t \n \f \r
+ suspicious = sum(1 for b in data if ((b < 32 and b not in allowed) or b == 127))
+ return (suspicious / len(data)) > threshold
+
+
+def is_probably_binary_file(
+ file_path: str, sample_size: int = 10 * 1024, threshold: float = 0.3
+) -> bool:
+ """Binary detection by reading only the first ~sample_size bytes of a file."""
+ try:
+ with open(file_path, "rb") as f:
+ sample = f.read(sample_size)
+ except (FileNotFoundError, PermissionError, OSError):
+ raise OSError(f"Unable to read file for binary detection: {file_path}")
+ return is_probably_binary_bytes(sample, threshold=threshold)
+
+
+def replace_placeholders_text(_content: str, **kwargs):
+ # Replace placeholders with values from kwargs
+ for key, value in kwargs.items():
+ placeholder = "{{" + key + "}}"
+ strval = str(value)
+ _content = _content.replace(placeholder, strval)
+ return _content
+
+
+def replace_placeholders_json(_content: str, **kwargs):
+ # Replace placeholders with values from kwargs
+ for key, value in kwargs.items():
+ placeholder = "{{" + key + "}}"
+ if placeholder in _content:
+ strval = json.dumps(value)
+ _content = _content.replace(placeholder, strval)
+ return _content
+
+
+def replace_placeholders_dict(_content: dict, **kwargs):
+ def replace_value(value):
+ if isinstance(value, str):
+ placeholders = re.findall(r"{{(\w+)}}", value)
+ if placeholders:
+ for placeholder in placeholders:
+ if placeholder in kwargs:
+ replacement = kwargs[placeholder]
+ if value == f"{{{{{placeholder}}}}}":
+ return replacement
+ elif isinstance(replacement, (dict, list)):
+ value = value.replace(
+ f"{{{{{placeholder}}}}}", json.dumps(replacement)
+ )
+ else:
+ value = value.replace(
+ f"{{{{{placeholder}}}}}", str(replacement)
+ )
+ return value
+ elif isinstance(value, dict):
+ return {k: replace_value(v) for k, v in value.items()}
+ elif isinstance(value, list):
+ return [replace_value(item) for item in value]
+ else:
+ return value
+
+ return replace_value(_content)
+
+
+def process_includes(
+ _content: str,
+ _directories: list[str],
+ _source_file: str = "",
+ _source_dir: str = "",
+ **kwargs,
+):
+ # {{include original}} — include same file from lower-priority directory
+ original_pattern = re.compile(r"{{\s*include\s+original\s*}}")
+
+ def replace_original(match):
+ if not _source_file or not _source_dir:
+ return match.group(0)
+ remaining_dirs = _get_dirs_after(_directories, _source_dir)
+ if not remaining_dirs:
+ return ""
+ try:
+ return read_prompt_file(_source_file, remaining_dirs, **kwargs)
+ except FileNotFoundError:
+ return ""
+
+ _content = re.sub(original_pattern, replace_original, _content)
+
+ # {{ include 'path' }} — include a named file
+ include_pattern = re.compile(r"{{\s*include\s*['\"](.*?)['\"]\s*}}")
+
+ def replace_include(match):
+ include_path = match.group(1)
+ if os.path.isabs(include_path):
+ return match.group(0)
+ try:
+ return read_prompt_file(include_path, _directories, **kwargs)
+ except FileNotFoundError:
+ return match.group(0)
+
+ return re.sub(include_pattern, replace_include, _content)
+
+
+def _get_dirs_after(_directories: list[str], _source_dir: str) -> list[str]:
+ """Return directories after _source_dir in the priority list."""
+ source_abs = os.path.normpath(os.path.abspath(_source_dir))
+ found = False
+ result: list[str] = []
+ for d in _directories:
+ d_abs = os.path.normpath(os.path.abspath(get_abs_path(d)))
+ if found:
+ result.append(d)
+ elif d_abs == source_abs:
+ found = True
+ return result
+
+
+def find_file_in_dirs(_filename: str, _directories: list[str]):
+ """
+ This function searches for a filename in a list of directories in order.
+ Returns the absolute path of the first found file.
+ """
+ # Loop through the directories in order
+ for directory in _directories:
+ # Create full path
+ full_path = get_abs_path(directory, _filename)
+ if exists(full_path):
+ return full_path
+
+ # If the file is not found, raise FileNotFoundError
+ raise FileNotFoundError(
+ f"File '{_filename}' not found in any of the provided directories."
+ )
+
+
+def get_unique_filenames_in_dirs(
+ dir_paths: list[str],
+ pattern: str = "*",
+ type: Literal["file", "dir", "any"] = "file",
+):
+ # returns absolute paths for unique filenames, priority by order in dir_paths
+ seen = set()
+ result = []
+ for dir_path in dir_paths:
+ full_dir = get_abs_path(dir_path)
+ for file_path in glob.glob(os.path.join(full_dir, pattern)):
+ fname = os.path.basename(file_path)
+ if fname not in seen and (
+ type == "any"
+ or (type == "file" and os.path.isfile(file_path))
+ or (type == "dir" and os.path.isdir(file_path))
+ ):
+ seen.add(fname)
+ result.append(get_abs_path(file_path))
+ # sort by filename (basename), not the full path
+ result.sort(key=lambda path: os.path.basename(path))
+ return result
+
+
+def find_existing_paths_by_pattern(pattern: str):
+ if not pattern:
+ return []
+
+ search_pattern = get_abs_path(pattern)
+ matches = glob.glob(search_pattern, recursive=True)
+ matches.sort()
+ return matches
+
+
+def remove_code_fences(text, language: str | None = None):
+ if language:
+ pattern = (
+ rf"(?ims)^[ \t]*(```|~~~)[ \t]*{re.escape(language)}[ \t]*\r?\n"
+ r"(.*?)^[ \t]*\1[ \t]*\r?$"
+ )
+ return re.sub(pattern, lambda match: match.group(2), text)
+
+ # Pattern to match code fences with optional language specifier
+ pattern = r"(```|~~~)(.*?\n)(.*?)(\1)"
+
+ # Function to replace the code fences
+ def replacer(match):
+ return match.group(3) # Return the code without fences
+
+ # Use re.DOTALL to make '.' match newlines
+ result = re.sub(pattern, replacer, text, flags=re.DOTALL)
+
+ return result
+
+
+def is_full_json_template(text):
+ # Pattern to match the entire text enclosed in ```json or ~~~json fences
+ pattern = r"^\s*(```|~~~)\s*json\s*\n(.*?)\n\1\s*$"
+ # Use re.DOTALL to make '.' match newlines
+ match = re.fullmatch(pattern, text.strip(), flags=re.DOTALL)
+ return bool(match)
+
+
+def write_file(relative_path: str, content: str, encoding: str = "utf-8"):
+ abs_path = get_abs_path(relative_path)
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
+ content = sanitize_string(content, encoding)
+ with open(abs_path, "w", encoding=encoding) as f:
+ f.write(content)
+
+def delete_file(relative_path: str):
+ abs_path = get_abs_path(relative_path)
+ if exists(abs_path):
+ os.remove(abs_path)
+
+def write_file_bin(relative_path: str, content: bytes):
+ abs_path = get_abs_path(relative_path)
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
+ with open(abs_path, "wb") as f:
+ f.write(content)
+
+
+def write_file_base64(relative_path: str, content: str):
+ # decode base64 string to bytes
+ data = base64.b64decode(content)
+ abs_path = get_abs_path(relative_path)
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
+ with open(abs_path, "wb") as f:
+ f.write(data)
+
+
+def delete_dir(relative_path: str):
+ # ensure deletion of directory without propagating errors
+ abs_path = get_abs_path(relative_path)
+ if os.path.exists(abs_path):
+ # first try with ignore_errors=True which is the safest option
+ shutil.rmtree(abs_path, ignore_errors=True)
+
+ # if directory still exists, try more aggressive methods
+ if os.path.exists(abs_path):
+ try:
+ # try to change permissions and delete again
+ for root, dirs, files in os.walk(abs_path, topdown=False):
+ for name in files:
+ file_path = os.path.join(root, name)
+ os.chmod(file_path, 0o777)
+ for name in dirs:
+ dir_path = os.path.join(root, name)
+ os.chmod(dir_path, 0o777)
+
+ # try again after changing permissions
+ shutil.rmtree(abs_path, ignore_errors=True)
+ except:
+ # suppress all errors - we're ensuring no errors propagate
+ pass
+
+
+def move_dir(old_path: str, new_path: str):
+ # rename/move the directory from old_path to new_path (both relative)
+ abs_old = get_abs_path(old_path)
+ abs_new = get_abs_path(new_path)
+ if not os.path.isdir(abs_old):
+ return # nothing to rename
+
+ # ensure parent directory exists
+ os.makedirs(os.path.dirname(abs_new), exist_ok=True)
+
+ try:
+ os.rename(abs_old, abs_new)
+ except OSError:
+ # os.rename fails across Docker volume mount points
+ import shutil
+ shutil.move(abs_old, abs_new)
+
+
+# move dir safely, remove with number if needed
+def move_dir_safe(src, dst, rename_format="{name}_{number}"):
+ base_dst = dst
+ i = 2
+ while exists(dst):
+ dst = rename_format.format(name=base_dst, number=i)
+ i += 1
+ move_dir(src, dst)
+ return dst
+
+
+# create dir safely, add number if needed
+def create_dir_safe(dst, rename_format="{name}_{number}"):
+ base_dst = dst
+ i = 2
+ while exists(dst):
+ dst = rename_format.format(name=base_dst, number=i)
+ i += 1
+ create_dir(dst)
+ return dst
+
+
+def create_dir(relative_path: str):
+ abs_path = get_abs_path(relative_path)
+ os.makedirs(abs_path, exist_ok=True)
+
+
+def list_files(relative_path: str, filter: str = "*"):
+ abs_path = get_abs_path(relative_path)
+ if not os.path.exists(abs_path):
+ return []
+ return [file for file in os.listdir(abs_path) if fnmatch(file, filter)]
+
+
+def make_dirs(relative_path: str):
+ abs_path = get_abs_path(relative_path)
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
+
+
+def _resolve_path(*relative_paths):
+ if len(relative_paths) == 1 and os.path.isabs(relative_paths[0]):
+ return relative_paths[0]
+ return os.path.join(_base_dir, *relative_paths)
+
+
+def get_abs_path(*relative_paths):
+ "Convert relative paths to absolute paths based on the base directory."
+ return _resolve_path(*relative_paths)
+
+
+def get_abs_path_dockerized(*relative_paths):
+ "Ensures the abs path is dockerized (i.e. /a0/... path)"
+ abs = get_abs_path(*relative_paths)
+ from helpers import runtime
+
+ if runtime.is_dockerized():
+ return abs
+ return normalize_a0_path(abs)
+
+
+def get_abs_path_development(*relative_paths):
+ "Ensures the abs path is relevant for dev environment"
+ abs = get_abs_path(*relative_paths)
+ return fix_dev_path(abs)
+
+
+def deabsolute_path(path: str):
+ "Convert absolute paths to relative paths based on the base directory."
+ return os.path.relpath(path, get_base_dir())
+
+
+def fix_dev_path(path: str):
+ "On dev environment, convert /a0/... paths to local absolute paths"
+ from helpers.runtime import is_development
+
+ if is_development():
+ if path.startswith("/a0/"):
+ path = path.replace("/a0/", "")
+ return get_abs_path(path)
+
+
+def normalize_a0_path(path: str):
+ "Convert absolute paths into /a0/... paths"
+ if is_in_base_dir(path):
+ deabs = deabsolute_path(path)
+ return "/a0/" + deabs
+ return path
+
+
+def exists(*relative_paths):
+ path = _resolve_path(*relative_paths)
+ return os.path.exists(path)
+
+
+def is_file(*relative_paths):
+ path = _resolve_path(*relative_paths)
+ return os.path.isfile(path)
+
+
+def is_dir(*relative_paths):
+ path = _resolve_path(*relative_paths)
+ return os.path.isdir(path)
+
+
+def get_base_dir():
+ return _base_dir
+
+
+def basename(path: str, suffix: str | None = None):
+ if suffix:
+ return os.path.basename(path).removesuffix(suffix)
+ return os.path.basename(path)
+
+
+def dirname(path: str):
+ return os.path.dirname(path)
+
+
+def is_in_base_dir(path: str):
+ return is_in_dir(path, get_base_dir())
+
+
+def is_in_dir(path: str, dir: str):
+ # check if the given path is within the directory
+ abs_path = os.path.abspath(path)
+ abs_dir = os.path.abspath(dir)
+ return os.path.commonpath([abs_path, abs_dir]) == abs_dir
+
+
+def get_subdirectories(
+ relative_path: str,
+ include: str | list[str] = "*",
+ exclude: str | list[str] | None = None,
+):
+ abs_path = get_abs_path(relative_path)
+ if not os.path.exists(abs_path):
+ return []
+ if isinstance(include, str):
+ include = [include]
+ if isinstance(exclude, str):
+ exclude = [exclude]
+ return [
+ subdir
+ for subdir in os.listdir(abs_path)
+ if os.path.isdir(os.path.join(abs_path, subdir))
+ and any(fnmatch(subdir, inc) for inc in include)
+ and (exclude is None or not any(fnmatch(subdir, exc) for exc in exclude))
+ ]
+
+
+def zip_dir(dir_path: str):
+ full_path = get_abs_path(dir_path)
+ zip_file_path = tempfile.NamedTemporaryFile(suffix=".zip", delete=False).name
+ base_name = os.path.basename(full_path)
+ with zipfile.ZipFile(zip_file_path, "w", compression=zipfile.ZIP_DEFLATED) as zip:
+ for root, _, files in os.walk(full_path):
+ for file in files:
+ file_path = os.path.join(root, file)
+ rel_path = os.path.relpath(file_path, full_path)
+ zip.write(file_path, os.path.join(base_name, rel_path))
+ return zip_file_path
+
+
+def move_file(relative_path: str, new_path: str):
+ abs_path = get_abs_path(relative_path)
+ new_abs_path = get_abs_path(new_path)
+ os.makedirs(os.path.dirname(new_abs_path), exist_ok=True)
+ try:
+ os.rename(abs_path, new_abs_path)
+ except OSError:
+ # fallback to copy and delete
+ import shutil
+
+ shutil.copy2(abs_path, new_abs_path)
+ try:
+ os.unlink(abs_path)
+ except OSError:
+ pass
+
+
+def safe_file_name(filename: str) -> str:
+ # Replace any character that's not alphanumeric, dash, underscore, or dot with underscore
+ return re.sub(r"[^a-zA-Z0-9-._]", "_", filename)
+
+
+def read_text_files_in_dir(
+ dir_path: str, max_size: int = 1024 * 1024, pattern: str = "*"
+) -> dict[str, str]:
+
+ abs_path = get_abs_path(dir_path)
+ if not os.path.exists(abs_path):
+ return {}
+ result = {}
+ for file_path in [os.path.join(abs_path, f) for f in os.listdir(abs_path)]:
+ try:
+ if not os.path.isfile(file_path):
+ continue
+ if not fnmatch(os.path.basename(file_path), pattern):
+ continue
+ if max_size > 0 and os.path.getsize(file_path) > max_size:
+ continue
+ mime, _ = mimetypes.guess_type(file_path)
+ if mime is not None and not mime.startswith("text"):
+ continue
+ # Check if file is binary by reading a small chunk
+ content = read_file(file_path)
+ result[os.path.basename(file_path)] = content
+ except Exception:
+ continue
+ return result
+
+
+def list_files_in_dir_recursively(relative_path: str) -> list[str]:
+ abs_path = get_abs_path(relative_path)
+ if not os.path.exists(abs_path):
+ return []
+ result = []
+ for root, dirs, files in os.walk(abs_path):
+ for file in files:
+ file_path = os.path.join(root, file)
+ # Return relative path from the base directory
+ rel_path = os.path.relpath(file_path, abs_path)
+ result.append(rel_path)
+ return result
diff --git a/helpers/files.py.dox.md b/helpers/files.py.dox.md
index 9a115678018be933bef7bab3d8941568451eea8b..086886e24aaff830157366c6cf6e1cad4eecc2e7 100644
--- a/helpers/files.py.dox.md
+++ b/helpers/files.py.dox.md
@@ -1,3 +1,82 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:418654181c91c9bdd9b29b47fcd4e0a534a559cd4022fccc0732e93b7255732c
-size 5049
+# files.py DOX
+
+## Purpose
+
+- Own the `files.py` helper module.
+- This module owns repository/user path helpers and file read/write/parsing primitives.
+- Keep this file-level DOX profile synchronized with `files.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `files.py` owns the runtime implementation.
+- `files.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `VariablesPlugin` (`ABC`)
+ - `get_variables(self, file: str, backup_dirs: list[str] | None=..., **kwargs) -> dict[str, Any]`
+- Top-level functions:
+- `load_plugin_variables(file: str, backup_dirs: list[str] | None=..., **kwargs) -> dict[str, Any]`
+- `parse_file(_filename: str, _directories: list[str] | None=..., _encoding=..., **kwargs)`
+- `read_prompt_file(_file: str, _directories: list[str] | None=..., _encoding=..., **kwargs)`
+- `evaluate_text_conditions(_content: str, **kwargs)`
+- `read_file(relative_path: str, encoding=...)`
+- `read_file_json(relative_path: str, encoding=...)`
+- `read_file_yaml(relative_path: str, encoding=...)`
+- `read_file_bin(relative_path: str)`
+- `read_file_base64(relative_path)`
+- `is_probably_binary_bytes(data: bytes, threshold: float=...) -> bool`: Binary detection.
+- `is_probably_binary_file(file_path: str, sample_size: int=..., threshold: float=...) -> bool`: Binary detection by reading only the first ~sample_size bytes of a file.
+- `replace_placeholders_text(_content: str, **kwargs)`
+- `replace_placeholders_json(_content: str, **kwargs)`
+- `replace_placeholders_dict(_content: dict, **kwargs)`
+- `process_includes(_content: str, _directories: list[str], _source_file: str=..., _source_dir: str=..., **kwargs)`
+- `_get_dirs_after(_directories: list[str], _source_dir: str) -> list[str]`: Return directories after _source_dir in the priority list.
+- `find_file_in_dirs(_filename: str, _directories: list[str])`: This function searches for a filename in a list of directories in order.
+- `get_unique_filenames_in_dirs(dir_paths: list[str], pattern: str=..., type: Literal['file', 'dir', 'any']=...)`
+- `find_existing_paths_by_pattern(pattern: str)`
+- `remove_code_fences(text, language: str | None=...)`: Remove every code fence, or only fences for one language while preserving their contents.
+- `is_full_json_template(text)`
+- `write_file(relative_path: str, content: str, encoding: str=...)`
+- `delete_file(relative_path: str)`
+- `write_file_bin(relative_path: str, content: bytes)`
+- `write_file_base64(relative_path: str, content: str)`
+- `delete_dir(relative_path: str)`
+- `move_dir(old_path: str, new_path: str)`
+- `move_dir_safe(src, dst, rename_format=...)`
+- `create_dir_safe(dst, rename_format=...)`
+- `create_dir(relative_path: str)`
+- Notable constants/configuration names: `AGENTS_DIR`, `PLUGINS_DIR`, `PROJECTS_DIR`, `EXTENSIONS_DIR`, `USER_DIR`, `TEMP_DIR`, `API_DIR`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, subprocess/runtime control, plugin state, settings/state persistence, secret handling.
+- Imported dependency areas include: `abc`, `base64`, `fnmatch`, `glob`, `helpers`, `helpers.strings`, `json`, `mimetypes`, `os`, `re`, `shutil`, `simpleeval`, `tempfile`, `typing`, `zipfile`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `os.path.dirname`, `os.path.abspath`, `find_file_in_dirs`, `is_full_json_template`, `remove_code_fences`, `evaluate_text_conditions`, `replace_placeholders_text`, `process_includes`, `re.compile`, `re.escape`, `_process`, `get_abs_path`, `is_probably_binary_bytes`, `replace_value`, `re.sub`, `os.path.normpath`, `FileNotFoundError`, `result.sort`, `glob.glob`, `matches.sort`, `re.fullmatch`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_a0_connector_prompt_gating.py`
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_default_prompt_budget.py`
+ - `tests/test_document_query_plugin.py`
+ - `tests/test_download_toast_regressions.py`
+ - `tests/test_file_tree_visualize.py`
+ - `tests/test_host_browser_connector.py`
+ - `tests/test_image_get_security.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/functions.py b/helpers/functions.py
index ba4e56bdd3209db860945cbf5fe2b7b41c0bed6c..9e85e6ab2b205bc9a7be1796521f9779c1587b52 100644
--- a/helpers/functions.py
+++ b/helpers/functions.py
@@ -1,3 +1,32 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:cfbc5ee7755928bc396514c55e56fdc4260ea7a3e833e3eb83ad32ae02f380f8
-size 870
+import inspect
+
+def safe_call(func, *args, **kwargs):
+ sig = inspect.signature(func)
+
+ bound_args = []
+ bound_kwargs = {}
+
+ params = sig.parameters
+
+ # Check if function accepts *args / **kwargs
+ accepts_var_args = any(p.kind == p.VAR_POSITIONAL for p in params.values())
+ accepts_var_kwargs = any(p.kind == p.VAR_KEYWORD for p in params.values())
+
+ # Handle positional args
+ if accepts_var_args:
+ bound_args = args
+ else:
+ max_positional = sum(
+ 1
+ for p in params.values()
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
+ )
+ bound_args = args[:max_positional]
+
+ # Handle kwargs
+ if accepts_var_kwargs:
+ bound_kwargs = kwargs
+ else:
+ bound_kwargs = {k: v for k, v in kwargs.items() if k in params}
+
+ return func(*bound_args, **bound_kwargs)
diff --git a/helpers/functions.py.dox.md b/helpers/functions.py.dox.md
index 910bad1dff4d9f9040e8a01ddadffc046c35baa4..c2e00daac62ee3d8c7dc5fccaa25eb5e046274b5 100644
--- a/helpers/functions.py.dox.md
+++ b/helpers/functions.py.dox.md
@@ -1,3 +1,45 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:27daedb21823f7baa1bc683dc2313f38ecd50076dd9266d54e4fa8a3c1de1863
-size 1829
+# functions.py DOX
+
+## Purpose
+
+- Own the `functions.py` helper module.
+- This module provides safe generic callable execution helpers.
+- Keep this file-level DOX profile synchronized with `functions.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `functions.py` owns the runtime implementation.
+- `functions.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `safe_call(func, *args, **kwargs)`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Imported dependency areas include: `inspect`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `inspect.signature`, `func`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_a0_connector_prompt_gating.py`
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_error_retry_plugin.py`
+ - `tests/test_oauth_codex.py`
+ - `tests/test_oauth_providers.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/git.py b/helpers/git.py
index ff7e5d61b938903cde1499dc0d07766ed8e291d7..340d3fa1afcf108c063d0c3820b0b8600dd7b776 100644
--- a/helpers/git.py
+++ b/helpers/git.py
@@ -1,3 +1,594 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1c7507a243f7493e4400e02ec18f86df8c6dbd31db8c3a80ed3aaa79d3701e16
-size 18160
+from git import Git, Repo
+from giturlparse import parse
+from datetime import datetime, timezone
+from dataclasses import dataclass
+import os
+import subprocess
+import base64
+import re
+import time
+from urllib.parse import urlparse, urlunparse
+from helpers import files
+from helpers.localization import Localization
+
+
+def strip_auth_from_url(url: str) -> str:
+ """Remove any authentication info from URL."""
+ if not url:
+ return url
+ parsed = urlparse(url)
+ if not parsed.hostname:
+ return url
+ clean_netloc = parsed.hostname
+ if parsed.port:
+ clean_netloc += f":{parsed.port}"
+ return urlunparse((parsed.scheme, clean_netloc, parsed.path, '', '', ''))
+
+
+def extract_author_repo(url: str) -> tuple[str, str]:
+ parsed = parse(strip_auth_from_url(url.strip()))
+ author = (parsed.owner or "").strip()
+ repo = (parsed.repo or parsed.name or "").strip()
+ if not parsed.valid or not author or not repo:
+ raise ValueError("Could not derive plugin name from URL")
+ if repo.endswith(".git"):
+ repo = repo[:-4]
+ if not author or not repo:
+ raise ValueError("Could not derive plugin name from URL")
+ return author, repo
+
+
+@dataclass
+class GitHeadInfo:
+ hash: str
+ short_hash: str
+ message: str
+ author: str
+ committed_at: str
+ authored_at: str
+
+
+@dataclass
+class GitReleaseInfo:
+ tag: str
+ short_tag: str
+ version: str
+ released_at: str
+
+
+@dataclass
+class GitRemoteReleaseInfo:
+ tag: str
+ commit_hash: str
+ short_commit_hash: str
+ released_at: str
+
+
+@dataclass
+class GitRemoteReleasesResult:
+ is_git_repo: bool
+ is_remote: bool
+ author: str
+ repo: str
+ releases: list[GitRemoteReleaseInfo]
+ error: str = ""
+
+
+@dataclass
+class GitRemoteCommitsInfo:
+ is_git_repo: bool
+ is_remote: bool
+ path: str
+ branch: str
+ remote_branch: str
+ commits_since_local: int
+ last_remote_commit_at: str
+ error: str = ""
+
+
+@dataclass
+class GitRepoReleaseInfo:
+ is_git_repo: bool
+ is_remote: bool
+ path: str
+ author: str
+ repo: str
+ branch: str
+ head: GitHeadInfo | None
+ release: GitReleaseInfo | None
+ error: str = ""
+
+
+def _format_git_timestamp(timestamp: int) -> str:
+ return datetime.fromtimestamp(
+ timestamp,
+ tz=timezone.utc,
+ ).strftime('%Y-%m-%d %H:%M:%S')
+
+
+def _split_describe_version(describe: str) -> tuple[str, int]:
+ normalized = describe.strip()
+ match = re.fullmatch(r"(.+)-(\d+)-g[0-9a-f]+", normalized)
+ if not match:
+ return normalized, 0
+ return match.group(1), int(match.group(2))
+
+
+def _format_release_version(
+ branch: str,
+ short_tag: str,
+ commits_since_tag: int,
+ commit_hash: str,
+) -> str:
+ version_prefix = branch[0].upper() if branch else "D"
+ version_core = short_tag or commit_hash[:7]
+
+ if (
+ short_tag
+ and commits_since_tag > 0
+ and branch.strip().lower() != "main"
+ ):
+ version_core = f"{short_tag}+{commits_since_tag}"
+
+ return f"{version_prefix} {version_core}"
+
+
+def get_remote_releases(author: str, repo: str) -> GitRemoteReleasesResult:
+ try:
+ author = author.strip()
+ repo = repo.strip()
+
+ if not author or not repo:
+ return GitRemoteReleasesResult(
+ is_remote=False,
+ is_git_repo=False,
+ author=author,
+ repo=repo,
+ releases=[],
+ error="Both author and repo are required.",
+ )
+
+ remote_url = f"https://github.com/{author}/{repo}.git"
+
+ env = os.environ.copy()
+ env['GIT_TERMINAL_PROMPT'] = '0'
+
+ try:
+ output = Git().ls_remote('--tags', '--refs', '--', remote_url, with_extended_output=False, env=env)
+ except Exception as e:
+ return GitRemoteReleasesResult(
+ is_remote=True,
+ is_git_repo=False,
+ author=author,
+ repo=repo,
+ releases=[],
+ error=f"Git remote query failed: {str(e)}",
+ )
+
+ releases: list[GitRemoteReleaseInfo] = []
+
+ for line in output.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+
+ parts = line.split()
+ if len(parts) != 2:
+ continue
+
+ commit_hash, ref_name = parts
+ prefix = 'refs/tags/'
+ if not ref_name.startswith(prefix):
+ continue
+
+ tag_name = ref_name[len(prefix):]
+ releases.append(GitRemoteReleaseInfo(
+ tag=tag_name,
+ commit_hash=commit_hash,
+ short_commit_hash=commit_hash[:7],
+ released_at="",
+ ))
+
+ releases.sort(key=lambda release: release.tag, reverse=True)
+
+ return GitRemoteReleasesResult(
+ is_git_repo=True,
+ is_remote=True,
+ author=author,
+ repo=repo,
+ releases=releases,
+ )
+ except Exception as e:
+ return GitRemoteReleasesResult(
+ is_git_repo=False,
+ is_remote=False,
+ author=author,
+ repo=repo,
+ releases=[],
+ error=str(e),
+ )
+
+
+def get_remote_commits_since_local(repo_path: str) -> GitRemoteCommitsInfo:
+ try:
+ repo = Repo(repo_path)
+ if repo.bare:
+ return GitRemoteCommitsInfo(
+ is_git_repo=False,
+ is_remote=False,
+ path=repo_path,
+ branch="",
+ remote_branch="",
+ commits_since_local=0,
+ last_remote_commit_at="",
+ error=f"Repository at {repo_path} is bare and cannot be used.",
+ )
+
+ if repo.head.is_detached:
+ return GitRemoteCommitsInfo(
+ is_git_repo=True,
+ is_remote=False,
+ path=repo_path,
+ branch="",
+ remote_branch="",
+ commits_since_local=0,
+ last_remote_commit_at="",
+ error="Repository HEAD is detached.",
+ )
+
+ branch = repo.active_branch.name
+
+ tracking_branch = repo.active_branch.tracking_branch()
+ if tracking_branch is None:
+ return GitRemoteCommitsInfo(
+ is_git_repo=True,
+ is_remote=False,
+ path=repo_path,
+ branch=branch,
+ remote_branch="",
+ commits_since_local=0,
+ last_remote_commit_at="",
+ error="Current branch has no tracking remote branch.",
+ )
+
+ remote_name = tracking_branch.remote_name
+ remote = repo.remotes[remote_name]
+ env = os.environ.copy()
+ env['GIT_TERMINAL_PROMPT'] = '0'
+ with repo.git.custom_environment(**env):
+ remote.fetch(repo.active_branch.name)
+
+ remote_commit = tracking_branch.commit
+ commits = list(repo.iter_commits(f"{repo.head.commit.hexsha}..{tracking_branch.path}"))
+
+ return GitRemoteCommitsInfo(
+ is_git_repo=True,
+ is_remote=True,
+ path=repo_path,
+ branch=branch,
+ remote_branch=tracking_branch.path,
+ commits_since_local=len(commits),
+ last_remote_commit_at=_format_git_timestamp(remote_commit.committed_date) if commits else "",
+ )
+ except Exception as e:
+ return GitRemoteCommitsInfo(
+ is_git_repo=False,
+ is_remote=False,
+ path=repo_path,
+ branch="",
+ remote_branch="",
+ commits_since_local=0,
+ last_remote_commit_at="",
+ error=str(e),
+ )
+
+
+def get_repo_release_info(repo_path: str) -> GitRepoReleaseInfo:
+ try:
+ repo = Repo(repo_path)
+ if repo.bare:
+ return GitRepoReleaseInfo(
+ is_git_repo=False,
+ is_remote=False,
+ path=repo_path,
+ author="",
+ repo="",
+ branch="",
+ head=None,
+ release=None,
+ error=f"Repository at {repo_path} is bare and cannot be used.",
+ )
+
+ commit = repo.head.commit
+ author = ""
+ repo_name = ""
+ is_remote = False
+
+ try:
+ if repo.remotes:
+ author, repo_name = extract_author_repo(repo.remotes.origin.url)
+ is_remote = bool(author and repo_name)
+ except Exception:
+ author = ""
+ repo_name = ""
+ is_remote = False
+
+ branch = ""
+ try:
+ branch = repo.active_branch.name if repo.head.is_detached is False else ""
+ except Exception:
+ branch = ""
+
+ tag = ""
+ short_tag = ""
+ release_time = ""
+ commits_since_tag = 0
+ try:
+ tag = repo.git.describe(tags=True, always=True)
+ short_tag, commits_since_tag = _split_describe_version(tag)
+
+ tag_ref = next((t for t in repo.tags if t.name == short_tag), None)
+ if tag_ref:
+ release_commit = tag_ref.commit
+ release_time = _format_git_timestamp(release_commit.committed_date)
+ except Exception:
+ tag = ""
+ short_tag = ""
+ release_time = ""
+ commits_since_tag = 0
+
+ version = _format_release_version(
+ branch,
+ short_tag,
+ commits_since_tag,
+ commit.hexsha,
+ )
+
+ return GitRepoReleaseInfo(
+ is_git_repo=True,
+ is_remote=is_remote,
+ path=repo_path,
+ author=author,
+ repo=repo_name,
+ branch=branch,
+ head=GitHeadInfo(
+ hash=commit.hexsha,
+ short_hash=commit.hexsha[:7],
+ message=str(commit.message).split("\n")[0][:200],
+ author=str(commit.author),
+ committed_at=_format_git_timestamp(commit.committed_date),
+ authored_at=_format_git_timestamp(commit.authored_date),
+ ),
+ release=GitReleaseInfo(
+ tag=tag,
+ short_tag=short_tag,
+ version=version,
+ released_at=release_time,
+ ),
+ )
+ except Exception as e:
+ return GitRepoReleaseInfo(
+ is_git_repo=False,
+ is_remote=False,
+ path=repo_path,
+ author="",
+ repo="",
+ branch="",
+ head=None,
+ release=None,
+ error=str(e),
+ )
+
+
+def get_git_info():
+ # Get the current working directory (assuming the repo is in the same folder as the script)
+ repo_path = files.get_base_dir()
+
+ state = get_repo_release_info(repo_path)
+ if not state.is_git_repo:
+ raise ValueError(state.error or f"Repository at {repo_path} is not usable.")
+
+ return {
+ "branch": state.branch,
+ "commit_hash": state.head.hash if state.head else "",
+ "commit_time": state.head.committed_at if state.head else "",
+ "tag": state.release.tag if state.release else "",
+ "short_tag": state.release.short_tag if state.release else "",
+ "version": state.release.version if state.release else "",
+ }
+
+def get_version():
+ try:
+ git_info = get_git_info()
+ return str(git_info.get("short_tag", "")).strip() or "unknown"
+ except Exception:
+ return "unknown"
+
+
+def is_official_agent_zero_repo() -> bool:
+ """Return True when origin points to agent0ai/agent-zero."""
+ try:
+ repo = Repo(files.get_base_dir())
+ if not repo.remotes:
+ return False
+
+ remote_url = strip_auth_from_url(repo.remotes.origin.url).lower().rstrip("/")
+
+ if remote_url.endswith(".git"):
+ remote_url = remote_url[:-4]
+
+ allowed_repos = [
+ "agent0ai/agent-zero",
+ "frdel/agent-zero",
+ ]
+ return any(
+ remote_url.endswith(f"github.com/{repo_name}")
+ or remote_url.endswith(f"github.com:{repo_name}")
+ for repo_name in allowed_repos
+ )
+ except Exception:
+ return False
+
+
+def clone_repo(url: str, dest: str, token: str | None = None):
+ """Clone a git repository. Uses http.extraHeader for token auth (never stored in URL/config)."""
+ cmd = ['git']
+
+ if token:
+ # GitHub Git HTTP requires Basic Auth, not Bearer
+ auth_string = f"x-access-token:{token}"
+ auth_base64 = base64.b64encode(auth_string.encode()).decode()
+ cmd.extend(['-c', f'http.extraHeader=Authorization: Basic {auth_base64}'])
+
+ cmd.extend(['clone', '--progress', '--', url, dest])
+
+ env = os.environ.copy()
+ env['GIT_TERMINAL_PROMPT'] = '0'
+
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env)
+
+ if result.returncode != 0:
+ error_msg = result.stderr.strip() or result.stdout.strip() or 'Unknown error'
+ raise Exception(f"Git clone failed: {error_msg}")
+
+ return Repo(dest)
+
+
+class DirtyTreeConflictError(Exception):
+ """Raised when a dirty plugin cannot be updated without overwriting local edits."""
+
+ def __init__(self, conflicting_files: list[str]):
+ super().__init__(
+ "Local changes conflict with the update. "
+ "Your plugin was restored without applying the update."
+ )
+ self.conflicting_files = conflicting_files
+
+
+def _list_dirty_tracked_files(repo: "Repo") -> list[str]:
+ """Return tracked files with uncommitted modifications, excluding A0 metadata."""
+ def _is_a0_file(path: str) -> bool:
+ return path.startswith(".a0proj") or path == ".a0proj"
+
+ changed = {d.a_path for d in repo.index.diff(None)}
+ changed.update(d.a_path for d in repo.index.diff("HEAD"))
+ return sorted(p for p in changed if p and not _is_a0_file(p))
+
+
+def update_repo(repo_path: str, auto_stash: bool = True) -> Repo:
+ """Fast-forward the repo to its tracking branch.
+
+ When `auto_stash` is True (default) and the working tree has uncommitted
+ changes to tracked files, those changes are stashed before the pull and
+ reapplied afterwards. If they conflict with the update, the repo and local
+ edits are restored to their original state before `DirtyTreeConflictError`
+ is raised.
+ """
+ repo = Repo(repo_path)
+ if repo.bare:
+ raise ValueError(f"Repository at {repo_path} is bare and cannot be updated.")
+
+ if repo.head.is_detached:
+ raise ValueError("Repository HEAD is detached.")
+
+ branch = repo.active_branch.name
+ tracking_branch = repo.active_branch.tracking_branch()
+ if tracking_branch is None:
+ raise ValueError("Current branch has no tracking remote branch.")
+
+ env = os.environ.copy()
+ env['GIT_TERMINAL_PROMPT'] = '0'
+
+ dirty_files = _list_dirty_tracked_files(repo) if auto_stash else []
+ original_head = repo.head.commit.hexsha
+ if dirty_files:
+ stash_msg = f"a0-auto-stash-{int(time.time())}"
+ repo.git.stash("push", "-m", stash_msg, "--", *dirty_files)
+
+ def restore_original_state():
+ repo.git.reset("--hard", original_head)
+ if dirty_files:
+ repo.git.stash("pop")
+
+ try:
+ with repo.git.custom_environment(**env):
+ repo.remotes[tracking_branch.remote_name].pull(branch)
+ except Exception:
+ if dirty_files:
+ restore_original_state()
+ raise
+
+ if dirty_files:
+ try:
+ repo.git.stash("pop")
+ except Exception:
+ restore_original_state()
+ raise DirtyTreeConflictError(dirty_files)
+
+ return repo
+
+
+# Files to ignore when checking dirty status (A0 project metadata)
+A0_IGNORE_PATTERNS = {".a0proj", ".a0proj/"}
+
+
+def get_repo_status(repo_path: str) -> dict:
+ """Get Git repository status, ignoring A0 project metadata files."""
+ try:
+ repo = Repo(repo_path)
+ if repo.bare:
+ return {"is_git_repo": False, "error": "Repository is bare"}
+
+ # Remote URL (always strip auth info for security)
+ remote_url = ""
+ try:
+ if repo.remotes:
+ remote_url = strip_auth_from_url(repo.remotes.origin.url)
+ except Exception:
+ pass
+
+ # Current branch
+ try:
+ current_branch = repo.active_branch.name if not repo.head.is_detached else f"HEAD@{repo.head.commit.hexsha[:7]}"
+ except Exception:
+ current_branch = "unknown"
+
+ # Check dirty status, excluding A0 metadata
+ def is_a0_file(path: str) -> bool:
+ return path.startswith(".a0proj") or path == ".a0proj"
+
+ # Filter out A0 files from diff and untracked
+ changed_files = [d.a_path for d in repo.index.diff(None)] + [d.a_path for d in repo.index.diff("HEAD")]
+ untracked = repo.untracked_files
+
+ real_changes = [f for f in changed_files if not is_a0_file(f)]
+ real_untracked = [f for f in untracked if not is_a0_file(f)]
+
+ is_dirty = len(real_changes) > 0 or len(real_untracked) > 0
+ untracked_count = len(real_untracked)
+
+ last_commit = None
+ try:
+ commit = repo.head.commit
+ last_commit = {
+ "hash": commit.hexsha[:7],
+ "message": str(commit.message).split("\n")[0][:80],
+ "author": str(commit.author),
+ "date": datetime.fromtimestamp(
+ commit.committed_date,
+ tz=Localization.get().get_tzinfo(),
+ ).strftime('%Y-%m-%d %H:%M %Z')
+ }
+ except Exception:
+ pass
+
+ return {
+ "is_git_repo": True,
+ "remote_url": remote_url,
+ "current_branch": current_branch,
+ "is_dirty": is_dirty,
+ "untracked_count": untracked_count,
+ "last_commit": last_commit
+ }
+ except Exception as e:
+ return {"is_git_repo": False, "error": str(e)}
diff --git a/helpers/git.py.dox.md b/helpers/git.py.dox.md
index f52cc2ac1d2e9558ef3bf6a2653a07be7955fb05..9661a9d52e3afc73d8d6462a1ceddbcab11994d1 100644
--- a/helpers/git.py.dox.md
+++ b/helpers/git.py.dox.md
@@ -1,3 +1,72 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:b85ea1bebc8831b2ed7c1e3ba4a44583a587b8672dc85951f47994166c73a651
-size 4310
+# git.py DOX
+
+## Purpose
+
+- Own the `git.py` helper module.
+- This module reads local and remote Git release/commit metadata safely.
+- Keep this file-level DOX profile synchronized with `git.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `git.py` owns the runtime implementation.
+- `git.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `GitHeadInfo` (no explicit base class)
+- `GitReleaseInfo` (no explicit base class)
+- `GitRemoteReleaseInfo` (no explicit base class)
+- `GitRemoteReleasesResult` (no explicit base class)
+- `GitRemoteCommitsInfo` (no explicit base class)
+- `GitRepoReleaseInfo` (no explicit base class)
+- Top-level functions:
+- `strip_auth_from_url(url: str) -> str`: Remove any authentication info from URL.
+- `extract_author_repo(url: str) -> tuple[str, str]`
+- `_format_git_timestamp(timestamp: int) -> str`
+- `_split_describe_version(describe: str) -> tuple[str, int]`
+- `_format_release_version(branch: str, short_tag: str, commits_since_tag: int, commit_hash: str) -> str`
+- `get_remote_releases(author: str, repo: str) -> GitRemoteReleasesResult`
+- `get_remote_commits_since_local(repo_path: str) -> GitRemoteCommitsInfo`
+- `get_repo_release_info(repo_path: str) -> GitRepoReleaseInfo`
+- `get_git_info()`
+- `get_version()`
+- `is_official_agent_zero_repo() -> bool`: Return True when origin points to agent0ai/agent-zero.
+- `clone_repo(url: str, dest: str, token: str | None=...)`: Clone a git repository. Uses http.extraHeader for token auth (never stored in URL/config).
+- `DirtyTreeConflictError`: Reports a plugin update that was rolled back because its local edits conflict with upstream.
+- `update_repo(repo_path: str, auto_stash: bool = True) -> Repo`: Temporarily stashes tracked plugin edits for an update, then restores them; on conflict it restores the original checkout and edits before raising `DirtyTreeConflictError`.
+- `get_repo_status(repo_path: str) -> dict`: Get Git repository status, ignoring A0 project metadata files.
+- Notable constants/configuration names: `A0_IGNORE_PATTERNS`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Git commit and release timestamp strings produced by `_format_git_timestamp` use UTC and omit a timezone suffix.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem writes, filesystem deletion, network calls, subprocess/runtime control, plugin state, settings/state persistence, secret handling.
+- Imported dependency areas include: `base64`, `dataclasses`, `datetime`, `git`, `giturlparse`, `helpers`, `helpers.localization`, `os`, `re`, `subprocess`, `urllib.parse`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `urlparse`, `urlunparse`, `parse`, `strip`, `repo.endswith`, `datetime.fromtimestamp.strftime`, `describe.strip`, `re.fullmatch`, `files.get_base_dir`, `get_repo_release_info`, `os.environ.copy`, `subprocess.run`, `Repo`, `repo.active_branch.tracking_branch`, `strip_auth_from_url`, `ValueError`, `match.group`, `branch.upper`, `author.strip`, `repo.strip`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_docker_release_plan.py`
+ - `tests/test_git_version_label.py`
+ - `tests/test_model_config_api_keys.py`
+ - `tests/test_model_config_project_presets.py`
+ - `tests/test_model_search.py`
+ - `tests/test_oauth_github_copilot.py`
+ - `tests/test_oauth_providers.py`
+ - `tests/test_onboarding_static.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/guids.py b/helpers/guids.py
index b24003b318a2a5aaf35851bf3822945cd15b6895..f0def4b4c60fef0a79a213d786db58adb478077f 100644
--- a/helpers/guids.py
+++ b/helpers/guids.py
@@ -1,3 +1,4 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3ecd3be86e09c5e10d88e4d576dc3dcdea6d5c3416af863606cfe3e962f70076
-size 147
+import random, string
+
+def generate_id(length: int = 8) -> str:
+ return "".join(random.choices(string.ascii_letters + string.digits, k=length))
diff --git a/helpers/guids.py.dox.md b/helpers/guids.py.dox.md
index ab6f33073346b7238ec4ebb7f6c8a93096cdd402..9fea993bfa42b4cd28eef4b12b790399f6be1172 100644
--- a/helpers/guids.py.dox.md
+++ b/helpers/guids.py.dox.md
@@ -1,3 +1,40 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:23ac145aa945ffd4af184f86b25749c5b192a03696128666a1f483fd389ee4a0
-size 1672
+# guids.py DOX
+
+## Purpose
+
+- Own the `guids.py` helper module.
+- This module generates framework IDs.
+- Keep this file-level DOX profile synchronized with `guids.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `guids.py` owns the runtime implementation.
+- `guids.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `generate_id(length: int=...) -> str`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Imported dependency areas include: `random`, `string`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `join`, `random.choices`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/history.py b/helpers/history.py
index cb5922195df67ff744e862a656753d3f4f84efa7..c980e8f1351f2e69598e6df17f1da9798fa70bc9 100644
--- a/helpers/history.py
+++ b/helpers/history.py
@@ -1,3 +1,815 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:0c99b93d77873b2996ac5399a0d6c7382f22fa0a9e866a654ddae647819c8510
-size 26118
+from abc import abstractmethod
+import asyncio
+from collections import OrderedDict
+from collections.abc import Mapping
+import json
+import math
+import uuid
+from typing import Coroutine, Literal, TypedDict, cast, Union, Dict, List, Any
+from helpers import messages, tokens, settings, call_llm
+from enum import Enum
+from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
+from plugins._model_config.helpers.model_config import get_chat_model_config
+
+
+BULK_MERGE_COUNT = 3
+TOPICS_MERGE_COUNT = 3
+CURRENT_TOPIC_RATIO = 0.5
+HISTORY_TOPIC_RATIO = 0.3
+HISTORY_BULK_RATIO = 0.2
+CURRENT_TOPIC_ATTENTION_COMPRESSION = 0.65 # compress current topic's attention window to 65% of size
+HISTORY_TOPIC_ATTENTION_COMPRESSION = 0 # compress history topic's attention window to 0% of size - only request and response remain intact
+LARGE_MESSAGE_TO_CURRENT_TOPIC_RATIO = 0.5
+LARGE_MESSAGE_TO_HISTORY_TOPIC_RATIO = 0.2
+RAW_MESSAGE_OUTPUT_TEXT_TRIM = 100
+COMPRESSION_TARGET_RATIO = 0.8
+
+
+class RawMessage(TypedDict):
+ raw_content: "MessageContent"
+ preview: str | None
+
+
+MessageContent = Union[
+ List["MessageContent"],
+ Dict[str, "MessageContent"],
+ List[Dict[str, "MessageContent"]],
+ str,
+ List[str],
+ RawMessage,
+]
+
+
+class OutputMessage(TypedDict, total=False):
+ ai: bool
+ content: MessageContent
+ metadata: dict[str, Any]
+ id: str
+ sequence: int
+
+
+class Record:
+ def __init__(self):
+ pass
+
+ @abstractmethod
+ def get_tokens(self) -> int:
+ pass
+
+ @abstractmethod
+ async def compress(self) -> bool:
+ pass
+
+ @abstractmethod
+ def output(self) -> list[OutputMessage]:
+ pass
+
+ @abstractmethod
+ async def summarize(self) -> str:
+ pass
+
+ @abstractmethod
+ def to_dict(self) -> dict:
+ pass
+
+ @staticmethod
+ def from_dict(data: dict, history: "History"):
+ cls = data["_cls"]
+ return globals()[cls].from_dict(data, history=history)
+
+ def output_langchain(self):
+ return output_langchain(self.output())
+
+ def output_text(self, human_label="user", ai_label="ai"):
+ return output_text(self.output(), ai_label, human_label)
+
+
+class Message(Record):
+ def __init__(
+ self,
+ ai: bool,
+ content: MessageContent,
+ tokens: int = 0,
+ id: str = "",
+ metadata: dict[str, Any] | None = None,
+ sequence: int = 0,
+ ):
+ self.id = id or str(uuid.uuid4())
+ self.ai = ai
+ self.content = content
+ self.metadata = metadata or {}
+ self.sequence = sequence
+ self.summary: str = ""
+ self.tokens: int = tokens or self.calculate_tokens()
+
+ def get_tokens(self) -> int:
+ if not self.tokens:
+ self.tokens = self.calculate_tokens()
+ return self.tokens
+
+ def calculate_tokens(self):
+ text = self.output_text()
+ return tokens.approximate_tokens(text)
+
+ def set_summary(self, summary: str):
+ self.summary = summary
+ self.tokens = self.calculate_tokens()
+
+ async def compress(self):
+ return False
+
+ def output(self):
+ return [
+ OutputMessage(
+ ai=self.ai,
+ content=self.summary or self.content,
+ metadata=self.metadata,
+ id=self.id,
+ sequence=self.sequence,
+ )
+ ]
+
+ def output_langchain(self):
+ return output_langchain(self.output())
+
+ def output_text(self, human_label="user", ai_label="ai"):
+ return output_text(self.output(), ai_label, human_label)
+
+ def to_dict(self):
+ return {
+ "_cls": "Message",
+ "id": self.id,
+ "ai": self.ai,
+ "content": self.content,
+ "metadata": self.metadata,
+ "sequence": self.sequence,
+ "summary": self.summary,
+ "tokens": self.tokens,
+ }
+
+ @staticmethod
+ def from_dict(data: dict, history: "History"):
+ content = data.get("content", "Content lost")
+ metadata = data.get("metadata", {})
+ metadata = metadata if isinstance(metadata, dict) else {}
+ if data["ai"]:
+ from helpers.llm_result import result_from_metadata
+
+ result = result_from_metadata(metadata)
+ if result:
+ metadata = {**metadata, **result.metadata()}
+ msg = Message(
+ ai=data["ai"],
+ content=content,
+ id=data.get("id", ""),
+ metadata=metadata,
+ sequence=int(data.get("sequence", 0) or 0),
+ )
+ msg.summary = data.get("summary", "")
+ msg.tokens = data.get("tokens", 0)
+ return msg
+
+
+class Topic(Record):
+ def __init__(self, history: "History"):
+ self.history = history
+ self.summary: str = ""
+ self.messages: list[Message] = []
+
+ def get_tokens(self):
+ if self.summary:
+ return tokens.approximate_tokens(self.summary)
+ else:
+ return sum(msg.get_tokens() for msg in self.messages)
+
+ def add_message(
+ self,
+ ai: bool,
+ content: MessageContent,
+ tokens: int = 0,
+ id: str = "",
+ metadata: dict[str, Any] | None = None,
+ sequence: int = 0,
+ ) -> Message:
+ msg = Message(
+ ai=ai,
+ content=content,
+ tokens=tokens,
+ id=id,
+ metadata=metadata,
+ sequence=sequence,
+ )
+ self.messages.append(msg)
+ return msg
+
+ def output(self) -> list[OutputMessage]:
+ if self.summary:
+ return [OutputMessage(ai=False, content=self.summary)]
+ else:
+ msgs = [m for r in self.messages for m in r.output()]
+ return msgs
+
+ async def summarize(self):
+ self.summary = await self.summarize_messages(self.messages)
+ return self.summary
+
+ def compress_large_messages(self, message_ratio: float = CURRENT_TOPIC_RATIO * LARGE_MESSAGE_TO_CURRENT_TOPIC_RATIO) -> bool:
+ from plugins._model_config.helpers.model_config import get_chat_model_config
+ chat_cfg = get_chat_model_config()
+ ctx_length = int(chat_cfg.get("ctx_length", 128000))
+ ctx_history = float(chat_cfg.get("ctx_history", 0.7))
+ msg_max_size = (
+ ctx_length
+ * ctx_history
+ * message_ratio
+ )
+ large_msgs = []
+ for m in (m for m in self.messages if not m.summary):
+ # TODO refactor this
+ out = m.output()
+ text = output_text(out)
+ tok = m.get_tokens()
+ leng = len(text)
+ if tok > msg_max_size:
+ large_msgs.append((m, tok, leng, out))
+ large_msgs.sort(key=lambda x: x[1], reverse=True)
+ for msg, tok, leng, out in large_msgs:
+ trim_to_chars = leng * (msg_max_size / tok)
+ # raw messages will be replaced as a whole, they would become invalid when truncated
+ if _is_raw_message(out[0]["content"]):
+ msg.set_summary(
+ "Message content replaced to save space in context window"
+ )
+
+ # regular messages will be truncated
+ else:
+ trunc = messages.truncate_dict_by_ratio(
+ self.history.agent,
+ out[0]["content"],
+ trim_to_chars * 1.15,
+ trim_to_chars * 0.85,
+ )
+ msg.set_summary(_json_dumps(trunc))
+
+ return True
+ return False
+
+ async def compress(self) -> bool:
+ compress = self.compress_large_messages()
+ if not compress:
+ compress = await self.compress_attention()
+ return compress
+
+ async def compress_attention(self, ratio: float = CURRENT_TOPIC_ATTENTION_COMPRESSION) -> bool:
+
+ middle = len(self.messages) - 2
+ if middle < 2:
+ return False
+ cnt_to_sum = middle - math.floor(middle * ratio)
+ if cnt_to_sum < 1:
+ return False
+ msg_to_sum = self.messages[1 : cnt_to_sum + 1]
+ summary = await self.summarize_messages(msg_to_sum)
+ sum_msg_content = self.history.agent.parse_prompt(
+ "fw.msg_summary.md", summary=summary
+ )
+ sum_msg = Message(False, sum_msg_content)
+ self.messages[1 : cnt_to_sum + 1] = [sum_msg]
+ return True
+
+ async def summarize_messages(self, messages: list[Message]):
+ msg_txt = [m.output_text() for m in messages]
+ summary = await self.history.agent.call_utility_model(
+ system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
+ message=self.history.agent.read_prompt(
+ "fw.topic_summary.msg.md", content=msg_txt
+ ),
+ )
+ return summary
+
+ def to_dict(self):
+ return {
+ "_cls": "Topic",
+ "summary": self.summary,
+ "messages": [m.to_dict() for m in self.messages],
+ }
+
+ @staticmethod
+ def from_dict(data: dict, history: "History"):
+ topic = Topic(history=history)
+ topic.summary = data.get("summary", "")
+ topic.messages = [
+ Message.from_dict(m, history=history) for m in data.get("messages", [])
+ ]
+ return topic
+
+
+class Bulk(Record):
+ def __init__(self, history: "History"):
+ self.history = history
+ self.summary: str = ""
+ self.records: list[Record] = []
+
+ def get_tokens(self):
+ if self.summary:
+ return tokens.approximate_tokens(self.summary)
+ else:
+ return sum([r.get_tokens() for r in self.records])
+
+ def output(
+ self, human_label: str = "user", ai_label: str = "ai"
+ ) -> list[OutputMessage]:
+ if self.summary:
+ return [OutputMessage(ai=False, content=self.summary)]
+ else:
+ msgs = [m for r in self.records for m in r.output()]
+ return msgs
+
+ async def compress(self):
+ return False
+
+ async def summarize(self):
+ self.summary = await self.history.agent.call_utility_model(
+ system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
+ message=self.history.agent.read_prompt(
+ "fw.topic_summary.msg.md", content=self.output_text()
+ ),
+ )
+ return self.summary
+
+ def to_dict(self):
+ return {
+ "_cls": "Bulk",
+ "summary": self.summary,
+ "records": [r.to_dict() for r in self.records],
+ }
+
+ @staticmethod
+ def from_dict(data: dict, history: "History"):
+ bulk = Bulk(history=history)
+ bulk.summary = data["summary"]
+ cls = data["_cls"]
+ bulk.records = [Record.from_dict(r, history=history) for r in data["records"]]
+ return bulk
+
+
+class History(Record):
+ def __init__(self, agent):
+ from agent import Agent
+
+ self.counter = 0
+ self.bulks: list[Bulk] = []
+ self.topics: list[Topic] = []
+ self.current = Topic(history=self)
+ self.agent: Agent = agent
+
+ def get_tokens(self) -> int:
+ return (
+ self.get_bulks_tokens()
+ + self.get_topics_tokens()
+ + self.get_current_topic_tokens()
+ )
+
+ def is_over_limit(self):
+ limit = self._get_ctx_size_for_history()
+ total = self.get_tokens()
+ return total > limit
+
+ def get_bulks_tokens(self) -> int:
+ return sum(record.get_tokens() for record in self.bulks)
+
+ def get_topics_tokens(self) -> int:
+ return sum(record.get_tokens() for record in self.topics)
+
+ def get_current_topic_tokens(self) -> int:
+ return self.current.get_tokens()
+
+ def add_message(
+ self,
+ ai: bool,
+ content: MessageContent,
+ tokens: int = 0,
+ id: str = "",
+ metadata: dict[str, Any] | None = None,
+ ) -> Message:
+ self.counter += 1
+ return self.current.add_message(
+ ai,
+ content=content,
+ tokens=tokens,
+ id=id,
+ metadata=metadata,
+ sequence=self.counter,
+ )
+
+ def new_topic(self):
+ if self.current.messages:
+ self.topics.append(self.current)
+ self.current = Topic(history=self)
+
+ def output(self) -> list[OutputMessage]:
+ self.trim_embeds(self._get_max_embeds())
+ result: list[OutputMessage] = []
+ result += [m for b in self.bulks for m in b.output()]
+ result += [m for t in self.topics for m in t.output()]
+ result += self.current.output()
+ return result
+
+ def messages_since(self, sequence: int) -> list[Message]:
+ return [
+ message
+ for message in self.all_messages()
+ if int(message.sequence or 0) > int(sequence or 0)
+ ]
+
+ def all_messages(self) -> list[Message]:
+ messages: list[Message] = []
+ for bulk in self.bulks:
+ messages.extend(_messages_from_record(bulk))
+ for topic in self.topics:
+ messages.extend(topic.messages)
+ messages.extend(self.current.messages)
+ return messages
+
+ def latest_llm_result_for_model(self, provider_model_key: str):
+ from helpers.llm_result import result_from_metadata
+
+ for message in reversed(self.all_messages()):
+ if not message.ai:
+ continue
+ result = result_from_metadata(message.metadata)
+ if not result:
+ continue
+ if result.provider_model_key == provider_model_key and result.response_id:
+ return result
+ return None
+
+ def trim_embeds(self, max_embeds: int) -> int:
+ if max_embeds == -1:
+ return 0
+
+ embeds_count = 0
+ removed = 0
+
+ for record in reversed(self.bulks + self.topics + [self.current]):
+ embeds_count, removed_now = self._trim_embeds_in_record(record, embeds_count, max_embeds)
+ removed += removed_now
+
+ return removed
+
+ def remove_all_embeds(self) -> int:
+ return self.trim_embeds(0)
+
+ def _trim_embeds_in_record(
+ self, record: Record, embeds_count: int, max_embeds: int
+ ) -> tuple[int, int]:
+ if isinstance(record, Message):
+ if record.summary:
+ return embeds_count, 0
+
+ if not _is_raw_message(record.content):
+ return embeds_count, 0
+
+ raw_message = cast(dict[str, Any], record.content)
+ raw_content = raw_message.get("raw_content", [])
+ if not isinstance(raw_content, list):
+ return embeds_count, 0
+
+ embeds_in_message = sum(1 for item in raw_content if _is_embedded_data(item))
+ if embeds_in_message <= 0:
+ return embeds_count, 0
+
+ if embeds_count + embeds_in_message > max_embeds:
+ record.set_summary("embedded data removed")
+ return embeds_count + embeds_in_message, embeds_in_message
+
+ return embeds_count + embeds_in_message, 0
+
+ if isinstance(record, Topic):
+ removed = 0
+ for message in reversed(record.messages):
+ embeds_count, removed_now = self._trim_embeds_in_record(message, embeds_count, max_embeds)
+ removed += removed_now
+ return embeds_count, removed
+
+ if isinstance(record, Bulk):
+ removed = 0
+ for nested in reversed(record.records):
+ embeds_count, removed_now = self._trim_embeds_in_record(nested, embeds_count, max_embeds)
+ removed += removed_now
+ return embeds_count, removed
+
+ return embeds_count, 0
+
+ @staticmethod
+ def from_dict(data: dict, history: "History"):
+ history.counter = data.get("counter", 0)
+ history.bulks = [Bulk.from_dict(b, history=history) for b in data["bulks"]]
+ history.topics = [Topic.from_dict(t, history=history) for t in data["topics"]]
+ history.current = Topic.from_dict(data["current"], history=history)
+ return history
+
+ def to_dict(self):
+ return {
+ "_cls": "History",
+ "counter": self.counter,
+ "bulks": [b.to_dict() for b in self.bulks],
+ "topics": [t.to_dict() for t in self.topics],
+ "current": self.current.to_dict(),
+ }
+
+ def serialize(self):
+ data = self.to_dict()
+ return _json_dumps(data)
+
+ async def compress(self):
+ compressed = False
+ total = self._get_ctx_size_for_history()
+ curr, hist, bulk = (
+ self.get_current_topic_tokens(),
+ self.get_topics_tokens(),
+ self.get_bulks_tokens(),
+ )
+ if (curr + hist + bulk) <= total:
+ return False
+
+ target = total * COMPRESSION_TARGET_RATIO
+ prev_total = curr + hist + bulk + 1
+ while True:
+ curr, hist, bulk = (
+ self.get_current_topic_tokens(),
+ self.get_topics_tokens(),
+ self.get_bulks_tokens(),
+ )
+
+ # safeguard against infinite loop in case LLM bloats the summary for some reason
+ if (curr + hist + bulk) >= prev_total:
+ break
+ prev_total = curr + hist + bulk
+
+ ratios = [
+ (curr, CURRENT_TOPIC_RATIO, "current_topic"),
+ (hist, HISTORY_TOPIC_RATIO, "history_topic"),
+ (bulk, HISTORY_BULK_RATIO, "history_bulk"),
+ ]
+ ratios = sorted(ratios, key=lambda x: (x[0] / target) / x[1], reverse=True)
+ compressed_part = False
+ for ratio in ratios:
+ if ratio[0] > ratio[1] * target:
+ over_part = ratio[2]
+ if over_part == "current_topic":
+ compressed_part = await self.current.compress()
+ elif over_part == "history_topic":
+ compressed_part = await self.compress_topics()
+ else:
+ compressed_part = await self.compress_bulks()
+ if compressed_part:
+ break
+
+ if compressed_part:
+ compressed = True
+ continue
+ else:
+ return compressed
+ return compressed
+
+ async def compress_topics(self) -> bool:
+
+ # 1. first identify large messages and compress them cheaply
+ for topic in self.topics:
+ if topic.compress_large_messages(HISTORY_TOPIC_RATIO*LARGE_MESSAGE_TO_HISTORY_TOPIC_RATIO):
+ return True
+
+ # 2. summarize topics attention window one by one
+ for topic in self.topics:
+ if await topic.compress_attention(HISTORY_TOPIC_ATTENTION_COMPRESSION):
+ return True
+
+ # 3. move oldest topics to bulks in chunks
+ if self.topics:
+ count = TOPICS_MERGE_COUNT if len(self.topics) >= TOPICS_MERGE_COUNT else 1
+ chunk = self.topics[:count]
+ bulk = Bulk(history=self)
+ bulk.records.extend(chunk)
+ await bulk.summarize()
+ self.bulks.append(bulk)
+ self.topics[:count] = []
+ return True
+ return False
+
+ async def compress_bulks(self):
+ # merge bulks if possible
+ compressed = await self.merge_bulks_by(BULK_MERGE_COUNT)
+ # remove oldest bulk if necessary
+ if not compressed:
+ self.bulks.pop(0)
+ return True
+ return compressed
+
+ async def merge_bulks_by(self, count: int):
+ # if bulks is empty, return False
+ if len(self.bulks) == 0:
+ return False
+ # merge bulks in groups of count, even if there are fewer than count
+ bulks = await asyncio.gather(
+ *[
+ self.merge_bulks(self.bulks[i : i + count])
+ for i in range(0, len(self.bulks), count)
+ ]
+ )
+ self.bulks = bulks
+ return True
+
+ async def merge_bulks(self, bulks: list[Bulk]) -> Bulk:
+ bulk = Bulk(history=self)
+ bulk.records = cast(list[Record], bulks)
+ await bulk.summarize()
+ return bulk
+
+ def _get_ctx_size_for_history(self) -> int:
+ chat_cfg = get_chat_model_config(self.agent)
+ ctx_length = int(chat_cfg.get("ctx_length", 128000))
+ ctx_history = float(chat_cfg.get("ctx_history", 0.7))
+ return int(ctx_length * ctx_history)
+
+ def _get_max_embeds(self) -> int:
+ chat_cfg = get_chat_model_config(self.agent)
+ if not chat_cfg.get("vision", False):
+ return 0
+
+ max_embeds = int(chat_cfg.get("max_embeds", 10))
+ if max_embeds <= 0:
+ max_embeds = -1
+ return max_embeds
+
+
+
+def deserialize_history(json_data: str, agent) -> History:
+ history = History(agent=agent)
+ if json_data:
+ data = _json_loads(json_data)
+ history = History.from_dict(data, history=history)
+ return history
+
+
+def _stringify_output(output: OutputMessage, ai_label="ai", human_label="human"):
+ return f'{ai_label if output["ai"] else human_label}: {_stringify_content(output["content"])}'
+
+
+def _stringify_content(content: MessageContent) -> str:
+ # already a string
+ if isinstance(content, str):
+ return content
+
+ # raw messages return preview or trimmed json
+ if _is_raw_message(content):
+ raw_message = cast(dict[str, Any], content)
+ preview = raw_message.get("preview")
+ if isinstance(preview, str) and preview:
+ return preview
+ text = _json_dumps(content)
+ if len(text) > RAW_MESSAGE_OUTPUT_TEXT_TRIM:
+ return text[:RAW_MESSAGE_OUTPUT_TEXT_TRIM] + "... TRIMMED"
+ return text
+
+ # regular messages of non-string are dumped as json
+ return _json_dumps(content)
+
+
+def _output_content_langchain(content: MessageContent):
+ if isinstance(content, str):
+ return content
+ if _is_raw_message(content):
+ raw_content = cast(dict[str, Any], content).get("raw_content")
+ return raw_content if raw_content is not None else _json_dumps(content)
+ try:
+ return _json_dumps(content)
+ except Exception as e:
+ raise e
+
+
+def group_outputs_abab(outputs: list[OutputMessage]) -> list[OutputMessage]:
+ result = []
+ for out in outputs:
+ if result and result[-1]["ai"] == out["ai"]:
+ result[-1] = OutputMessage(
+ ai=result[-1]["ai"],
+ content=_merge_outputs(result[-1]["content"], out["content"]),
+ )
+ else:
+ result.append(out)
+ return result
+
+
+def group_messages_abab(messages: list[BaseMessage]) -> list[BaseMessage]:
+ result = []
+ for msg in messages:
+ if result and isinstance(result[-1], type(msg)):
+ # create new instance of the same type with merged content
+ result[-1] = type(result[-1])(content=_merge_outputs(result[-1].content, msg.content)) # type: ignore
+ else:
+ result.append(msg)
+ return result
+
+
+def output_langchain(messages: list[OutputMessage]):
+ result = []
+ for m in messages:
+ content = _output_content_langchain(content=m["content"])
+ if not content or (isinstance(content, str) and not content.strip()):
+ continue # skip empty messages, models
+ if m["ai"]:
+ result.append(AIMessage(content)) # type: ignore
+ else:
+ result.append(HumanMessage(content)) # type: ignore
+ # ensure message type alternation
+ result = group_messages_abab(result)
+ while result and isinstance(result[0], AIMessage):
+ result.pop(0)
+ return result
+
+
+def output_text(messages: list[OutputMessage], ai_label="ai", human_label="human"):
+ return "\n".join(_stringify_output(o, ai_label, human_label) for o in messages)
+
+
+def clear_responses_provider_state(agent) -> None:
+ key = getattr(agent, "DATA_NAME_RESPONSES_STATE", "responses_state")
+ get_data = getattr(agent, "get_data", None)
+ set_data = getattr(agent, "set_data", None)
+ if not callable(get_data) or not callable(set_data):
+ return
+
+ state = get_data(key)
+ if not isinstance(state, dict):
+ return
+
+ state = dict(state)
+ removed = False
+ for field in ("response_id", "previous_response_id"):
+ if field in state:
+ state.pop(field, None)
+ removed = True
+
+ if removed:
+ set_data(key, state)
+
+
+def _merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent:
+ if isinstance(a, str) and isinstance(b, str):
+ return a + "\n" + b
+
+ def make_list(obj: MessageContent) -> list[MessageContent]:
+ if isinstance(obj, list):
+ return obj # type: ignore
+ if isinstance(obj, dict):
+ return [obj]
+ if isinstance(obj, str):
+ return [{"type": "text", "text": obj}]
+ return [obj]
+
+ a = make_list(a)
+ b = make_list(b)
+
+ return cast(MessageContent, a + b)
+
+
+def _merge_properties(
+ a: Dict[str, MessageContent], b: Dict[str, MessageContent]
+) -> Dict[str, MessageContent]:
+ result = a.copy()
+ for k, v in b.items():
+ if k in result:
+ result[k] = _merge_outputs(result[k], v)
+ else:
+ result[k] = v
+ return result
+
+
+def _is_raw_message(obj: object) -> bool:
+ return isinstance(obj, Mapping) and "raw_content" in obj
+
+
+def _is_embedded_data(obj: object) -> bool:
+ return isinstance(obj, Mapping) and obj.get("type") == "image_url"
+
+
+def _messages_from_record(record: Record) -> list[Message]:
+ if isinstance(record, Message):
+ return [record]
+ if isinstance(record, Topic):
+ return list(record.messages)
+ if isinstance(record, Bulk):
+ messages: list[Message] = []
+ for nested in record.records:
+ messages.extend(_messages_from_record(nested))
+ return messages
+ return []
+
+
+def _json_dumps(obj):
+ return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
+
+
+def _json_loads(obj):
+ return json.loads(obj)
diff --git a/helpers/history.py.dox.md b/helpers/history.py.dox.md
index 21c18f033cc80de6771c1e48ded797d47469ba71..ac1c2b03fb304d595ff6ad9b07894d3969966e23 100644
--- a/helpers/history.py.dox.md
+++ b/helpers/history.py.dox.md
@@ -1,3 +1,114 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:403f5519d2771b45db9e9b4b3818c0c908e0bda6d242d6ebf7adf3a2d454bf93
-size 6289
+# history.py DOX
+
+## Purpose
+
+- Own the `history.py` helper module.
+- This module owns chat history message records and model-output conversion.
+- Keep this file-level DOX profile synchronized with `history.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `history.py` owns the runtime implementation.
+- `history.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `RawMessage` (`TypedDict`)
+- `OutputMessage` (`TypedDict`)
+- `Record` (no explicit base class)
+ - `get_tokens(self) -> int`
+ - `async compress(self) -> bool`
+ - `output(self) -> list[OutputMessage]`
+ - `async summarize(self) -> str`
+ - `to_dict(self) -> dict`
+ - `from_dict(data: dict, history: 'History')`
+ - `output_langchain(self)`
+ - `output_text(self, human_label=..., ai_label=...)`
+- `Message` (`Record`)
+ - `get_tokens(self) -> int`
+ - `calculate_tokens(self)`
+ - `set_summary(self, summary: str)`
+ - `async compress(self)`
+ - `output(self)`
+ - `output_langchain(self)`
+ - `output_text(self, human_label=..., ai_label=...)`
+ - `to_dict(self)`
+- `Topic` (`Record`)
+ - `get_tokens(self)`
+ - `add_message(self, ai: bool, content: MessageContent, tokens: int=..., id: str=...) -> Message`
+ - `output(self) -> list[OutputMessage]`
+ - `async summarize(self)`
+ - `compress_large_messages(self, message_ratio: float=...) -> bool`
+ - `async compress(self) -> bool`
+ - `async compress_attention(self, ratio: float=...) -> bool`
+ - `async summarize_messages(self, messages: list[Message])`
+- `Bulk` (`Record`)
+ - `get_tokens(self)`
+ - `output(self, human_label: str=..., ai_label: str=...) -> list[OutputMessage]`
+ - `async compress(self)`
+ - `async summarize(self)`
+ - `to_dict(self)`
+ - `from_dict(data: dict, history: 'History')`
+- `History` (`Record`)
+ - `get_tokens(self) -> int`
+ - `is_over_limit(self)`
+ - `get_bulks_tokens(self) -> int`
+ - `get_topics_tokens(self) -> int`
+ - `get_current_topic_tokens(self) -> int`
+ - `add_message(self, ai: bool, content: MessageContent, tokens: int=..., id: str=...) -> Message`
+ - `new_topic(self)`
+ - `output(self) -> list[OutputMessage]`
+- Top-level functions:
+- `deserialize_history(json_data: str, agent) -> History`
+- `_stringify_output(output: OutputMessage, ai_label=..., human_label=...)`
+- `_stringify_content(content: MessageContent) -> str`
+- `_output_content_langchain(content: MessageContent)`
+- `group_outputs_abab(outputs: list[OutputMessage]) -> list[OutputMessage]`
+- `group_messages_abab(messages: list[BaseMessage]) -> list[BaseMessage]`
+- `output_langchain(messages: list[OutputMessage])`
+- `output_text(messages: list[OutputMessage], ai_label=..., human_label=...)`
+- `clear_responses_provider_state(agent) -> None`
+- `_merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent`
+- `_merge_properties(a: Dict[str, MessageContent], b: Dict[str, MessageContent]) -> Dict[str, MessageContent]`
+- `_is_raw_message(obj: object) -> bool`
+- `_is_embedded_data(obj: object) -> bool`
+- `_json_dumps(obj)`
+- `_json_loads(obj)`
+- Notable constants/configuration names: `BULK_MERGE_COUNT`, `TOPICS_MERGE_COUNT`, `CURRENT_TOPIC_RATIO`, `HISTORY_TOPIC_RATIO`, `HISTORY_BULK_RATIO`, `CURRENT_TOPIC_ATTENTION_COMPRESSION`, `HISTORY_TOPIC_ATTENTION_COMPRESSION`, `LARGE_MESSAGE_TO_CURRENT_TOPIC_RATIO`, `LARGE_MESSAGE_TO_HISTORY_TOPIC_RATIO`, `RAW_MESSAGE_OUTPUT_TEXT_TRIM`, `COMPRESSION_TARGET_RATIO`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- `clear_responses_provider_state(agent)` removes the active provider continuation IDs after local history rewrites while preserving stored response ID lists for later cleanup.
+- `Message.from_dict()` normalizes legacy AI Responses metadata through `LLMResult.metadata()` so loaded chats shed transient payloads while unrelated metadata and non-AI tool-result inputs remain intact.
+- `output_langchain()` removes leading assistant messages after grouping so provider histories always begin with a user turn; the WebUI greeting remains persisted and displayed but is not sent as an orphaned assistant message.
+- `_json_dumps()` emits compact JSON (`","`, `":"` separators) for serialized history and generated non-string user-turn content.
+- Observed side-effect areas: filesystem writes, filesystem deletion, model calls, plugin state, settings/state persistence, secret handling.
+- Imported dependency areas include: `abc`, `asyncio`, `collections`, `collections.abc`, `enum`, `helpers`, `json`, `langchain_core.messages`, `math`, `plugins._model_config.helpers.model_config`, `typing`, `uuid`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `History`, `_is_raw_message`, `_json_dumps`, `group_messages_abab`, `join`, `make_list`, `cast`, `a.copy`, `json.dumps`, `json.loads`, `globals.from_dict`, `output_langchain`, `output_text`, `self.output_text`, `tokens.approximate_tokens`, `self.calculate_tokens`, `Message`, `get_chat_model_config`, `large_msgs.sort`, `self.compress_large_messages`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_chat_compaction.py`
+ - `tests/test_error_retry_plugin.py`
+ - `tests/test_history_compression_wait.py`
+ - `tests/test_mcp_handler_multimodal.py`
+ - `tests/test_memory_quality.py`
+ - `tests/test_model_config_project_presets.py`
+ - `tests/test_office_document_store.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/images.py b/helpers/images.py
index 2c776604794abfbe3a3eabfd0a526b2726bc134f..4e9a5e9172812e7df1f73461dcbf5fdce6138836 100644
--- a/helpers/images.py
+++ b/helpers/images.py
@@ -1,3 +1,104 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9f85184a18da194aea46f4a9b6f49bbb0b8942b1649b05f4f066fee8db46a0e2
-size 3489
+import base64
+import io
+import math
+import mimetypes
+from pathlib import Path
+from typing import Any
+from urllib.parse import unquote, urlparse
+
+from PIL import Image
+
+
+def prepare_content(content: Any) -> Any:
+ if isinstance(content, list):
+ return [prepare_content(item) for item in content]
+ if not isinstance(content, dict):
+ return content
+
+ if content.get("type") == "image_url":
+ image_url = content.get("image_url")
+ if isinstance(image_url, dict):
+ url = str(image_url.get("url", "") or "").strip()
+ if is_local_ref(url):
+ return {**content, "image_url": {**image_url, "url": to_data_url(url)}}
+ elif isinstance(image_url, str):
+ url = image_url.strip()
+ if is_local_ref(url):
+ return {**content, "image_url": {"url": to_data_url(url)}}
+
+ return {key: prepare_content(value) for key, value in content.items()}
+
+
+def is_local_ref(url: str) -> bool:
+ if not url:
+ return False
+ lowered = url.lower()
+ if lowered.startswith(("http://", "https://", "data:")):
+ return False
+ return lowered.startswith("file://") or url.startswith(("/", "./", "../", "~"))
+
+
+def to_data_url(url: str) -> str:
+ path = resolve_ref(url)
+ mime_type = mimetypes.guess_type(path.name)[0]
+ if not mime_type or not mime_type.startswith("image/"):
+ raise ValueError(f"Image attachment must have an image MIME type: {path}")
+ encoded = base64.b64encode(path.read_bytes()).decode("utf-8")
+ return f"data:{mime_type};base64,{encoded}"
+
+
+def resolve_ref(url: str) -> Path:
+ raw_path = unquote(urlparse(url).path) if url.lower().startswith("file://") else url
+ path = Path(raw_path).expanduser()
+ candidates = [path]
+ if raw_path.startswith("/a0/"):
+ from helpers import files
+
+ candidates.append(Path(files.fix_dev_path(raw_path)))
+ elif not path.is_absolute():
+ from helpers import files
+
+ candidates.append(Path(files.get_abs_path(raw_path)))
+
+ seen: set[str] = set()
+ for candidate in candidates:
+ key = str(candidate)
+ if key in seen:
+ continue
+ seen.add(key)
+ if candidate.exists() and candidate.is_file():
+ return candidate
+
+ raise FileNotFoundError(f"Image attachment path does not exist: {raw_path}")
+
+
+def compress_image(image_data: bytes, *, max_pixels: int = 256_000, quality: int = 50) -> bytes:
+ """Compress an image by scaling it down and converting to JPEG with quality settings.
+
+ Args:
+ image_data: Raw image bytes
+ max_pixels: Maximum number of pixels in the output image (width * height)
+ quality: JPEG quality setting (1-100)
+
+ Returns:
+ Compressed image as bytes
+ """
+ # load image from bytes
+ img = Image.open(io.BytesIO(image_data))
+
+ # calculate scaling factor to get to max_pixels
+ current_pixels = img.width * img.height
+ if current_pixels > max_pixels:
+ scale = math.sqrt(max_pixels / current_pixels)
+ new_width = int(img.width * scale)
+ new_height = int(img.height * scale)
+ img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
+
+ # convert to RGB if needed (for JPEG)
+ if img.mode in ('RGBA', 'P'):
+ img = img.convert('RGB')
+
+ # save as JPEG with compression
+ output = io.BytesIO()
+ img.save(output, format='JPEG', quality=quality, optimize=True)
+ return output.getvalue()
diff --git a/helpers/images.py.dox.md b/helpers/images.py.dox.md
index 36ffa19939188dcb7034d4d7bb3454dbb64630d1..1d2cd0320dd63dfad3a3a372c6975052e911a804 100644
--- a/helpers/images.py.dox.md
+++ b/helpers/images.py.dox.md
@@ -1,3 +1,48 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:192d4d9194e497c36ef2177c3edae15900210727a614ee61d0425588b30a5abf
-size 2521
+# images.py DOX
+
+## Purpose
+
+- Own the `images.py` helper module.
+- This module resolves, compresses, and serializes image references for model inputs.
+- Keep this file-level DOX profile synchronized with `images.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `images.py` owns the runtime implementation.
+- `images.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `prepare_content(content: Any) -> Any`
+- `is_local_ref(url: str) -> bool`
+- `to_data_url(url: str) -> str`
+- `resolve_ref(url: str) -> Path`
+- `compress_image(image_data: bytes, max_pixels: int=..., quality: int=...) -> bytes`: Compress an image by scaling it down and converting to JPEG with quality settings.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, network calls, settings/state persistence.
+- Imported dependency areas include: `PIL`, `base64`, `io`, `math`, `mimetypes`, `pathlib`, `typing`, `urllib.parse`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `url.lower`, `lowered.startswith`, `resolve_ref`, `base64.b64encode.decode`, `Path.expanduser`, `raw_path.startswith`, `FileNotFoundError`, `io.BytesIO`, `img.save`, `output.getvalue`, `prepare_content`, `url.startswith`, `mimetypes.guess_type`, `ValueError`, `url.lower.startswith`, `unquote`, `seen.add`, `math.sqrt`, `img.resize`, `img.convert`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_image_get_security.py`
+ - `tests/test_vision_load_image_refs.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/integration_commands.py b/helpers/integration_commands.py
index e6dfe3a4c22b9025f365116cba8e32b4f7551c5c..34eb9fbfad96b187de001f666a9c055d9967503d 100644
--- a/helpers/integration_commands.py
+++ b/helpers/integration_commands.py
@@ -1,3 +1,567 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3187047e38b564f3bf8bd19d7a6edc1af6229538c472db6bbd4556ab705b6f4b
-size 19648
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from helpers import message_queue as mq
+from helpers import projects
+from helpers.persist_chat import save_tmp_chat
+from helpers.state_monitor_integration import mark_dirty_for_context
+from plugins._model_config.helpers import model_config
+
+if TYPE_CHECKING:
+ from agent import AgentContext
+
+
+_CLEAR_VALUES = {"", "default", "none", "clear", "off"}
+_MODEL_INHERIT_VALUES = {"", "none", "clear", "off", "inherit"}
+
+
+@dataclass(frozen=True)
+class IntegrationCommandDef:
+ name: str
+ description: str
+ category: str
+ aliases: tuple[str, ...] = ()
+ args_hint: str = ""
+ menu: bool = True
+ integrations: tuple[str, ...] = ()
+
+
+COMMAND_REGISTRY: tuple[IntegrationCommandDef, ...] = (
+ IntegrationCommandDef("commands", "Show all integration commands.", "Info", aliases=("help",)),
+ IntegrationCommandDef(
+ "status",
+ "Show this chat's project, model, agent, and queue state.",
+ "Info",
+ ),
+ IntegrationCommandDef("new", "Start a fresh chat context.", "Session"),
+ IntegrationCommandDef(
+ "sessions",
+ "Show or switch recent chat sessions.",
+ "Session",
+ aliases=("session",),
+ integrations=("telegram",),
+ ),
+ IntegrationCommandDef("clear", "Reset the current chat context.", "Session", aliases=("reset",)),
+ IntegrationCommandDef(
+ "queue",
+ "Show or manage queued messages.",
+ "Session",
+ args_hint="[send|clear]",
+ ),
+ IntegrationCommandDef("send", "Send queued messages now.", "Session", aliases=("push",)),
+ IntegrationCommandDef(
+ "steer",
+ "Intervene in the currently running task.",
+ "Session",
+ args_hint="",
+ ),
+ IntegrationCommandDef("pause", "Pause the active run.", "Session"),
+ IntegrationCommandDef("resume", "Resume a paused run.", "Session"),
+ IntegrationCommandDef("nudge", "Nudge the active run.", "Session"),
+ IntegrationCommandDef(
+ "stream",
+ "Enable or disable Telegram response streaming.",
+ "Configuration",
+ args_hint="[on|off]",
+ integrations=("telegram",),
+ ),
+ IntegrationCommandDef(
+ "tools",
+ "Show or hide Telegram tool progress.",
+ "Configuration",
+ args_hint="[on|off]",
+ integrations=("telegram",),
+ ),
+ IntegrationCommandDef(
+ "project",
+ "Show or switch the active project.",
+ "Configuration",
+ args_hint="[name|none]",
+ ),
+ IntegrationCommandDef(
+ "model",
+ "Show or switch the chat model preset.",
+ "Configuration",
+ aliases=("config", "preset"),
+ args_hint="[preset|inherit]",
+ ),
+ IntegrationCommandDef(
+ "agent",
+ "Show or switch the agent profile.",
+ "Configuration",
+ aliases=("profile",),
+ args_hint="[profile]",
+ ),
+)
+
+
+_COMMAND_LOOKUP = {
+ f"/{name}": command
+ for command in COMMAND_REGISTRY
+ for name in (command.name, *command.aliases)
+}
+
+
+def extract_command_line(text: str) -> str:
+ for line in (text or "").splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ return stripped
+ return ""
+
+
+def parse_command(text: str, *, integration: str | None = None) -> tuple[str, str] | None:
+ line = extract_command_line(text)
+ if not line.startswith("/"):
+ return None
+
+ command, _, args = line.partition(" ")
+ command = _normalize_command_token(command)
+ resolved = resolve_command(command, integration=integration)
+ if not resolved:
+ return None
+
+ return f"/{resolved.name}", args.strip()
+
+
+def resolve_command(command: str, *, integration: str | None = None) -> IntegrationCommandDef | None:
+ normalized = _normalize_command_token(command)
+ if not normalized.startswith("/"):
+ normalized = f"/{normalized}"
+ command_def = _COMMAND_LOOKUP.get(normalized)
+ if not command_def or not _is_command_available(command_def, integration):
+ return None
+ return command_def
+
+
+def telegram_menu_commands() -> list[tuple[str, str]]:
+ return [
+ (command.name, _telegram_description(command))
+ for command in COMMAND_REGISTRY
+ if command.menu and _is_command_available(command, "telegram")
+ ]
+
+
+def command_names(include_aliases: bool = True, *, integration: str | None = None) -> list[str]:
+ names: list[str] = []
+ for command in COMMAND_REGISTRY:
+ if not _is_command_available(command, integration):
+ continue
+ names.append(command.name)
+ if include_aliases:
+ names.extend(command.aliases)
+ return names
+
+
+def help_text(*, full: bool = False, integration: str | None = None) -> str:
+ commands = tuple(
+ command
+ for command in COMMAND_REGISTRY
+ if _is_command_available(command, integration) and (full or command.menu)
+ )
+ lines = ["Available commands:"]
+ for command in commands:
+ args = f" {command.args_hint}" if command.args_hint else ""
+ alias_text = ""
+ if command.aliases:
+ alias_text = f" (alias: {', '.join('/' + alias for alias in command.aliases)})"
+ lines.append(f"/{command.name}{args} - {command.description}{alias_text}")
+ return "\n".join(lines)
+
+
+def unknown_command_text(command: str, *, integration: str | None = None) -> str:
+ token = _normalize_command_token(command).split(" ", 1)[0]
+ return f"Unknown command: {token}\n\n{help_text(full=True, integration=integration)}"
+
+
+def try_handle_command(
+ context: "AgentContext",
+ text: str,
+ *,
+ integration: str | None = None,
+) -> str | None:
+ parsed = parse_command(text, integration=integration)
+ if not parsed:
+ return None
+
+ command, args = parsed
+ if command == "/commands":
+ return help_text(full=True, integration=integration)
+ if command == "/status":
+ return _handle_status(context)
+ if command == "/sessions":
+ return _handle_sessions(context)
+ if command in {"/new", "/clear"}:
+ return _handle_clear(context, new_chat=(command == "/new"))
+ if command == "/send":
+ return _handle_queue(context, "send")
+ if command == "/queue":
+ return _handle_queue(context, args)
+ if command == "/steer":
+ return _handle_steer(context, args)
+ if command == "/pause":
+ return _handle_pause(context)
+ if command == "/resume":
+ return _handle_resume(context)
+ if command == "/nudge":
+ return _handle_nudge(context)
+ if command == "/stream":
+ return _handle_toggle(context, args, "telegram_stream_enabled", "Response streaming")
+ if command == "/tools":
+ return _handle_toggle(context, args, "telegram_tools_enabled", "Tool progress")
+ if command == "/project":
+ return _handle_project(context, args)
+ if command == "/model":
+ return _handle_model(context, args)
+ if command == "/agent":
+ return _handle_agent(context, args)
+ return None
+
+
+def _normalize_command_token(command: str) -> str:
+ normalized = command.strip().lower()
+ if not normalized:
+ return ""
+ token, *rest = normalized.split(" ", 1)
+ if "@" in token:
+ token = token.split("@", 1)[0]
+ return f"{token} {rest[0]}".strip() if rest else token
+
+
+def _is_command_available(command: IntegrationCommandDef, integration: str | None) -> bool:
+ if not command.integrations:
+ return True
+ if not integration:
+ return False
+ return integration.lower() in command.integrations
+
+
+def _handle_queue(context: "AgentContext", args: str) -> str:
+ queue = mq.get_queue(context)
+ count = len(queue)
+ action = args.strip().lower()
+
+ if not action:
+ noun = "message" if count == 1 else "messages"
+ return (
+ f"Queue has {count} {noun}.\n"
+ "Use /send or /queue send to send everything as one batch."
+ )
+
+ if action in {"clear", "reset"}:
+ mq.remove(context)
+ mark_dirty_for_context(context.id, reason="integration_commands.queue_clear")
+ return "Queue cleared."
+
+ if action not in {"send", "all"}:
+ return "Unknown queue action. Use /queue send to flush or /queue clear to clear."
+
+ if count == 0:
+ return "Queue is empty."
+
+ sent_count = mq.send_all_aggregated(context)
+ mark_dirty_for_context(context.id, reason="integration_commands.queue_send")
+ noun = "message" if sent_count == 1 else "messages"
+ return f"Sent {sent_count} queued {noun} as one batch."
+
+
+def _handle_status(context: "AgentContext") -> str:
+ project_name = context.get_data("project") or "none"
+ agent_profile = getattr(context.agent0.config, "profile", "default")
+ running = "running" if context.is_running() else "idle"
+ if getattr(context, "paused", False):
+ running = "paused"
+ queue_count = len(mq.get_queue(context))
+ return (
+ f"Status: {running}\n"
+ f"Project: {project_name}\n"
+ f"Model: {model_config.get_effective_preset_name(context.agent0)}\n"
+ f"Agent: {agent_profile}\n"
+ f"Queued messages: {queue_count}"
+ )
+
+
+def _handle_sessions(context: "AgentContext") -> str:
+ from agent import AgentContext
+
+ contexts = sorted(
+ AgentContext.all(),
+ key=lambda item: str(item.output().get("last_message") or ""),
+ reverse=True,
+ )
+ lines = ["Recent sessions:"]
+ for item in contexts[:4]:
+ marker = " (current)" if item.id == context.id else ""
+ running = " - running" if item.is_running() else ""
+ lines.append(f"- {item.name or item.id}{marker}{running}")
+ if len(contexts) > 4:
+ lines.append(f"And {len(contexts) - 4} more. Use Telegram buttons to page through them.")
+ return "\n".join(lines)
+
+
+def _handle_clear(context: "AgentContext", *, new_chat: bool) -> str:
+ context.reset()
+ mq.remove(context)
+ save_tmp_chat(context)
+ reason = "integration_commands.new" if new_chat else "integration_commands.clear"
+ mark_dirty_for_context(context.id, reason=reason)
+ return "Started a fresh chat." if new_chat else "Chat cleared."
+
+
+def _handle_steer(context: "AgentContext", args: str) -> str:
+ message = args.strip()
+ if not message:
+ return "Usage: /steer "
+ from agent import UserMessage
+
+ context.communicate(UserMessage(message=message))
+ if context.is_running():
+ return "Steering message sent to the active run."
+ return "Message sent."
+
+
+def _handle_pause(context: "AgentContext") -> str:
+ if not context.is_running():
+ return "No active run is currently running."
+ context.paused = True
+ return "Agent paused."
+
+
+def _handle_resume(context: "AgentContext") -> str:
+ context.paused = False
+ return "Agent resumed."
+
+
+def _handle_nudge(context: "AgentContext") -> str:
+ context.nudge()
+ return "Agent nudged."
+
+
+def _handle_toggle(context: "AgentContext", args: str, key: str, label: str) -> str:
+ value = _parse_toggle(args)
+ current = _get_toggle(context, key)
+ if value is None:
+ state = "on" if current else "off"
+ return f"{label}: {state}. Use /{key.split('_')[1]} on or /{key.split('_')[1]} off."
+ context.set_data(key, value)
+ save_tmp_chat(context)
+ mark_dirty_for_context(context.id, reason=f"integration_commands.{key}")
+ return f"{label} {'enabled' if value else 'disabled'}."
+
+
+def _handle_project(context: "AgentContext", args: str) -> str:
+ items = projects.get_active_projects_list() or []
+ current_name = context.get_data("project") or ""
+
+ if not args:
+ current_label = _describe_project(items, current_name)
+ available = ", ".join(_format_project_entry(item) for item in items) or "none"
+ return (
+ f"Current project: {current_label}\n"
+ f"Available projects: {available}\n"
+ "Use /project to switch, or /project none to clear it."
+ )
+
+ desired = _strip_quotes(args)
+ if _normalize_lookup(desired) in _CLEAR_VALUES:
+ if not current_name:
+ return "No project is active."
+ projects.deactivate_project(context.id)
+ return "Cleared the active project."
+
+ match, ambiguous = _match_named_item(items, desired, keys=("name", "title"))
+ if ambiguous:
+ names = ", ".join(_format_project_entry(item) for item in ambiguous)
+ return f"Project name is ambiguous. Matches: {names}"
+ if not match:
+ available = ", ".join(_format_project_entry(item) for item in items) or "none"
+ return f"Project '{desired}' was not found. Available projects: {available}"
+
+ if match.get("name") == current_name:
+ return f"Already using project {match.get('title') or match.get('name')}."
+
+ projects.activate_project(context.id, match["name"])
+ return f"Switched project to {match.get('title') or match['name']}."
+
+
+def _handle_model(context: "AgentContext", args: str) -> str:
+ allowed = model_config.is_chat_override_allowed(context.agent0)
+ presets = [preset for preset in model_config.get_presets() if preset.get("name")]
+ current_override = context.get_data("chat_model_override")
+
+ if not args:
+ current_label = model_config.get_effective_preset_name(context.agent0)
+ available = ", ".join(preset["name"] for preset in presets) or "none"
+ suffix = "Use /model to switch, or /model inherit to use the scoped preset."
+ if not allowed:
+ suffix = "Per-chat config switching is disabled in Model Configuration."
+ return (
+ f"Current model: {current_label}\n"
+ f"Available presets: {available}\n"
+ f"{suffix}"
+ )
+
+ if not allowed:
+ return "Config switching is disabled in Model Configuration."
+
+ desired = _strip_quotes(args)
+ if _normalize_lookup(desired) in _MODEL_INHERIT_VALUES:
+ if not current_override:
+ return "Already using the scoped model preset."
+ context.set_data("chat_model_override", None)
+ save_tmp_chat(context)
+ mark_dirty_for_context(context.id, reason="integration_commands.config_clear")
+ inherited = model_config.get_effective_preset_name(context.agent0)
+ return f"Switched back to the scoped model preset: {inherited}."
+
+ match, ambiguous = _match_named_item(presets, desired, keys=("name",))
+ if ambiguous:
+ names = ", ".join(item["name"] for item in ambiguous)
+ return f"Config name is ambiguous. Matches: {names}"
+ if not match:
+ available = ", ".join(preset["name"] for preset in presets) or "none"
+ return f"Config '{desired}' was not found. Available configs: {available}"
+
+ preset_name = match["name"]
+ if isinstance(current_override, dict) and current_override.get("preset_name") == preset_name:
+ return f"Already using config {preset_name}."
+
+ context.set_data("chat_model_override", {"preset_name": preset_name})
+ save_tmp_chat(context)
+ mark_dirty_for_context(context.id, reason="integration_commands.config_set")
+ return f"Switched model preset to {preset_name}."
+
+
+def _handle_agent(context: "AgentContext", args: str) -> str:
+ from helpers import subagents
+ from initialize import initialize_agent
+
+ items = subagents.get_all_agents_list()
+ current = getattr(context.agent0.config, "profile", "default")
+ if not args:
+ available = ", ".join(_format_agent_entry(item) for item in items) or "none"
+ return (
+ f"Current agent: {current}\n"
+ f"Available agents: {available}\n"
+ "Use /agent to switch after the current run finishes."
+ )
+
+ if context.is_running():
+ return "Agent profile can be changed after the current run finishes."
+
+ desired = _strip_quotes(args)
+ match, ambiguous = _match_named_item(items, desired, keys=("key", "label"))
+ if ambiguous:
+ names = ", ".join(_format_agent_entry(item) for item in ambiguous)
+ return f"Agent profile is ambiguous. Matches: {names}"
+ if not match:
+ available = ", ".join(_format_agent_entry(item) for item in items) or "none"
+ return f"Agent profile '{desired}' was not found. Available agents: {available}"
+
+ profile = str(match["key"])
+ if profile == current:
+ return f"Already using agent {match.get('label') or profile}."
+
+ config = initialize_agent(override_settings={"agent_profile": profile})
+ context.config = config
+ context.agent0.config = config
+ save_tmp_chat(context)
+ mark_dirty_for_context(context.id, reason="integration_commands.agent_set")
+ return f"Switched agent to {match.get('label') or profile}."
+
+
+def _format_project_entry(item: dict) -> str:
+ title = str(item.get("title", "") or "").strip()
+ name = str(item.get("name", "") or "").strip()
+ if title and title.lower() != name.lower():
+ return f"{title} ({name})"
+ return name or title
+
+
+def _format_agent_entry(item: dict) -> str:
+ key = str(item.get("key", "") or "").strip()
+ label = str(item.get("label", "") or "").strip()
+ if label and label.lower() != key.lower():
+ return f"{label} ({key})"
+ return key or label
+
+
+def _telegram_description(command: IntegrationCommandDef) -> str:
+ description = command.description.strip()
+ return description[:255] if len(description) > 255 else description
+
+
+def _describe_project(items: list[dict], current_name: str) -> str:
+ if not current_name:
+ return "none"
+ for item in items:
+ if item.get("name") == current_name:
+ return item.get("title") or current_name
+ return current_name
+
+
+def _strip_quotes(value: str) -> str:
+ trimmed = value.strip()
+ if len(trimmed) >= 2 and trimmed[0] == trimmed[-1] and trimmed[0] in {'"', "'"}:
+ return trimmed[1:-1].strip()
+ return trimmed
+
+
+def _normalize_lookup(value: str) -> str:
+ lowered = value.lower().strip()
+ lowered = re.sub(r"[\s_\-]+", " ", lowered)
+ lowered = re.sub(r"[^a-z0-9 ]+", "", lowered)
+ return lowered.strip()
+
+
+def _get_toggle(context: "AgentContext", key: str) -> bool:
+ value = context.get_data(key)
+ return True if value is None else bool(value)
+
+
+def _parse_toggle(args: str) -> bool | None:
+ value = _normalize_lookup(args)
+ if value in {"on", "enable", "enabled", "yes", "true", "1"}:
+ return True
+ if value in {"off", "disable", "disabled", "no", "false", "0"}:
+ return False
+ return None
+
+
+def _match_named_item(
+ items: list[dict],
+ desired: str,
+ *,
+ keys: tuple[str, ...],
+) -> tuple[dict | None, list[dict]]:
+ normalized = _normalize_lookup(desired)
+ exact_matches: list[dict] = []
+
+ for item in items:
+ values = [str(item.get(key, "") or "") for key in keys]
+ normalized_values = [_normalize_lookup(value) for value in values if value]
+ if normalized in normalized_values:
+ exact_matches.append(item)
+
+ if len(exact_matches) == 1:
+ return exact_matches[0], []
+ if len(exact_matches) > 1:
+ return None, exact_matches
+
+ partial_matches: list[dict] = []
+ for item in items:
+ values = [str(item.get(key, "") or "") for key in keys]
+ normalized_values = [_normalize_lookup(value) for value in values if value]
+ if any(normalized and normalized in value for value in normalized_values):
+ partial_matches.append(item)
+
+ if len(partial_matches) == 1:
+ return partial_matches[0], []
+ if len(partial_matches) > 1:
+ return None, partial_matches
+
+ return None, []
diff --git a/helpers/integration_commands.py.dox.md b/helpers/integration_commands.py.dox.md
index 38da24decda8b3a5e103b079cd7ad67b63b9d463..86795ec28d66a4309e20da96007c053e0da31a01 100644
--- a/helpers/integration_commands.py.dox.md
+++ b/helpers/integration_commands.py.dox.md
@@ -1,3 +1,47 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:172b31c7651fe095fc55e0f5ee977d98f90330ca4dcf347b765974b040f8b66b
-size 2987
+# integration_commands.py DOX
+
+## Purpose
+
+- Own the `integration_commands.py` helper module.
+- This module parses external integration slash commands such as project/model/send controls.
+- Keep this file-level DOX profile synchronized with `integration_commands.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `integration_commands.py` owns the runtime implementation.
+- `integration_commands.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Public command helpers are `extract_command_line`, `parse_command`, `resolve_command`, `telegram_menu_commands`, `command_names`, `help_text`, `unknown_command_text`, and `try_handle_command`.
+- Internal handlers own queue, status, session, lifecycle, toggle, project, model-preset, and agent-profile commands.
+- Notable constants are `COMMAND_REGISTRY`, `_CLEAR_VALUES`, and `_MODEL_INHERIT_VALUES`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem writes, model calls, plugin state, settings/state persistence.
+- Imported dependency areas include: `__future__`, `helpers`, `helpers.persist_chat`, `helpers.state_monitor_integration`, `plugins._model_config.helpers`, `re`, `typing`.
+- `/agent` switches the top-level chat profile and preserves existing
+ subordinate agent profiles. Choices come from the shared presentation
+ catalog, though status may report an existing chat using the utility profile.
+- `/model ` stores a per-chat global preset reference, `/model inherit` clears it, and status always reports the effective scoped-or-chat preset. `Default` is a real selectable preset, not an alias for clearing the chat selection.
+
+## Key Concepts
+
+- Important called helpers/classes include `mq.get_queue`, `mq.send_all_aggregated`, `mark_dirty_for_context`, `projects.activate_project`, `initialize_agent`, `model_config.get_effective_preset_name`, `model_config.get_presets`, `context.get_data`, `context.set_data`, and `save_tmp_chat`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_subagent_profiles.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/job_loop.py b/helpers/job_loop.py
index 106d8152b22ff106f838270ad37f270cff98f813..c5fdf86fe26e2c212e2feb50aa9de8a5530b461a 100644
--- a/helpers/job_loop.py
+++ b/helpers/job_loop.py
@@ -1,3 +1,57 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:aadfd3159bac11951e097b9bbf269228c5303a09cc83276934ab7d66e27d48ab
-size 1728
+import asyncio
+from datetime import datetime
+import time
+from helpers.task_scheduler import TaskScheduler
+from helpers.print_style import PrintStyle
+from helpers import errors
+from helpers import runtime
+
+
+SLEEP_TIME = 60
+
+keep_running = True
+pause_time = 0
+
+
+async def run_loop():
+ global pause_time, keep_running
+
+ while True:
+ if runtime.is_development():
+ # Signal to container that the job loop should be paused
+ # if we are runing a development instance to avoid duble-running the jobs
+ try:
+ await runtime.call_development_function(pause_loop)
+ except Exception as e:
+ PrintStyle().error("Failed to pause job loop by development instance: " + errors.error_text(e))
+ if not keep_running and (time.time() - pause_time) > (SLEEP_TIME * 2):
+ resume_loop()
+ if keep_running:
+ try:
+ await scheduler_tick()
+ except Exception as e:
+ PrintStyle().error(errors.format_error(e))
+ await asyncio.sleep(SLEEP_TIME) # TODO! - if we lower it under 1min, it can run a 5min job multiple times in it's target minute
+
+
+async def scheduler_tick():
+ # Get the task scheduler instance and print detailed debug info
+ scheduler = TaskScheduler.get()
+ # Run the scheduler tick
+ await scheduler.tick()
+
+ # Run job_loop extensions (e.g. email polling)
+ from helpers.extension import call_extensions_async
+ await call_extensions_async("job_loop")
+
+
+def pause_loop():
+ global keep_running, pause_time
+ keep_running = False
+ pause_time = time.time()
+
+
+def resume_loop():
+ global keep_running, pause_time
+ keep_running = True
+ pause_time = 0
diff --git a/helpers/job_loop.py.dox.md b/helpers/job_loop.py.dox.md
index 953b6e3af5f4b5abd61e39c8d1b522984ead8773..b0414749da7c86fbddc4784e92ee1b9e315e4f73 100644
--- a/helpers/job_loop.py.dox.md
+++ b/helpers/job_loop.py.dox.md
@@ -1,3 +1,46 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:8e91d88967122246f756865a04fe47e17dd1d2ae3616facca74dd308a85e7c00
-size 2105
+# job_loop.py DOX
+
+## Purpose
+
+- Own the `job_loop.py` helper module.
+- This module runs periodic scheduler and maintenance loops.
+- Keep this file-level DOX profile synchronized with `job_loop.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `job_loop.py` owns the runtime implementation.
+- `job_loop.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `async run_loop()`
+- `async scheduler_tick()`
+- `pause_loop()`
+- `resume_loop()`
+- Notable constants/configuration names: `SLEEP_TIME`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: scheduler state.
+- Imported dependency areas include: `asyncio`, `datetime`, `helpers`, `helpers.print_style`, `helpers.task_scheduler`, `time`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `time.time`, `runtime.is_development`, `scheduler.tick`, `call_extensions_async`, `resume_loop`, `asyncio.sleep`, `runtime.call_development_function`, `PrintStyle.error`, `scheduler_tick`, `errors.format_error`, `PrintStyle`, `errors.error_text`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_api_chat_lifetime.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/kvp.py b/helpers/kvp.py
index afd6294cfac1b728a45dd99265c6afc266c171ba..a051bb6f2f7e0b5a4cdad498a70b8b919ed704ab 100644
--- a/helpers/kvp.py
+++ b/helpers/kvp.py
@@ -1,3 +1,112 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:edf8e1a8e7529a03d56bb1edf4f338abf2cb7b53b8b196175a5fff98b376c010
-size 2918
+import fnmatch
+import glob
+import json
+import os
+import tempfile
+import threading
+from typing import Any
+
+from helpers.files import get_abs_path
+
+_runtime_lock = threading.RLock()
+_runtime_store: dict[str, Any] = {}
+
+_persistent_lock = threading.RLock()
+
+
+def _persistent_dir() -> str:
+ return get_abs_path("usr", "kvp")
+
+
+def _validate_key(key: str) -> None:
+ if not key:
+ raise ValueError("key must not be empty")
+ if "\x00" in key:
+ raise ValueError("key contains NUL")
+ if "/" in key or os.path.sep in key or (os.path.altsep and os.path.altsep in key):
+ raise ValueError("key must not contain path separators")
+
+
+def _key_to_path(key: str) -> str:
+ _validate_key(key)
+ return os.path.join(_persistent_dir(), f"{key}.json")
+
+
+def get_runtime(key: str, default: Any = None) -> Any:
+ with _runtime_lock:
+ return _runtime_store.get(key, default)
+
+
+def set_runtime(key: str, value: Any) -> None:
+ _validate_key(key)
+ with _runtime_lock:
+ _runtime_store[key] = value
+
+
+def remove_runtime(key: str) -> None:
+ with _runtime_lock:
+ _runtime_store.pop(key, None)
+
+
+def find_runtime(pattern: str) -> list[str]:
+ if not pattern:
+ return []
+ with _runtime_lock:
+ return sorted([k for k in _runtime_store.keys() if fnmatch.fnmatch(k, pattern)])
+
+
+def get_persistent(key: str, default: Any = None) -> Any:
+ path = _key_to_path(key)
+ with _persistent_lock:
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except FileNotFoundError:
+ return default
+
+
+def set_persistent(key: str, value: Any) -> None:
+ path = _key_to_path(key)
+ dir_path = os.path.dirname(path)
+
+ with _persistent_lock:
+ os.makedirs(dir_path, exist_ok=True)
+
+ fd, tmp_path = tempfile.mkstemp(prefix=f"{key}.", suffix=".tmp", dir=dir_path)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ json.dump(value, f, ensure_ascii=False, separators=(",", ":"))
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp_path, path)
+ finally:
+ try:
+ if os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+def remove_persistent(key: str) -> None:
+ path = _key_to_path(key)
+ with _persistent_lock:
+ try:
+ os.unlink(path)
+ except FileNotFoundError:
+ return
+
+
+def find_persistent(pattern: str) -> list[str]:
+ if not pattern:
+ return []
+
+ dir_path = _persistent_dir()
+ with _persistent_lock:
+ if not os.path.isdir(dir_path):
+ return []
+
+ search = os.path.join(dir_path, f"{pattern}.json")
+ paths = glob.glob(search)
+ keys = [os.path.basename(p)[: -len(".json")] for p in paths]
+ keys.sort()
+ return keys
diff --git a/helpers/kvp.py.dox.md b/helpers/kvp.py.dox.md
index 179eb6104f0f66c195d3fe6457d2990ade52ff09..d7e6e0de0ff100d4b4982fa98b17a5692c9c5a90 100644
--- a/helpers/kvp.py.dox.md
+++ b/helpers/kvp.py.dox.md
@@ -1,3 +1,52 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:b0daf4643cf6c68a2fc209ff3bb5f5f45f7b98a8179bbe375446dd0ad817fad4
-size 2558
+# kvp.py DOX
+
+## Purpose
+
+- Own the `kvp.py` helper module.
+- This module provides runtime and persistent key-value storage helpers.
+- Keep this file-level DOX profile synchronized with `kvp.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `kvp.py` owns the runtime implementation.
+- `kvp.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `_persistent_dir() -> str`
+- `_validate_key(key: str) -> None`
+- `_key_to_path(key: str) -> str`
+- `get_runtime(key: str, default: Any=...) -> Any`
+- `set_runtime(key: str, value: Any) -> None`
+- `remove_runtime(key: str) -> None`
+- `find_runtime(pattern: str) -> list[str]`
+- `get_persistent(key: str, default: Any=...) -> Any`
+- `set_persistent(key: str, value: Any) -> None`
+- `remove_persistent(key: str) -> None`
+- `find_persistent(pattern: str) -> list[str]`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, settings/state persistence.
+- Imported dependency areas include: `fnmatch`, `glob`, `helpers.files`, `json`, `os`, `tempfile`, `threading`, `typing`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `threading.RLock`, `get_abs_path`, `_validate_key`, `os.path.join`, `_key_to_path`, `os.path.dirname`, `_persistent_dir`, `ValueError`, `_runtime_store.pop`, `os.makedirs`, `tempfile.mkstemp`, `glob.glob`, `keys.sort`, `os.replace`, `os.unlink`, `os.path.isdir`, `json.load`, `os.fdopen`, `json.dump`, `f.flush`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_browser_agent_regressions.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/litellm_transport.py b/helpers/litellm_transport.py
index b39a6ffa2e935e610aba39f7403b9b2ae621ad3d..3a88f574ecf5b76ef88f4c9ddfe7e6d5b19c5b94 100644
--- a/helpers/litellm_transport.py
+++ b/helpers/litellm_transport.py
@@ -1,3 +1,2053 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:0e273ee3a26b1a612bba1873ab21a157234381c56ff963f501337225cb7c13f7
-size 73345
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum
+import hashlib
+import inspect
+import json
+from typing import Any, AsyncIterator, Iterator, Optional
+
+from litellm import (
+ acompletion,
+ adelete_responses,
+ aresponses,
+ completion,
+ delete_responses,
+ responses,
+)
+
+from helpers import images
+from helpers.llm_result import LLMResult
+
+
+ChatChunk = dict[str, str]
+
+
+class TransportMode(Enum):
+ RESPONSES = "responses"
+ CHAT_COMPLETIONS = "chat_completions"
+
+
+class TransportRecovery(Enum):
+ RAISE = "raise"
+ RETRY_RESPONSES = "retry_responses"
+ RETRY_LOCAL_RESPONSES = "retry_local_responses"
+ FALLBACK_TO_CHAT = "fallback_to_chat"
+
+
+RESPONSES_ALIASES = {"response", "responses", "responses_api"}
+RESPONSES_REASONING_EFFORTS = {"minimal", "low", "medium", "high"}
+RESPONSES_REASONING_FALLBACK_EFFORT = "high"
+NO_REASONING_EFFORT_ALIASES = {"", "0", "false", "no", "none", "off", "disabled"}
+RESPONSES_UNSUPPORTED_CACHE: set[str] = set()
+RESPONSES_STATE_UNSUPPORTED_CACHE: set[str] = set()
+RESPONSES_BUILTIN_UNSUPPORTED_CACHE: dict[str, set[str]] = {}
+OPENAI_RESPONSES_EXTRA_BODY_PARAMS = {
+ "context_management",
+ "prompt_cache_retention",
+}
+CACHE_CONTROL_PROMPT_PROVIDERS = {
+ "anthropic",
+ "bedrock",
+ "databricks",
+ "dashscope",
+ "gemini",
+ "gemini_api_oauth",
+ "minimax",
+ "openrouter",
+ "vertex_ai",
+ "vertexai",
+ "z_ai",
+ "zai",
+}
+OPENAI_PROMPT_CACHE_PROVIDERS = {"openai", "azure"}
+RESPONSES_STATE_PROVIDER = "provider"
+RESPONSES_STATE_LOCAL = "local"
+RESPONSES_STATE_OFF = "off"
+RESPONSES_STATES = {
+ RESPONSES_STATE_PROVIDER,
+ RESPONSES_STATE_LOCAL,
+ RESPONSES_STATE_OFF,
+}
+
+
+@dataclass
+class TransportPolicy:
+ mode: TransportMode
+ allow_fallback: bool = True
+ retried_reasoning: bool = False
+ fallback_error: Exception | None = None
+ state_fallback_error: Exception | None = None
+ cache_key: str = ""
+ state: str = RESPONSES_STATE_PROVIDER
+
+ @classmethod
+ def from_request(
+ cls,
+ model: str,
+ kwargs: dict[str, Any],
+ messages: list[dict[str, Any]] | None = None,
+ ) -> "TransportPolicy":
+ mode = cls._pop_mode(kwargs)
+ allow_fallback = _coerce_bool(
+ kwargs.pop("a0_responses_fallback", True), default=True
+ )
+ cache_key = _responses_cache_key(model, kwargs)
+ state = _normalize_responses_state(kwargs.get("responses_state"))
+
+ if mode is TransportMode.CHAT_COMPLETIONS:
+ _drop_responses_only_kwargs(kwargs)
+ return cls(
+ mode=mode,
+ allow_fallback=allow_fallback,
+ cache_key=cache_key,
+ state=RESPONSES_STATE_OFF,
+ )
+
+ if (
+ state == RESPONSES_STATE_PROVIDER
+ and cache_key in RESPONSES_STATE_UNSUPPORTED_CACHE
+ ):
+ kwargs["responses_state"] = RESPONSES_STATE_LOCAL
+ state = RESPONSES_STATE_LOCAL
+
+ _filter_unsupported_builtin_tools(kwargs, cache_key)
+
+ if _should_preserve_cache_control_on_chat(model, kwargs, messages or []):
+ return cls(
+ mode=TransportMode.CHAT_COMPLETIONS,
+ allow_fallback=allow_fallback,
+ cache_key=cache_key,
+ state=RESPONSES_STATE_OFF,
+ )
+
+ if cache_key in RESPONSES_UNSUPPORTED_CACHE:
+ return cls(
+ mode=TransportMode.CHAT_COMPLETIONS,
+ allow_fallback=allow_fallback,
+ fallback_error=RuntimeError("Responses API previously failed"),
+ cache_key=cache_key,
+ state=RESPONSES_STATE_OFF,
+ )
+
+ return cls(
+ mode=TransportMode.RESPONSES,
+ allow_fallback=allow_fallback,
+ cache_key=cache_key,
+ state=state,
+ )
+
+ @staticmethod
+ def _pop_mode(kwargs: dict[str, Any]) -> TransportMode:
+ value = str(kwargs.pop("a0_api_mode", "") or "").lower().strip()
+ if value in RESPONSES_ALIASES:
+ return TransportMode.RESPONSES
+ return TransportMode.CHAT_COMPLETIONS
+
+ @property
+ def using_responses(self) -> bool:
+ return self.mode is TransportMode.RESPONSES
+
+ def recover(self, exc: Exception, *, got_any_chunk: bool) -> TransportRecovery:
+ if not self.using_responses or got_any_chunk:
+ return TransportRecovery.RAISE
+ if not self.retried_reasoning and _is_responses_reasoning_effort_error(exc):
+ self.retried_reasoning = True
+ return TransportRecovery.RETRY_RESPONSES
+ if (
+ self.state == RESPONSES_STATE_PROVIDER
+ and _is_responses_state_unsupported_error(exc)
+ ):
+ self.state = RESPONSES_STATE_LOCAL
+ self.state_fallback_error = exc
+ if self.cache_key:
+ RESPONSES_STATE_UNSUPPORTED_CACHE.add(self.cache_key)
+ return TransportRecovery.RETRY_LOCAL_RESPONSES
+ if self.allow_fallback and _is_responses_not_supported_error(exc):
+ self.mode = TransportMode.CHAT_COMPLETIONS
+ self.fallback_error = exc
+ self.state = RESPONSES_STATE_OFF
+ if self.cache_key:
+ RESPONSES_UNSUPPORTED_CACHE.add(self.cache_key)
+ return TransportRecovery.FALLBACK_TO_CHAT
+ return TransportRecovery.RAISE
+
+
+@dataclass
+class LiteLLMTransport:
+ model: str
+ messages: list[dict[str, Any]]
+ kwargs: dict[str, Any]
+ stop: Optional[list[str]] = None
+ policy: TransportPolicy = field(init=False)
+ last_result: LLMResult | None = field(init=False, default=None)
+ last_request_state: str = field(init=False, default=RESPONSES_STATE_PROVIDER)
+ explicit_prompt_caching: bool = field(init=False, default=False)
+
+ def __post_init__(self) -> None:
+ self.kwargs = _without_stream_kwarg(dict(self.kwargs))
+ self.explicit_prompt_caching = _coerce_bool(
+ self.kwargs.pop("a0_explicit_prompt_caching", False), default=False
+ )
+ if self.explicit_prompt_caching:
+ self.messages = apply_chat_prompt_cache_markers(
+ self.messages,
+ model=self.model,
+ kwargs=self.kwargs,
+ )
+ self.policy = TransportPolicy.from_request(
+ self.model,
+ self.kwargs,
+ messages=self.messages,
+ )
+
+ def complete(self) -> ChatChunk:
+ while True:
+ try:
+ if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
+ raw_response = completion(**self._chat_request(stream=False))
+ parsed = ChatCompletionsTransport.parse(raw_response)
+ self.last_result = self._llm_result_from_chat(
+ parsed, raw_response
+ )
+ return parsed
+ request = self._responses_request(stream=False)
+ raw_response = responses(**request)
+ parsed = ResponsesTransport.parse_response(raw_response)
+ self.last_result = self._llm_result_from_response(
+ raw_response, request
+ )
+ return parsed
+ except Exception as exc:
+ if self._recover(exc, got_any_chunk=False):
+ continue
+ raise
+
+ async def acomplete(self) -> ChatChunk:
+ while True:
+ try:
+ if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
+ raw_response = await acompletion(
+ **self._chat_request(stream=False)
+ )
+ parsed = ChatCompletionsTransport.parse(raw_response)
+ self.last_result = self._llm_result_from_chat(
+ parsed, raw_response
+ )
+ return parsed
+ request = self._responses_request(stream=False)
+ raw_response = await aresponses(**request)
+ parsed = ResponsesTransport.parse_response(raw_response)
+ self.last_result = self._llm_result_from_response(
+ raw_response, request
+ )
+ return parsed
+ except Exception as exc:
+ if self._recover(exc, got_any_chunk=False):
+ continue
+ raise
+
+ def stream(self) -> Iterator[ChatChunk]:
+ while True:
+ iterator = None
+ exhausted = False
+ got_any_chunk = False
+ try:
+ if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
+ iterator = completion(**self._chat_request(stream=True))
+ parser = ChatCompletionsStreamParser()
+ for chunk in iterator:
+ parsed = parser.parse(chunk)
+ if _has_chunk_delta(parsed):
+ got_any_chunk = True
+ yield parsed
+ parsed = parser.flush()
+ if _has_chunk_delta(parsed):
+ got_any_chunk = True
+ yield parsed
+ self.last_result = self._stream_result_from_chat_parser(parser)
+ else:
+ request = self._responses_request(stream=True)
+ iterator = responses(**request)
+ parser = ResponsesEventParser()
+ for event in iterator:
+ parsed = parser.parse(event)
+ if _has_chunk_delta(parsed):
+ got_any_chunk = True
+ yield parsed
+ self.last_result = self._stream_result_from_parser(
+ parser, request
+ )
+ exhausted = True
+ return
+ except Exception as exc:
+ if self._recover(exc, got_any_chunk=got_any_chunk):
+ continue
+ raise
+ finally:
+ if iterator is not None and not exhausted:
+ _close_sync_stream(iterator)
+
+ async def astream(self) -> AsyncIterator[ChatChunk]:
+ while True:
+ iterator = None
+ exhausted = False
+ got_any_chunk = False
+ try:
+ if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
+ iterator = await acompletion(**self._chat_request(stream=True))
+ parser = ChatCompletionsStreamParser()
+ async for chunk in iterator: # type: ignore[union-attr]
+ parsed = parser.parse(chunk)
+ if _has_chunk_delta(parsed):
+ got_any_chunk = True
+ yield parsed
+ parsed = parser.flush()
+ if _has_chunk_delta(parsed):
+ got_any_chunk = True
+ yield parsed
+ self.last_result = self._stream_result_from_chat_parser(parser)
+ else:
+ request = self._responses_request(stream=True)
+ iterator = await aresponses(**request)
+ parser = ResponsesEventParser()
+ async for event in iterator: # type: ignore[union-attr]
+ parsed = parser.parse(event)
+ if _has_chunk_delta(parsed):
+ got_any_chunk = True
+ yield parsed
+ self.last_result = self._stream_result_from_parser(
+ parser, request
+ )
+ exhausted = True
+ return
+ except Exception as exc:
+ if self._recover(exc, got_any_chunk=got_any_chunk):
+ continue
+ raise
+ finally:
+ if iterator is not None and not exhausted:
+ await _close_async_stream(iterator)
+
+ def _recover(self, exc: Exception, *, got_any_chunk: bool) -> bool:
+ if (
+ self.policy.using_responses
+ and not got_any_chunk
+ and self.kwargs.get("responses_builtin_tools")
+ and _is_responses_builtin_tool_error(exc)
+ ):
+ downgraded = _builtin_tool_types(self.kwargs.get("responses_builtin_tools"))
+ if downgraded:
+ RESPONSES_BUILTIN_UNSUPPORTED_CACHE.setdefault(
+ self.policy.cache_key, set()
+ ).update(downgraded)
+ self.kwargs["_a0_responses_builtin_downgrades"] = sorted(downgraded)
+ self.kwargs["responses_builtin_tools"] = []
+ return True
+
+ recovery = self.policy.recover(exc, got_any_chunk=got_any_chunk)
+ if recovery is TransportRecovery.RETRY_RESPONSES:
+ self.kwargs["reasoning"] = {
+ "effort": RESPONSES_REASONING_FALLBACK_EFFORT
+ }
+ return True
+ if recovery is TransportRecovery.RETRY_LOCAL_RESPONSES:
+ self.kwargs["responses_state"] = RESPONSES_STATE_LOCAL
+ self.kwargs.pop("previous_response_id", None)
+ return True
+ return recovery is TransportRecovery.FALLBACK_TO_CHAT
+
+ def _chat_request(self, *, stream: bool) -> dict[str, Any]:
+ chat_kwargs = ChatCompletionsTransport.prepare_kwargs(
+ self.kwargs,
+ fallback_error=self.policy.fallback_error,
+ model=self.model,
+ messages=self.messages,
+ explicit_prompt_caching=self.explicit_prompt_caching,
+ )
+ request = {
+ "model": self.model,
+ "messages": ChatCompletionsTransport.prepare_messages(
+ self.messages,
+ model=self.model,
+ kwargs=chat_kwargs,
+ ),
+ "stream": stream,
+ **chat_kwargs,
+ }
+ if self.stop is not None:
+ request["stop"] = self.stop
+ return request
+
+ def _responses_request(self, *, stream: bool) -> dict[str, Any]:
+ response_kwargs = ResponsesTransport.from_chat(
+ self.messages,
+ self.kwargs,
+ stop=self.stop,
+ model=self.model,
+ )
+ self.last_request_state = _normalize_responses_state(
+ self.kwargs.get("responses_state")
+ )
+ return {
+ "model": self.model,
+ "stream": stream,
+ **response_kwargs,
+ }
+
+ def _llm_result_from_chat(
+ self, parsed: ChatChunk, response: Any = None
+ ) -> LLMResult:
+ return LLMResult.from_chat(
+ response=parsed["response_delta"],
+ reasoning=parsed["reasoning_delta"],
+ usage=_reported_usage(response),
+ input_items=ResponsesTransport.input_from_messages(self.messages),
+ output_items=parsed.get("_output_items"),
+ provider_model_key=self.model,
+ capability=self._capability_metadata(),
+ )
+
+ def _llm_result_from_response(
+ self, response: Any, request: dict[str, Any]
+ ) -> LLMResult:
+ result = LLMResult.from_response(
+ response,
+ input_items=_as_list(request.get("input")),
+ previous_response_id=str(request.get("previous_response_id") or ""),
+ provider_model_key=self.model,
+ mode=TransportMode.RESPONSES.value,
+ state=self.last_request_state,
+ capability=self._capability_metadata(),
+ )
+ result.usage = _reported_usage(response)
+ return result
+
+ def _stream_result_from_parser(
+ self, parser: "ResponsesEventParser", request: dict[str, Any]
+ ) -> LLMResult | None:
+ if parser.completed_response is None:
+ return None
+ response = _object_to_dict(parser.completed_response)
+ output = _as_list(response.get("output"))
+ if parser.function_calls and not any(
+ _get_value(item, "type") == "function_call" for item in output
+ ):
+ response["output"] = [*output, *parser.function_calls.values()]
+ return self._llm_result_from_response(response, request)
+
+ def _stream_result_from_chat_parser(
+ self, parser: "ChatCompletionsStreamParser"
+ ) -> LLMResult | None:
+ output_items = parser.output_items()
+ if not output_items and not parser.usage:
+ return None
+ return LLMResult.from_chat(
+ response=parser.function_calls_text(),
+ usage=parser.usage,
+ input_items=ResponsesTransport.input_from_messages(self.messages),
+ output_items=output_items,
+ provider_model_key=self.model,
+ capability=self._capability_metadata(),
+ )
+
+ def _capability_metadata(self) -> dict[str, Any]:
+ return {
+ "mode": self.policy.mode.value,
+ "state": self.policy.state,
+ "cache_key": self.policy.cache_key,
+ "fallback_error": _exception_text(self.policy.fallback_error)
+ if self.policy.fallback_error
+ else "",
+ "state_fallback_error": _exception_text(self.policy.state_fallback_error)
+ if self.policy.state_fallback_error
+ else "",
+ "builtin_tool_downgrades": list(
+ self.kwargs.get("_a0_responses_builtin_downgrades") or []
+ ),
+ }
+
+
+class ChatCompletionsTransport:
+ @staticmethod
+ def prepare_messages(
+ messages: list[dict[str, Any]],
+ *,
+ model: str = "",
+ kwargs: dict[str, Any] | None = None,
+ ) -> list[dict[str, Any]]:
+ if _is_openai_prompt_cache_provider(model, kwargs or {}):
+ stripped = _without_cache_control(messages)
+ return stripped if isinstance(stripped, list) else messages
+ return messages
+
+ @staticmethod
+ def prepare_kwargs(
+ kwargs: dict[str, Any],
+ fallback_error: Exception | None = None,
+ *,
+ model: str = "",
+ messages: list[dict[str, Any]] | None = None,
+ explicit_prompt_caching: bool = False,
+ ) -> dict[str, Any]:
+ chat_kwargs = dict(kwargs)
+ _drop_internal_transport_kwargs(chat_kwargs)
+ if not _has_tools(chat_kwargs.get("tools")):
+ chat_kwargs.pop("tools", None)
+ chat_kwargs.pop("tool_choice", None)
+ chat_kwargs.pop("parallel_tool_calls", None)
+ if _is_openai_prompt_cache_provider(model, chat_kwargs):
+ _prepare_openai_prompt_cache_params(
+ chat_kwargs,
+ messages or [],
+ model=model,
+ )
+ elif _supports_cache_control_markers(model, chat_kwargs) and (
+ explicit_prompt_caching or _has_cache_control(messages or [])
+ ):
+ _apply_tool_cache_control(chat_kwargs)
+ if fallback_error is not None:
+ chat_kwargs.setdefault("drop_params", True)
+ return {key: value for key, value in chat_kwargs.items() if value is not None}
+
+ @staticmethod
+ def parse(chunk: Any) -> ChatChunk:
+ choice = _first_choice(chunk)
+ delta = _get_value(choice, "delta") or {}
+ message = _get_value(choice, "message") or _get_value(
+ _get_value(choice, "model_extra") or {}, "message"
+ ) or {}
+ response_delta = _get_value(delta, "content") or _get_value(
+ message, "content"
+ ) or ""
+ reasoning_delta = _get_value(delta, "reasoning_content") or _get_value(
+ message, "reasoning_content"
+ ) or ""
+ parsed = {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
+ if not response_delta:
+ tool_calls = _as_list(_get_value(message, "tool_calls"))
+ response_delta = ChatCompletionsTransport.tool_calls_text(tool_calls)
+ if response_delta:
+ parsed["response_delta"] = response_delta
+ parsed["_output_items"] = ChatCompletionsTransport.output_items(
+ tool_calls
+ )
+ return parsed
+
+ @classmethod
+ def tool_calls_text(cls, tool_calls: Any) -> str:
+ calls = [cls.tool_call_object(call) for call in _as_list(tool_calls)]
+ calls = [call for call in calls if call]
+ if not calls:
+ return ""
+ if len(calls) == 1:
+ return json.dumps(calls[0], ensure_ascii=False)
+ return json.dumps(
+ {"tool_name": "parallel_tool_calls", "tool_args": {"calls": calls}},
+ ensure_ascii=False,
+ )
+
+ @classmethod
+ def output_items(cls, tool_calls: Any) -> list[dict[str, Any]]:
+ items = []
+ for index, tool_call in enumerate(_as_list(tool_calls)):
+ item = cls.function_call_item(tool_call, fallback_index=index)
+ if item:
+ items.append(item)
+ return items
+
+ @classmethod
+ def function_call_item(
+ cls, tool_call: Any, *, fallback_index: int = 0
+ ) -> dict[str, Any]:
+ function = _get_value(tool_call, "function") or {}
+ name = _get_value(function, "name") or _get_value(tool_call, "name")
+ if not name:
+ return {}
+ raw_arguments = _get_value(function, "arguments")
+ if raw_arguments is None:
+ raw_arguments = _get_value(tool_call, "arguments") or "{}"
+ call_id = str(_get_value(tool_call, "id") or f"call_{fallback_index}")
+ return {
+ "type": "function_call",
+ "id": call_id,
+ "call_id": call_id,
+ "name": str(name),
+ "arguments": raw_arguments
+ if isinstance(raw_arguments, str)
+ else json.dumps(raw_arguments),
+ }
+
+ @classmethod
+ def tool_call_object(cls, tool_call: Any) -> dict[str, Any]:
+ item = cls.function_call_item(tool_call)
+ if not item:
+ return {}
+ return ResponsesTransport.function_call_object(item)
+
+
+class ChatCompletionsStreamParser:
+ def __init__(self) -> None:
+ self.tool_calls: dict[str, dict[str, Any]] = {}
+ self.order: list[str] = []
+ self.emitted = False
+ self.usage: dict[str, Any] = {}
+
+ def parse(self, chunk: Any) -> ChatChunk:
+ if usage := _reported_usage(chunk):
+ self.usage.update(usage)
+ parsed = ChatCompletionsTransport.parse(chunk)
+ choice = _first_choice(chunk)
+ delta = _get_value(choice, "delta") or {}
+ self._append_tool_calls(_get_value(delta, "tool_calls"))
+ self._append_legacy_function_call(_get_value(delta, "function_call"))
+
+ if _get_value(choice, "finish_reason") in {"tool_calls", "function_call"}:
+ text = self._emit()
+ if text and not parsed["response_delta"]:
+ parsed["response_delta"] = text
+ return parsed
+
+ def flush(self) -> ChatChunk:
+ return {"reasoning_delta": "", "response_delta": self._emit()}
+
+ def function_calls_text(self) -> str:
+ return ChatCompletionsTransport.tool_calls_text(self._ordered_tool_calls())
+
+ def output_items(self) -> list[dict[str, Any]]:
+ return ChatCompletionsTransport.output_items(self._ordered_tool_calls())
+
+ def _append_tool_calls(self, tool_calls: Any) -> None:
+ for fallback_index, tool_call in enumerate(_as_list(tool_calls)):
+ key = self._tool_call_key(tool_call, fallback_index)
+ current = self._current_tool_call(key)
+ if _get_value(tool_call, "id"):
+ current["id"] = _get_value(tool_call, "id")
+ if _get_value(tool_call, "type"):
+ current["type"] = _get_value(tool_call, "type")
+ self._append_function_delta(current, _get_value(tool_call, "function"))
+
+ def _append_legacy_function_call(self, function_call: Any) -> None:
+ if not function_call:
+ return
+ current = self._current_tool_call("0")
+ current["type"] = "function"
+ self._append_function_delta(current, function_call)
+
+ def _append_function_delta(self, tool_call: dict[str, Any], delta: Any) -> None:
+ if not delta:
+ return
+ function = tool_call.setdefault("function", {})
+ if _get_value(delta, "name"):
+ function["name"] = _get_value(delta, "name")
+ if _get_value(delta, "arguments") is not None:
+ function["arguments"] = str(function.get("arguments") or "") + str(
+ _get_value(delta, "arguments") or ""
+ )
+
+ def _current_tool_call(self, key: str) -> dict[str, Any]:
+ if key not in self.tool_calls:
+ self.tool_calls[key] = {"type": "function", "function": {}}
+ self.order.append(key)
+ return self.tool_calls[key]
+
+ def _ordered_tool_calls(self) -> list[dict[str, Any]]:
+ return [self.tool_calls[key] for key in self.order]
+
+ def _emit(self) -> str:
+ if self.emitted:
+ return ""
+ text = self.function_calls_text()
+ if text:
+ self.emitted = True
+ return text
+
+ @staticmethod
+ def _tool_call_key(tool_call: Any, fallback_index: int) -> str:
+ index = _get_value(tool_call, "index")
+ if index is not None:
+ return str(index)
+ if _get_value(tool_call, "id"):
+ return str(_get_value(tool_call, "id"))
+ return str(fallback_index)
+
+
+class ResponsesTransport:
+ @classmethod
+ def from_chat(
+ cls,
+ messages: list[dict[str, Any]],
+ kwargs: dict[str, Any],
+ stop: Optional[list[str]] = None,
+ model: str = "",
+ ) -> dict[str, Any]:
+ request = cls.prepare_kwargs(kwargs, stop=stop, model=model, messages=messages)
+ state = _normalize_responses_state(kwargs.get("responses_state"))
+ input_items = cls._select_input_items(kwargs, messages, state)
+ request["input"] = input_items or ""
+ cls.apply_state(request, kwargs, state=state)
+ return request
+
+ @classmethod
+ def from_input(
+ cls,
+ input_items: list[dict[str, Any]],
+ kwargs: dict[str, Any],
+ stop: Optional[list[str]] = None,
+ model: str = "",
+ messages: list[dict[str, Any]] | None = None,
+ ) -> dict[str, Any]:
+ request = cls.prepare_kwargs(kwargs, stop=stop, model=model, messages=messages)
+ state = _normalize_responses_state(kwargs.get("responses_state"))
+ request["input"] = list(input_items or []) or ""
+ cls.apply_state(request, kwargs, state=state)
+ return request
+
+ @classmethod
+ def prepare_kwargs(
+ cls,
+ kwargs: dict[str, Any],
+ stop: Optional[list[str]] = None,
+ model: str = "",
+ messages: list[dict[str, Any]] | None = None,
+ ) -> dict[str, Any]:
+ request = dict(kwargs)
+ response_function_tools = request.pop("a0_responses_function_tools", None)
+ response_builtin_tools = request.pop("responses_builtin_tools", None)
+ _drop_responses_only_kwargs(request)
+ _drop_legacy_transport_kwargs(request)
+ request.pop("stop", None)
+
+ max_completion_tokens = request.pop("max_completion_tokens", None)
+ max_tokens = request.pop("max_tokens", None)
+ if "max_output_tokens" not in request:
+ request["max_output_tokens"] = max_completion_tokens or max_tokens
+
+ reasoning_effort = request.pop("reasoning_effort", None)
+ if "reasoning" in request:
+ request["reasoning"] = cls.normalize_reasoning(request["reasoning"])
+ elif reasoning_effort is not None:
+ request["reasoning"] = cls.normalize_reasoning(reasoning_effort)
+
+ response_format = request.pop("response_format", None)
+ if response_format is not None:
+ text_param, text_format = cls.text_from_response_format(response_format)
+ if text_param is not None and "text" not in request:
+ request["text"] = text_param
+ if text_format is not None and "text_format" not in request:
+ request["text_format"] = text_format
+
+ functions = request.pop("functions", None)
+ if functions and "tools" not in request:
+ request["tools"] = [
+ {"type": "function", **function}
+ for function in functions
+ if isinstance(function, dict)
+ ]
+
+ tools = cls.tools_from_chat(request.pop("tools", None))
+ tools = cls.merge_response_tools(
+ tools,
+ response_function_tools=response_function_tools,
+ response_builtin_tools=response_builtin_tools,
+ )
+ if _has_tools(tools):
+ request["tools"] = tools
+ else:
+ request.pop("tools", None)
+
+ function_call = request.pop("function_call", None)
+ if function_call is not None and "tool_choice" not in request:
+ request["tool_choice"] = cls.tool_choice_from_function_call(function_call)
+ elif "tool_choice" in request:
+ request["tool_choice"] = cls.tool_choice_from_chat(request["tool_choice"])
+
+ if _has_tools(response_function_tools):
+ request.setdefault("tool_choice", "required")
+ request.setdefault("parallel_tool_calls", False)
+
+ if not _has_tools(request.get("tools")):
+ request.pop("tool_choice", None)
+ request.pop("parallel_tool_calls", None)
+
+ cls.prepare_prompt_caching(request, messages or [], model=model)
+
+ _ = stop
+ return {key: value for key, value in request.items() if value is not None}
+
+ @classmethod
+ def _select_input_items(
+ cls,
+ kwargs: dict[str, Any],
+ messages: list[dict[str, Any]],
+ state: str,
+ ) -> list[dict[str, Any]]:
+ provider_items = kwargs.get("responses_input_items")
+ local_items = kwargs.get("responses_local_input_items")
+ previous_response_id = kwargs.get("previous_response_id")
+
+ if (
+ state == RESPONSES_STATE_PROVIDER
+ and previous_response_id
+ and isinstance(provider_items, list)
+ ):
+ return [dict(item) for item in provider_items if isinstance(item, dict)]
+
+ if state == RESPONSES_STATE_LOCAL and isinstance(local_items, list):
+ return [dict(item) for item in local_items if isinstance(item, dict)]
+
+ return cls.input_from_messages(messages)
+
+ @staticmethod
+ def apply_state(
+ request: dict[str, Any],
+ kwargs: dict[str, Any],
+ *,
+ state: str,
+ ) -> None:
+ if state == RESPONSES_STATE_PROVIDER:
+ request.setdefault("store", True)
+ previous_response_id = str(kwargs.get("previous_response_id") or "")
+ if previous_response_id:
+ request["previous_response_id"] = previous_response_id
+ elif state == RESPONSES_STATE_LOCAL:
+ request.setdefault("store", False)
+ else:
+ request.setdefault("store", False)
+
+ @classmethod
+ def merge_response_tools(
+ cls,
+ tools: Any,
+ *,
+ response_function_tools: Any = None,
+ response_builtin_tools: Any = None,
+ ) -> list[Any]:
+ merged: list[Any] = []
+ for source in (tools, response_function_tools, response_builtin_tools):
+ source_tools = (
+ source if isinstance(source, list) else [source] if source else []
+ )
+ for tool in source_tools:
+ normalized = cls.normalize_response_tool(tool)
+ if normalized:
+ merged.append(normalized)
+ return merged
+
+ @staticmethod
+ def normalize_response_tool(tool: Any) -> dict[str, Any] | None:
+ if isinstance(tool, str):
+ tool = {"type": tool}
+ if not isinstance(tool, dict):
+ return None
+ normalized = dict(tool)
+ if normalized.get("type") == "function":
+ normalized["parameters"] = _normalize_function_parameters(
+ normalized.get("parameters")
+ )
+ return normalized
+
+ @staticmethod
+ def prepare_prompt_caching(
+ request: dict[str, Any],
+ messages: list[dict[str, Any]],
+ model: str = "",
+ ) -> None:
+ if not _is_openai_prompt_cache_provider(model, request):
+ return
+
+ _prepare_openai_prompt_cache_params(request, messages, model=model)
+
+ for key in OPENAI_RESPONSES_EXTRA_BODY_PARAMS:
+ if key not in request:
+ continue
+ extra_body = request.get("extra_body")
+ if not isinstance(extra_body, dict):
+ extra_body = {}
+ extra_body.setdefault(key, request.pop(key))
+ request["extra_body"] = extra_body
+
+ @classmethod
+ def input_from_messages(
+ cls, messages: list[dict[str, Any]]
+ ) -> list[dict[str, Any]]:
+ response_input: list[dict[str, Any]] = []
+
+ for message in messages:
+ role = str(message.get("role") or "user")
+ content = message.get("content", "")
+
+ if role == "tool":
+ response_input.append(
+ {
+ "type": "function_call_output",
+ "call_id": str(message.get("tool_call_id") or ""),
+ "output": _content_to_text(content),
+ }
+ )
+ continue
+
+ tool_calls = message.get("tool_calls")
+ if role == "assistant" and isinstance(tool_calls, list) and tool_calls:
+ if _has_real_content(content):
+ response_input.append(
+ {
+ "role": "assistant",
+ "content": cls.content_from_chat(content, role=role),
+ }
+ )
+ response_input.extend(cls.tool_calls_from_chat(tool_calls))
+ continue
+
+ response_input.append(
+ {
+ "role": role
+ if role in {"user", "assistant", "system", "developer"}
+ else "user",
+ "content": cls.content_from_chat(content, role=role),
+ }
+ )
+
+ return response_input
+
+ @classmethod
+ def content_from_chat(cls, content: Any, role: str = "user") -> Any:
+ content = images.prepare_content(content)
+ if not isinstance(content, list):
+ return content
+ return [
+ converted
+ for item in content
+ if (converted := cls.content_part_from_chat(item, role=role)) is not None
+ ]
+
+ @staticmethod
+ def content_part_from_chat(item: Any, role: str = "user") -> Any:
+ if not isinstance(item, dict):
+ return item
+
+ item_type = item.get("type")
+ if item_type in {"input_text", "output_text", "input_image", "input_file"}:
+ return dict(item)
+ if item_type == "text":
+ return {
+ "type": "output_text" if role == "assistant" else "input_text",
+ "text": item.get("text", ""),
+ }
+ if item_type == "image_url":
+ image_url = item.get("image_url")
+ if isinstance(image_url, dict):
+ url = image_url.get("url", "")
+ detail = image_url.get("detail")
+ else:
+ url = image_url or ""
+ detail = item.get("detail")
+ result = {"type": "input_image", "image_url": url}
+ if detail:
+ result["detail"] = detail
+ return result
+
+ return dict(item)
+
+ @staticmethod
+ def tool_calls_from_chat(tool_calls: list[Any]) -> list[dict[str, Any]]:
+ response_input: list[dict[str, Any]] = []
+ for tool_call in tool_calls:
+ if not isinstance(tool_call, dict):
+ continue
+ function = tool_call.get("function") or {}
+ if not isinstance(function, dict):
+ function = {}
+ response_input.append(
+ {
+ "type": "function_call",
+ "call_id": str(tool_call.get("id") or ""),
+ "id": str(tool_call.get("id") or ""),
+ "name": str(function.get("name") or tool_call.get("name") or ""),
+ "arguments": str(function.get("arguments") or ""),
+ "status": "completed",
+ }
+ )
+ return response_input
+
+ @staticmethod
+ def tools_from_chat(tools: Any) -> Any:
+ if not isinstance(tools, list):
+ return tools
+ response_tools: list[Any] = []
+ for tool in tools:
+ if not isinstance(tool, dict):
+ response_tools.append(tool)
+ continue
+ if tool.get("type") == "function" and isinstance(
+ tool.get("function"), dict
+ ):
+ function = tool["function"]
+ response_tool = {
+ "type": "function",
+ "name": function.get("name", ""),
+ "description": function.get("description", ""),
+ "parameters": _normalize_function_parameters(
+ function.get("parameters")
+ ),
+ }
+ if "strict" in function:
+ response_tool["strict"] = function["strict"]
+ response_tools.append(response_tool)
+ else:
+ response_tools.append(dict(tool))
+ return response_tools
+
+ @staticmethod
+ def tool_choice_from_function_call(function_call: Any) -> Any:
+ if isinstance(function_call, str):
+ return function_call
+ if isinstance(function_call, dict) and function_call.get("name"):
+ return {"type": "function", "name": function_call["name"]}
+ return function_call
+
+ @staticmethod
+ def tool_choice_from_chat(tool_choice: Any) -> Any:
+ if (
+ isinstance(tool_choice, dict)
+ and tool_choice.get("type") == "function"
+ and isinstance(tool_choice.get("function"), dict)
+ ):
+ return {"type": "function", "name": tool_choice["function"].get("name", "")}
+ return tool_choice
+
+ @staticmethod
+ def text_from_response_format(response_format: Any) -> tuple[Any, Any]:
+ if isinstance(response_format, type):
+ return None, response_format
+ if not isinstance(response_format, dict):
+ return response_format, None
+
+ format_type = response_format.get("type")
+ if format_type == "json_schema":
+ schema = response_format.get("json_schema") or {}
+ return (
+ {
+ "format": {
+ "type": "json_schema",
+ "name": schema.get("name", "response_schema"),
+ "schema": schema.get("schema", {}),
+ "strict": schema.get("strict", False),
+ }
+ },
+ None,
+ )
+ if format_type:
+ return {"format": {"type": format_type}}, None
+ return response_format, None
+
+ @staticmethod
+ def normalize_reasoning(reasoning: Any) -> Any:
+ if isinstance(reasoning, dict):
+ normalized = dict(reasoning)
+ if "effort" in normalized:
+ effort = _normalize_reasoning_effort(normalized.get("effort"))
+ if effort is None:
+ normalized.pop("effort", None)
+ else:
+ normalized["effort"] = effort
+ return normalized or None
+ if reasoning is None:
+ return None
+ effort = _normalize_reasoning_effort(reasoning)
+ return {"effort": effort} if effort is not None else None
+
+ @classmethod
+ def parse_response(cls, response: Any) -> ChatChunk:
+ response_delta = cls.output_text(response)
+ reasoning_delta = cls.reasoning_text(response)
+ if not response_delta:
+ response_delta = cls.function_calls_text(response)
+ return {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
+
+ @classmethod
+ def parse_event(cls, event: Any) -> ChatChunk:
+ return ResponsesEventParser().parse(event)
+
+ @classmethod
+ def output_text(cls, response: Any) -> str:
+ output_text = _get_value(response, "output_text")
+ if isinstance(output_text, str):
+ return output_text
+
+ pieces: list[str] = []
+ for item in _as_list(_get_value(response, "output")):
+ if _get_value(item, "type") != "message":
+ continue
+ for block in _as_list(_get_value(item, "content")):
+ block_type = _get_value(block, "type")
+ if block_type in {"output_text", "text"}:
+ text = _get_value(block, "text")
+ if isinstance(text, str):
+ pieces.append(text)
+ elif block_type == "refusal":
+ refusal = _get_value(block, "refusal")
+ if isinstance(refusal, str):
+ pieces.append(refusal)
+ return "".join(pieces)
+
+ @staticmethod
+ def reasoning_text(response: Any) -> str:
+ pieces: list[str] = []
+ for item in _as_list(_get_value(response, "output")):
+ if _get_value(item, "type") != "reasoning":
+ continue
+ for block in _as_list(_get_value(item, "summary")):
+ text = _get_value(block, "text") or _get_value(block, "reasoning")
+ if isinstance(text, str):
+ pieces.append(text)
+ return "".join(pieces)
+
+ @classmethod
+ def function_calls_text(cls, response: Any) -> str:
+ calls = [
+ cls.function_call_object(item)
+ for item in _as_list(_get_value(response, "output"))
+ ]
+ calls = [call for call in calls if call]
+ if not calls:
+ return ""
+ if len(calls) == 1:
+ return json.dumps(calls[0])
+ return json.dumps(
+ {"tool_name": "parallel_tool_calls", "tool_args": {"calls": calls}}
+ )
+
+ @classmethod
+ def function_call_text(cls, item: Any) -> str:
+ call = cls.function_call_object(item)
+ if not call:
+ return ""
+ return json.dumps(call, ensure_ascii=False)
+
+ @staticmethod
+ def function_call_object(item: Any) -> dict[str, Any]:
+ if _get_value(item, "type") != "function_call":
+ return {}
+ name = _get_value(item, "name")
+ if not name:
+ return {}
+ raw_arguments = _get_value(item, "arguments") or "{}"
+ if isinstance(raw_arguments, str):
+ try:
+ args = json.loads(raw_arguments or "{}")
+ except Exception:
+ args = {"arguments": raw_arguments}
+ elif isinstance(raw_arguments, dict):
+ args = raw_arguments
+ else:
+ args = {"arguments": raw_arguments}
+ if not isinstance(args, dict):
+ args = {"arguments": args}
+ return {
+ "tool_name": str(name),
+ "tool_args": args,
+ }
+
+
+class ResponsesEventParser:
+ """Stateful parser for Responses streaming events."""
+
+ def __init__(self) -> None:
+ self.function_calls: dict[str, dict[str, Any]] = {}
+ self.output_index_keys: dict[str, str] = {}
+ self.emitted_function_calls: set[str] = set()
+ self.streamed_response_calls: dict[str, str] = {}
+ self.seen_response_delta = False
+ self.seen_reasoning_delta = False
+ self.completed_response: Any = None
+
+ def parse(self, event: Any) -> ChatChunk:
+ event_type = _get_value(event, "type") or ""
+ response_delta = ""
+ reasoning_delta = ""
+
+ if event_type in {
+ "response.output_text.delta",
+ "response.refusal.delta",
+ "response.text.delta",
+ }:
+ response_delta = str(_get_value(event, "delta") or "")
+ elif event_type in {
+ "response.reasoning_summary_text.delta",
+ "response.reasoning_text.delta",
+ }:
+ reasoning_delta = str(_get_value(event, "delta") or "")
+ elif event_type == "response.output_item.added":
+ self._remember_function_call(_get_value(event, "item"), event)
+ elif event_type == "response.function_call_arguments.delta":
+ response_delta = self._append_function_call_arguments(event)
+ elif event_type == "response.function_call_arguments.done":
+ response_delta = self._complete_function_call(event)
+ elif event_type == "response.output_item.done":
+ response_delta = self._complete_output_item(_get_value(event, "item"), event)
+ elif event_type == "response.completed":
+ response_delta, reasoning_delta = self._complete_response(event)
+ elif event_type == "response.failed":
+ raise RuntimeError(self._response_error_message(event))
+ elif event_type == "error":
+ error = _get_value(event, "error")
+ message = _get_value(error, "message") or error
+ raise RuntimeError(str(message))
+
+ if response_delta:
+ self.seen_response_delta = True
+ if reasoning_delta:
+ self.seen_reasoning_delta = True
+
+ return {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
+
+ def _remember_function_call(self, item: Any, event: Any) -> str:
+ if _get_value(item, "type") != "function_call":
+ return ""
+ key = self._event_key(event, item)
+ if not key:
+ return ""
+ current = self.function_calls.get(key, {})
+ merged = {**current, **_object_to_dict(item)}
+ self.function_calls[key] = merged
+ output_index = _get_value(event, "output_index")
+ if output_index is not None:
+ self.output_index_keys[str(output_index)] = key
+ return key
+
+ def _append_function_call_arguments(self, event: Any) -> str:
+ key = self._event_key(event)
+ if not key:
+ return ""
+ current = self.function_calls.setdefault(key, {"type": "function_call"})
+ delta = str(_get_value(event, "delta") or "")
+ current["arguments"] = str(current.get("arguments") or "") + delta
+ if current.get("name") != "response":
+ return ""
+ if key not in self.streamed_response_calls:
+ self.streamed_response_calls[key] = str(current["arguments"])
+ return '{"tool_name":"response","tool_args":' + str(
+ current["arguments"]
+ )
+ self.streamed_response_calls[key] += delta
+ return delta
+
+ def _complete_function_call(self, event: Any) -> str:
+ key = self._event_key(event)
+ if not key:
+ return ""
+ current = self.function_calls.setdefault(key, {"type": "function_call"})
+ if _get_value(event, "arguments") is not None:
+ current["arguments"] = _get_value(event, "arguments")
+ if _get_value(event, "name"):
+ current["name"] = _get_value(event, "name")
+ if key in self.streamed_response_calls:
+ return self._finish_response_call(key, current)
+ return self._emit_function_call(key, current)
+
+ def _complete_output_item(self, item: Any, event: Any) -> str:
+ key = self._remember_function_call(item, event)
+ if not key:
+ return ""
+ if key in self.streamed_response_calls:
+ return self._finish_response_call(key, self.function_calls[key])
+ return self._emit_function_call(key, self.function_calls[key])
+
+ def _finish_response_call(self, key: str, item: Any) -> str:
+ streamed = self.streamed_response_calls.pop(key)
+ arguments = str(_get_value(item, "arguments") or "")
+ self.emitted_function_calls.add(key)
+ tail = arguments[len(streamed) :] if arguments.startswith(streamed) else ""
+ return tail + "}"
+
+ def _complete_response(self, event: Any) -> tuple[str, str]:
+ self.completed_response = _get_value(event, "response")
+ if self.seen_response_delta or self.emitted_function_calls:
+ return "", ""
+ parsed = ResponsesTransport.parse_response(self.completed_response)
+ if self.seen_reasoning_delta:
+ parsed["reasoning_delta"] = ""
+ return parsed["response_delta"], parsed["reasoning_delta"]
+
+ def _emit_function_call(self, key: str, item: Any) -> str:
+ if key in self.emitted_function_calls:
+ return ""
+ text = ResponsesTransport.function_call_text(item)
+ if text:
+ self.emitted_function_calls.add(key)
+ return text
+
+ def _event_key(self, event: Any, item: Any = None) -> str:
+ key = _get_value(event, "item_id") or _get_value(item, "id")
+ if key:
+ return str(key)
+ output_index = _get_value(event, "output_index")
+ if output_index is not None:
+ output_key = self.output_index_keys.get(str(output_index))
+ if output_key:
+ return output_key
+ return f"output:{output_index}"
+ return ""
+
+ def _response_error_message(self, event: Any) -> str:
+ response = _get_value(event, "response") or {}
+ error = _get_value(response, "error") or _get_value(event, "error")
+ message = _get_value(error, "message") or error
+ return str(message or "Responses API request failed")
+
+
+def clear_transport_capability_cache() -> None:
+ RESPONSES_UNSUPPORTED_CACHE.clear()
+ RESPONSES_STATE_UNSUPPORTED_CACHE.clear()
+ RESPONSES_BUILTIN_UNSUPPORTED_CACHE.clear()
+
+
+def delete_stored_response_ids(
+ response_ids: list[str], **kwargs: Any
+) -> list[tuple[str, str]]:
+ errors: list[tuple[str, str]] = []
+ for response_id in response_ids:
+ try:
+ delete_responses(response_id=response_id, **kwargs)
+ except Exception as exc:
+ errors.append((response_id, _exception_text(exc)))
+ return errors
+
+
+async def adelete_stored_response_ids(
+ response_ids: list[str], **kwargs: Any
+) -> list[tuple[str, str]]:
+ errors: list[tuple[str, str]] = []
+ for response_id in response_ids:
+ try:
+ await adelete_responses(response_id=response_id, **kwargs)
+ except Exception as exc:
+ errors.append((response_id, _exception_text(exc)))
+ return errors
+
+
+def _coerce_bool(value: Any, default: bool = False) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"1", "true", "yes", "on"}:
+ return True
+ if normalized in {"0", "false", "no", "off", "none"}:
+ return False
+ return bool(value)
+
+
+def _responses_cache_key(model: str, kwargs: dict[str, Any]) -> str:
+ api_base = (
+ kwargs.get("api_base")
+ or kwargs.get("base_url")
+ or kwargs.get("api_base_url")
+ or ""
+ )
+ custom_provider = kwargs.get("custom_llm_provider") or ""
+ return "|".join(str(part) for part in (model, custom_provider, api_base))
+
+
+def _drop_legacy_transport_kwargs(kwargs: dict[str, Any]) -> None:
+ kwargs.pop("a0_api_mode", None)
+ kwargs.pop("a0_responses_fallback", None)
+
+
+def _drop_responses_only_kwargs(kwargs: dict[str, Any]) -> None:
+ kwargs.pop("responses_state", None)
+ kwargs.pop("responses_delete_on_chat_delete", None)
+ kwargs.pop("responses_input_items", None)
+ kwargs.pop("responses_local_input_items", None)
+ kwargs.pop("previous_response_id", None)
+ kwargs.pop("_a0_responses_builtin_downgrades", None)
+
+
+def _drop_internal_transport_kwargs(kwargs: dict[str, Any]) -> None:
+ _drop_legacy_transport_kwargs(kwargs)
+ kwargs.pop("a0_explicit_prompt_caching", None)
+ kwargs.pop("a0_responses_function_tools", None)
+ kwargs.pop("responses_builtin_tools", None)
+ _drop_responses_only_kwargs(kwargs)
+
+
+def _normalize_responses_state(value: Any) -> str:
+ normalized = str(value or RESPONSES_STATE_PROVIDER).strip().lower()
+ return normalized if normalized in RESPONSES_STATES else RESPONSES_STATE_PROVIDER
+
+
+def _filter_unsupported_builtin_tools(kwargs: dict[str, Any], cache_key: str) -> None:
+ unsupported = RESPONSES_BUILTIN_UNSUPPORTED_CACHE.get(cache_key)
+ if not unsupported:
+ return
+ tools = kwargs.get("responses_builtin_tools")
+ if not isinstance(tools, list):
+ return
+ filtered = []
+ downgraded = []
+ for tool in tools:
+ tool_type = _response_tool_type(tool)
+ if tool_type in unsupported:
+ downgraded.append(tool_type)
+ continue
+ filtered.append(tool)
+ if downgraded:
+ kwargs["responses_builtin_tools"] = filtered
+ kwargs["_a0_responses_builtin_downgrades"] = sorted(set(downgraded))
+
+
+def _builtin_tool_types(tools: Any) -> set[str]:
+ return {
+ tool_type
+ for tool in _as_list(tools)
+ if (tool_type := _response_tool_type(tool))
+ }
+
+
+def _response_tool_type(tool: Any) -> str:
+ if isinstance(tool, str):
+ return tool
+ if isinstance(tool, dict):
+ return str(tool.get("type") or "")
+ return ""
+
+
+def _normalize_function_parameters(parameters: Any) -> dict[str, Any]:
+ if not isinstance(parameters, dict):
+ return _permissive_function_parameters()
+
+ normalized = dict(parameters)
+ normalized.setdefault("type", "object")
+ if normalized.get("type") == "object" and not isinstance(
+ normalized.get("properties"), dict
+ ):
+ normalized["properties"] = {}
+ return normalized or _permissive_function_parameters()
+
+
+def _permissive_function_parameters() -> dict[str, Any]:
+ return {"type": "object", "properties": {}, "additionalProperties": True}
+
+
+def apply_chat_prompt_cache_markers(
+ messages: list[dict[str, Any]],
+ *,
+ model: str = "",
+ kwargs: dict[str, Any] | None = None,
+) -> list[dict[str, Any]]:
+ if not _supports_cache_control_markers(model, kwargs or {}):
+ return [dict(message) for message in messages]
+
+ prepared = [_strip_message_cache_control(message) for message in messages]
+ for index in _prompt_cache_message_indexes(prepared):
+ prepared[index] = _message_with_cache_control(prepared[index])
+ return prepared
+
+
+def _prompt_cache_message_indexes(messages: list[dict[str, Any]]) -> list[int]:
+ indexes: list[int] = []
+
+ leading_context: list[int] = []
+ for index, message in enumerate(messages):
+ role = str(message.get("role") or "")
+ if role in {"system", "developer"}:
+ leading_context.append(index)
+ continue
+ break
+ if leading_context:
+ indexes.append(leading_context[-1])
+
+ user_indexes = [
+ index
+ for index, message in enumerate(messages)
+ if str(message.get("role") or "") == "user"
+ ]
+ indexes.extend(user_indexes[-2:])
+
+ deduplicated: list[int] = []
+ for index in indexes:
+ if index not in deduplicated:
+ deduplicated.append(index)
+ return deduplicated[:3]
+
+
+def _strip_message_cache_control(message: dict[str, Any]) -> dict[str, Any]:
+ result = dict(message)
+ result.pop("cache_control", None)
+ return result
+
+
+def _message_with_cache_control(message: dict[str, Any]) -> dict[str, Any]:
+ result = dict(message)
+ result["content"] = _content_with_cache_control(result.get("content", ""))
+ return result
+
+
+def _content_with_cache_control(content: Any) -> Any:
+ marker = _cache_control_marker()
+ if isinstance(content, list):
+ blocks = [_copy_content_block(block) for block in content]
+ if not blocks:
+ return [{"type": "text", "text": "", "cache_control": marker}]
+ for index in range(len(blocks) - 1, -1, -1):
+ block = blocks[index]
+ if isinstance(block, dict):
+ block["cache_control"] = marker
+ return blocks
+ if isinstance(block, str):
+ blocks[index] = {
+ "type": "text",
+ "text": block,
+ "cache_control": marker,
+ }
+ return blocks
+ return blocks
+
+ if isinstance(content, dict):
+ block = dict(content)
+ block["cache_control"] = marker
+ return block
+
+ return [
+ {
+ "type": "text",
+ "text": _content_to_text(content),
+ "cache_control": marker,
+ }
+ ]
+
+
+def _copy_content_block(block: Any) -> Any:
+ if isinstance(block, dict):
+ return dict(block)
+ if isinstance(block, list):
+ return [_copy_content_block(item) for item in block]
+ return block
+
+
+def _apply_tool_cache_control(kwargs: dict[str, Any]) -> None:
+ tools = kwargs.get("tools")
+ if not isinstance(tools, list) or not tools or _has_cache_control(tools):
+ return
+
+ prepared = [dict(tool) if isinstance(tool, dict) else tool for tool in tools]
+ for index in range(len(prepared) - 1, -1, -1):
+ tool = prepared[index]
+ if not isinstance(tool, dict):
+ continue
+ if tool.get("type") == "function" and isinstance(tool.get("function"), dict):
+ function = dict(tool["function"])
+ function["cache_control"] = _cache_control_marker()
+ tool = dict(tool)
+ tool["function"] = function
+ prepared[index] = tool
+ else:
+ tool = dict(tool)
+ tool["cache_control"] = _cache_control_marker()
+ prepared[index] = tool
+ kwargs["tools"] = prepared
+ return
+
+
+def _cache_control_marker() -> dict[str, str]:
+ return {"type": "ephemeral"}
+
+
+def _should_preserve_cache_control_on_chat(
+ model: str,
+ kwargs: dict[str, Any],
+ messages: list[dict[str, Any]],
+) -> bool:
+ if not (_has_cache_control(messages) or _has_cache_control(kwargs.get("tools"))):
+ return False
+ return not _is_native_responses_provider(model, kwargs)
+
+
+def _is_native_responses_provider(model: str, kwargs: dict[str, Any]) -> bool:
+ api_base = _api_base(kwargs)
+ if "openrouter.ai" in api_base or "anthropic.com" in api_base:
+ return False
+
+ provider = _normalized_provider(model, kwargs)
+ return provider in {"openai", "azure", "azure_ai", "xai"}
+
+
+def _supports_cache_control_markers(
+ model: str,
+ kwargs: dict[str, Any],
+) -> bool:
+ api_base = _api_base(kwargs)
+ if "openrouter.ai" in api_base or "anthropic.com" in api_base:
+ return True
+ provider = _normalized_provider(model, kwargs)
+ return provider in CACHE_CONTROL_PROMPT_PROVIDERS
+
+
+def _is_openai_prompt_cache_provider(model: str, kwargs: dict[str, Any]) -> bool:
+ api_base = _api_base(kwargs)
+ provider = _normalized_provider(model, kwargs)
+
+ if api_base:
+ if "api.openai.com" in api_base:
+ return provider in {"", "openai"}
+ if "openai.azure.com" in api_base:
+ return provider in {"", "azure", "openai"}
+ return False
+
+ if not provider and str(model):
+ provider = "openai"
+ return provider in OPENAI_PROMPT_CACHE_PROVIDERS
+
+
+def _api_base(kwargs: dict[str, Any]) -> str:
+ return str(
+ kwargs.get("api_base")
+ or kwargs.get("base_url")
+ or kwargs.get("api_base_url")
+ or ""
+ ).lower()
+
+
+def _normalized_provider(model: str, kwargs: dict[str, Any]) -> str:
+ provider = str(kwargs.get("custom_llm_provider") or "").strip().lower()
+ if not provider and "/" in str(model):
+ provider = str(model).split("/", 1)[0].strip().lower()
+ return provider.replace("-", "_")
+
+
+def _prepare_openai_prompt_cache_params(
+ request: dict[str, Any],
+ messages: list[dict[str, Any]],
+ *,
+ model: str = "",
+) -> None:
+ if "prompt_cache_key" in request:
+ return
+
+ sanitized_messages = _without_cache_control(messages)
+ sanitized_request = _without_cache_control(request)
+ prompt_cache_key = _default_prompt_cache_key(
+ model,
+ sanitized_messages if isinstance(sanitized_messages, list) else messages,
+ sanitized_request if isinstance(sanitized_request, dict) else request,
+ )
+ if prompt_cache_key:
+ request["prompt_cache_key"] = prompt_cache_key
+
+
+def _default_prompt_cache_key(
+ model: str,
+ messages: list[dict[str, Any]],
+ request: dict[str, Any],
+) -> str:
+ material = _prompt_cache_key_material(messages, request)
+ if not material:
+ return ""
+ digest = hashlib.sha256(
+ json.dumps(
+ {
+ "model": model,
+ "material": material,
+ },
+ sort_keys=True,
+ default=str,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ ).hexdigest()[:32]
+ return f"a0-{digest}"
+
+
+def _prompt_cache_key_material(
+ messages: list[dict[str, Any]],
+ request: dict[str, Any],
+) -> dict[str, Any]:
+ material: dict[str, Any] = {}
+
+ leading_messages: list[dict[str, Any]] = []
+ for message in messages:
+ role = str(message.get("role") or "")
+ if role not in {"system", "developer"}:
+ break
+ leading_messages.append(
+ {
+ "role": role,
+ "content": message.get("content"),
+ }
+ )
+ if leading_messages:
+ material["messages"] = leading_messages
+
+ if request.get("instructions"):
+ material["instructions"] = request["instructions"]
+ if request.get("prompt"):
+ material["prompt"] = request["prompt"]
+ if request.get("tools"):
+ material["tools"] = request["tools"]
+
+ return material
+
+
+def _has_cache_control(value: Any) -> bool:
+ if isinstance(value, dict):
+ if value.get("cache_control") is not None:
+ return True
+ return any(_has_cache_control(item) for item in value.values())
+ if isinstance(value, list):
+ return any(_has_cache_control(item) for item in value)
+ return False
+
+
+def _without_cache_control(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {
+ key: _without_cache_control(item)
+ for key, item in value.items()
+ if key != "cache_control"
+ }
+ if isinstance(value, list):
+ return [_without_cache_control(item) for item in value]
+ return value
+
+
+def _object_to_dict(obj: Any) -> dict[str, Any]:
+ if isinstance(obj, dict):
+ return dict(obj)
+ if hasattr(obj, "model_dump"):
+ dumped = obj.model_dump()
+ return dict(dumped) if isinstance(dumped, dict) else {}
+ if hasattr(obj, "dict"):
+ dumped = obj.dict()
+ return dict(dumped) if isinstance(dumped, dict) else {}
+ return {}
+
+
+def _reported_usage(response: Any) -> dict[str, Any]:
+ usage = _object_to_dict(_get_value(response, "usage"))
+ hidden = _object_to_dict(_get_value(response, "_hidden_params"))
+ if usage.get("cost") is None:
+ usage.pop("cost", None)
+ if hidden.get("response_cost") is not None:
+ usage["cost"] = hidden["response_cost"]
+ return usage
+
+
+def _normalize_reasoning_effort(effort: Any) -> str | None:
+ if isinstance(effort, str):
+ normalized = effort.strip().lower()
+ else:
+ normalized = str(effort).strip().lower() if effort is not None else ""
+ if normalized in RESPONSES_REASONING_EFFORTS:
+ return normalized
+ if normalized in NO_REASONING_EFFORT_ALIASES:
+ return None
+ return RESPONSES_REASONING_FALLBACK_EFFORT
+
+
+def _is_responses_reasoning_effort_error(exc: Exception) -> bool:
+ text = _exception_text(exc).lower()
+ return (
+ "response.reasoning.effort" in text
+ and "minimal" in text
+ and "high" in text
+ and "none" in text
+ )
+
+
+def _is_responses_not_supported_error(exc: Exception) -> bool:
+ text = _exception_text(exc).lower()
+ if any(marker in text for marker in ("429", "too many requests", "rate limit")):
+ return False
+ if _is_sse_json_decode_error(exc):
+ return True
+ if _is_bad_request_error(exc) and _looks_like_responses_request_rejected(text):
+ return True
+ if _is_server_error(exc) and _looks_like_responses_endpoint(text):
+ return True
+ if _is_not_found_error(exc) and _looks_like_responses_endpoint_not_found(text):
+ return True
+ if "/v1/responses" in text and any(
+ marker in text for marker in ("404", "not found")
+ ):
+ return True
+ return any(
+ marker in text
+ for marker in (
+ "responses api",
+ "does not support responses",
+ "not support responses",
+ "unsupportedparamserror",
+ "does not support parameters",
+ "no 'tools' defined while 'tool_choice' is specified",
+ "tools` must not be an empty array",
+ "tools must not be an empty array",
+ "not available through this proxy",
+ "litellm[proxy]",
+ "no module named 'fastapi'",
+ )
+ )
+
+
+def _is_not_found_error(exc: Exception) -> bool:
+ if _exception_status_code(exc) == 404:
+ return True
+ return "notfounderror" in _exception_type_chain(exc).lower()
+
+
+def _is_bad_request_error(exc: Exception) -> bool:
+ if _exception_status_code(exc) == 400:
+ return True
+ type_chain = _exception_type_chain(exc).lower()
+ if "badrequesterror" in type_chain:
+ return True
+ text = _exception_text(exc).lower()
+ return "400" in text and "bad request" in text
+
+
+def _is_server_error(exc: Exception) -> bool:
+ status_code = _exception_status_code(exc)
+ if isinstance(status_code, int) and 500 <= status_code < 600:
+ return True
+ type_chain = _exception_type_chain(exc).lower()
+ if "internalservererror" in type_chain:
+ return True
+ text = _exception_text(exc).lower()
+ return any(
+ marker in text
+ for marker in (
+ "500 internal server error",
+ "server error '500",
+ "internalservererror",
+ )
+ )
+
+
+def _is_sse_json_decode_error(exc: Exception) -> bool:
+ current: BaseException | None = exc
+ while current is not None:
+ if isinstance(current, json.JSONDecodeError) and _looks_like_sse_payload(
+ current.doc
+ ):
+ return True
+ current = current.__cause__ or (
+ current.__context__ if current.__context__ is not current.__cause__ else None
+ )
+ return _looks_like_sse_payload(_exception_text(exc))
+
+
+def _looks_like_sse_payload(text: Any) -> bool:
+ if not isinstance(text, str):
+ return False
+ lowered = text.lstrip().lower()
+ return lowered.startswith("event:") and "\ndata:" in lowered
+
+
+def _looks_like_responses_request_rejected(text: str) -> bool:
+ if "/v1/responses" in text or "responses api" in text:
+ return True
+ return any(
+ marker in text
+ for marker in (
+ "input_image",
+ "response.input",
+ "expected object, received string",
+ "expected string, received array",
+ "zod",
+ "failed to deserialize input",
+ "failed to deserialize response",
+ "failed to deserialize responses",
+ "cannot determine type",
+ )
+ )
+
+
+def _looks_like_responses_endpoint_not_found(text: str) -> bool:
+ if "/v1/responses" in text:
+ return True
+ if "not found" not in text:
+ return False
+ if "openaiexception" in text:
+ return True
+ return "detail" in text and "not found" in text
+
+
+def _looks_like_responses_endpoint(text: str) -> bool:
+ return "/responses" in text or "path /api/v1/responses" in text
+
+
+def _is_responses_state_unsupported_error(exc: Exception) -> bool:
+ text = _exception_text(exc).lower()
+ if any(marker in text for marker in ("429", "too many requests", "rate limit")):
+ return False
+ if "404" in text and "/v1/responses/" in text:
+ return True
+ return any(
+ marker in text
+ for marker in (
+ "previous_response_id",
+ "store",
+ "stored response",
+ "response not found",
+ "no response found",
+ "does not support response storage",
+ "doesn't support response storage",
+ "response storage is not supported",
+ "state is not supported",
+ )
+ )
+
+
+def _is_responses_builtin_tool_error(exc: Exception) -> bool:
+ text = _exception_text(exc).lower()
+ if any(marker in text for marker in ("429", "too many requests", "rate limit")):
+ return False
+ return any(
+ marker in text
+ for marker in (
+ "unsupported tool",
+ "unsupported tools",
+ "invalid tool",
+ "tool type",
+ "tools[",
+ "web_search",
+ "file_search",
+ "code_interpreter",
+ "image_generation",
+ "computer_use_preview",
+ "mcp",
+ )
+ )
+
+
+def _exception_text(exc: Exception | None) -> str:
+ if exc is None:
+ return ""
+ parts = [exc.__class__.__name__, str(exc)]
+ for attr in ("status_code", "code", "message", "body"):
+ value = getattr(exc, attr, None)
+ if value not in (None, ""):
+ parts.append(f"{attr}={value}")
+ response = getattr(exc, "response", None)
+ if response is not None:
+ response_text = getattr(response, "text", None)
+ if response_text:
+ parts.append(str(response_text))
+ response_url = getattr(response, "url", None)
+ if response_url:
+ parts.append(str(response_url))
+ cause = getattr(exc, "__cause__", None)
+ context = getattr(exc, "__context__", None)
+ if cause is not None:
+ parts.append(str(cause))
+ if context is not None and context is not cause:
+ parts.append(str(context))
+ return "\n".join(parts)
+
+
+def _exception_status_code(exc: Exception | None) -> int | None:
+ if exc is None:
+ return None
+ for attr in ("status_code", "code"):
+ value = getattr(exc, attr, None)
+ if isinstance(value, int):
+ return value
+ if isinstance(value, str) and value.isdigit():
+ return int(value)
+ response = getattr(exc, "response", None)
+ value = getattr(response, "status_code", None)
+ return value if isinstance(value, int) else None
+
+
+def _exception_type_chain(exc: Exception | None) -> str:
+ names: list[str] = []
+ current = exc
+ while current is not None:
+ names.append(current.__class__.__name__)
+ cause = getattr(current, "__cause__", None)
+ context = getattr(current, "__context__", None)
+ current = cause or (context if context is not cause else None)
+ return "\n".join(names)
+
+
+def _close_sync_stream(stream: Any) -> None:
+ for method_name in ("close", "aclose"):
+ close = getattr(stream, method_name, None)
+ if close is None:
+ continue
+ result = close()
+ if inspect.isawaitable(result):
+ result.close()
+ return
+
+
+async def _close_async_stream(stream: Any) -> None:
+ for method_name in ("aclose", "close"):
+ close = getattr(stream, method_name, None)
+ if close is None:
+ continue
+ result = close()
+ if inspect.isawaitable(result):
+ await result
+ return
+
+
+def _without_stream_kwarg(kwargs: dict[str, Any]) -> dict[str, Any]:
+ kwargs.pop("stream", None)
+ return kwargs
+
+
+def _first_choice(chunk: Any) -> Any:
+ choices = _get_value(chunk, "choices") or []
+ return choices[0] if choices else {}
+
+
+def _get_value(obj: Any, key: str) -> Any:
+ if isinstance(obj, dict):
+ return obj.get(key)
+ value = getattr(obj, key, None)
+ if value is not None:
+ return value
+ return _object_to_dict(obj).get(key)
+
+
+def _as_list(value: Any) -> list[Any]:
+ return value if isinstance(value, list) else []
+
+
+def _has_tools(tools: Any) -> bool:
+ if isinstance(tools, list):
+ return bool(tools)
+ return bool(tools)
+
+
+def _has_chunk_delta(chunk: ChatChunk) -> bool:
+ return bool(chunk.get("response_delta") or chunk.get("reasoning_delta"))
+
+
+def _has_real_content(content: Any) -> bool:
+ if content == "empty":
+ return False
+ if isinstance(content, str):
+ return bool(content.strip())
+ if isinstance(content, list):
+ return len(content) > 0
+ return content is not None
+
+
+def _content_to_text(content: Any) -> str:
+ content = images.prepare_content(content)
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ pieces: list[str] = []
+ for item in content:
+ if isinstance(item, str):
+ pieces.append(item)
+ elif isinstance(item, dict):
+ text = item.get("text")
+ if isinstance(text, str):
+ pieces.append(text)
+ return "\n".join(pieces)
+ return "" if content is None else str(content)
diff --git a/helpers/litellm_transport.py.dox.md b/helpers/litellm_transport.py.dox.md
index 76594e8d612bac11ca2c3edd594147ad03a263df..78f304baf5b0026593ae752ba1a8c4bfcb4f8dac 100644
--- a/helpers/litellm_transport.py.dox.md
+++ b/helpers/litellm_transport.py.dox.md
@@ -1,3 +1,56 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:4124f28f52daf2e6416d218126740670e8fc953e3511dd809640ef49ab001da1
-size 3871
+# litellm_transport.py DOX
+
+## Purpose
+
+- Own Agent Zero's LiteLLM transport adapter for Chat Completions and Responses API calls.
+- Normalize Agent Zero model-call kwargs into provider-safe LiteLLM requests.
+- Preserve canonical response metadata for history, provider-state continuation, and fallback decisions.
+
+## Ownership
+
+- `litellm_transport.py` owns the runtime implementation.
+- `litellm_transport.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `TransportMode`
+- `TransportRecovery`
+- `TransportPolicy`
+- `LiteLLMTransport`
+- `ChatCompletionsTransport`
+- `ResponsesTransport`
+- `ResponsesEventParser`
+- Top-level functions include transport cache reset, request normalization, parsing, prompt-cache preparation, and response/error classifiers.
+
+## Runtime Contracts
+
+- Keep provider selection and provider-specific defaults outside this helper; callers pass a resolved LiteLLM model name and kwargs.
+- Strip Agent Zero internal kwargs before sending requests to LiteLLM.
+- Do not send orphan tool controls when no tools are present; strict OpenAI-compatible servers can reject empty `tools` arrays.
+- When Agent Zero function tools are present, default Responses requests to one required native call; explicit request-level `tool_choice` and `parallel_tool_calls` values still win.
+- Normalize function tool parameter schemas with an explicit object `properties` field before Responses requests so OpenAI-compatible chat backends reached through LiteLLM can validate them.
+- Default to Chat Completions; use Responses only when `a0_api_mode` explicitly selects it, with fallback to Chat Completions when unsupported.
+- Fall back to Chat Completions when a Responses request is rejected before any output by an endpoint-specific or shape-specific Bad Request indicating the provider cannot parse Responses payloads.
+- Treat opaque type-discrimination errors such as `cannot determine type` from OpenAI-compatible Responses endpoints as shape-specific rejections.
+- Fall back to Chat Completions when a Responses endpoint fails before output with an endpoint-specific server error, proxy path-unavailable error, or LiteLLM proxy-extra import error.
+- Fall back to Chat Completions when LiteLLM's Responses mock streaming path tries to JSON-decode a real SSE stream before any output.
+- Preserve Chat Completions tool calls from both non-streaming responses and streaming deltas as canonical `LLMResult` function-call items.
+- Preserve provider usage and LiteLLM response cost for both transports only when the response or stream actually supplies them; do not synthesize unavailable provider accounting.
+- Preserve Responses function calls collected from stream events when a terminal completed event omits them.
+- Stream native `response` function arguments through a canonical response-tool envelope while continuing to buffer other function calls until completion.
+- Serialize synthesized Responses function-call JSON with literal Unicode so streamed raw-response logs preserve tool arguments.
+- Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported.
+- Keep prompt-cache markers only for providers that accept them.
+
+## Work Guidance
+
+- Add provider-agnostic request cleanup here when multiple OpenAI-compatible providers can benefit.
+- Treat fallback behavior as a shared transport contract, not a provider registry.
+- Keep tool conversion symmetric between Chat Completions and Responses requests.
+
+## Verification
+
+- Run `pytest tests/test_stream_tool_early_stop.py tests/test_responses_architecture.py -q` after changing transport normalization or fallback behavior.
+- Run local-provider smoke checks when changing OpenAI-compatible request cleanup.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/llm_result.py b/helpers/llm_result.py
index cf7ce2a4ef9867d70dfcc25e6272432a04fd3c72..23859871c590833b4b946a3fbea2c2542a5b0ccd 100644
--- a/helpers/llm_result.py
+++ b/helpers/llm_result.py
@@ -1,3 +1,329 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:2910cce45fbad64b6cd81ee6107ae7b33c3e8796a265d6ac56d2616fcaead25f
-size 11137
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+import json
+from typing import Any
+
+
+RESPONSE_METADATA_KEY = "responses"
+LOCAL_FUNCTION_TOOL_TYPES = {"function_call"}
+TEXT_OUTPUT_TYPES = {"message"}
+REASONING_OUTPUT_TYPES = {"reasoning"}
+
+
+@dataclass
+class ResponseItem:
+ type: str
+ data: dict[str, Any] = field(default_factory=dict)
+
+ @classmethod
+ def from_any(cls, item: Any) -> "ResponseItem":
+ data = object_to_dict(item)
+ return cls(type=str(data.get("type") or ""), data=data)
+
+ def to_dict(self) -> dict[str, Any]:
+ return dict(self.data)
+
+
+@dataclass
+class ResponseFunctionCall:
+ name: str
+ arguments: dict[str, Any]
+ call_id: str
+ item_id: str = ""
+ raw: dict[str, Any] = field(default_factory=dict)
+
+ @classmethod
+ def from_item(cls, item: ResponseItem) -> "ResponseFunctionCall | None":
+ if item.type != "function_call":
+ return None
+ name = str(item.data.get("name") or "")
+ if not name:
+ return None
+ return cls(
+ name=name,
+ arguments=parse_arguments(item.data.get("arguments")),
+ call_id=str(item.data.get("call_id") or item.data.get("id") or ""),
+ item_id=str(item.data.get("id") or ""),
+ raw=dict(item.data),
+ )
+
+
+@dataclass
+class LLMResult:
+ response: str = ""
+ reasoning: str = ""
+ response_id: str = ""
+ previous_response_id: str = ""
+ input_items: list[dict[str, Any]] = field(default_factory=list)
+ output_items: list[ResponseItem] = field(default_factory=list)
+ provider_model_key: str = ""
+ mode: str = "responses"
+ state: str = "provider"
+ usage: dict[str, Any] = field(default_factory=dict)
+ raw: dict[str, Any] = field(default_factory=dict)
+ capability: dict[str, Any] = field(default_factory=dict)
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any] | None) -> "LLMResult":
+ data = data or {}
+ return cls(
+ response=str(data.get("response") or ""),
+ reasoning=str(data.get("reasoning") or ""),
+ response_id=str(data.get("response_id") or ""),
+ previous_response_id=str(data.get("previous_response_id") or ""),
+ input_items=list(data.get("input_items") or []),
+ output_items=[
+ ResponseItem.from_any(item) for item in data.get("output_items") or []
+ ],
+ provider_model_key=str(data.get("provider_model_key") or ""),
+ mode=str(data.get("mode") or "responses"),
+ state=str(data.get("state") or "provider"),
+ usage=object_to_dict(data.get("usage") or {}),
+ raw=object_to_dict(data.get("raw") or {}),
+ capability=object_to_dict(data.get("capability") or {}),
+ )
+
+ @classmethod
+ def from_response(
+ cls,
+ response: Any,
+ *,
+ input_items: list[dict[str, Any]] | None = None,
+ previous_response_id: str = "",
+ provider_model_key: str = "",
+ mode: str = "responses",
+ state: str = "provider",
+ capability: dict[str, Any] | None = None,
+ ) -> "LLMResult":
+ raw = object_to_dict(response)
+ output_items = [ResponseItem.from_any(item) for item in as_list(raw.get("output"))]
+ result = cls(
+ response_id=str(raw.get("id") or ""),
+ previous_response_id=str(
+ raw.get("previous_response_id") or previous_response_id or ""
+ ),
+ input_items=list(input_items or []),
+ output_items=output_items,
+ provider_model_key=provider_model_key,
+ mode=mode,
+ state=state,
+ usage=object_to_dict(raw.get("usage") or {}),
+ raw=raw,
+ capability=dict(capability or {}),
+ )
+ result.response = output_text(raw, output_items)
+ result.reasoning = reasoning_text(output_items)
+ if not result.response and result.function_calls:
+ result.response = result.function_calls_text()
+ return result
+
+ @classmethod
+ def from_chat(
+ cls,
+ *,
+ response: str,
+ reasoning: str = "",
+ usage: dict[str, Any] | None = None,
+ input_items: list[dict[str, Any]] | None = None,
+ output_items: list[dict[str, Any]] | None = None,
+ provider_model_key: str = "",
+ capability: dict[str, Any] | None = None,
+ ) -> "LLMResult":
+ items = [ResponseItem.from_any(item) for item in output_items or []]
+ if response and not items:
+ items.append(
+ ResponseItem(
+ type="message",
+ data={
+ "type": "message",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": response}],
+ },
+ )
+ )
+ if reasoning:
+ items.insert(
+ 0,
+ ResponseItem(
+ type="reasoning",
+ data={
+ "type": "reasoning",
+ "summary": [{"type": "summary_text", "text": reasoning}],
+ },
+ ),
+ )
+ result = cls(
+ response=response,
+ reasoning=reasoning,
+ input_items=list(input_items or []),
+ output_items=items,
+ provider_model_key=provider_model_key,
+ mode="chat_completions",
+ state="off",
+ usage=object_to_dict(usage or {}),
+ capability=dict(capability or {}),
+ )
+ if not result.response and result.function_calls:
+ result.response = result.function_calls_text()
+ return result
+
+ @property
+ def function_calls(self) -> list[ResponseFunctionCall]:
+ calls: list[ResponseFunctionCall] = []
+ for item in self.output_items:
+ call = ResponseFunctionCall.from_item(item)
+ if call:
+ calls.append(call)
+ return calls
+
+ @property
+ def builtin_items(self) -> list[ResponseItem]:
+ return [
+ item
+ for item in self.output_items
+ if item.type
+ and item.type not in TEXT_OUTPUT_TYPES
+ and item.type not in REASONING_OUTPUT_TYPES
+ and item.type not in LOCAL_FUNCTION_TOOL_TYPES
+ ]
+
+ def function_calls_text(self) -> str:
+ calls = [
+ {"tool_name": call.name, "tool_args": call.arguments}
+ for call in self.function_calls
+ ]
+ if not calls:
+ return ""
+ if len(calls) == 1:
+ return json.dumps(calls[0], ensure_ascii=False)
+ return json.dumps(
+ {"tool_name": "parallel_tool_calls", "tool_args": {"calls": calls}},
+ ensure_ascii=False,
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "response": self.response,
+ "reasoning": self.reasoning,
+ "response_id": self.response_id,
+ "previous_response_id": self.previous_response_id,
+ "input_items": self.input_items,
+ "output_items": [item.to_dict() for item in self.output_items],
+ "provider_model_key": self.provider_model_key,
+ "mode": self.mode,
+ "state": self.state,
+ "usage": self.usage,
+ "raw": self.raw,
+ "capability": self.capability,
+ }
+
+ def metadata(self) -> dict[str, Any]:
+ return {
+ RESPONSE_METADATA_KEY: {
+ "response_id": self.response_id,
+ "previous_response_id": self.previous_response_id,
+ "output_items": [item.to_dict() for item in self.output_items],
+ "provider_model_key": self.provider_model_key,
+ "mode": self.mode,
+ "state": self.state,
+ "usage": self.usage,
+ "capability": self.capability,
+ }
+ }
+
+
+def function_call_output_item(
+ call_id: str,
+ output: str,
+ *,
+ acknowledged_safety_checks: list[dict[str, Any]] | None = None,
+) -> dict[str, Any]:
+ item: dict[str, Any] = {
+ "type": "function_call_output",
+ "call_id": str(call_id or ""),
+ "output": output,
+ }
+ if acknowledged_safety_checks:
+ item["acknowledged_safety_checks"] = acknowledged_safety_checks
+ return item
+
+
+def metadata_from_llm_result(result: LLMResult | None) -> dict[str, Any]:
+ return result.metadata() if result else {}
+
+
+def result_from_metadata(metadata: dict[str, Any] | None) -> LLMResult | None:
+ if not isinstance(metadata, dict):
+ return None
+ data = metadata.get(RESPONSE_METADATA_KEY)
+ if not isinstance(data, dict):
+ return None
+ return LLMResult.from_dict(data)
+
+
+def object_to_dict(obj: Any) -> dict[str, Any]:
+ if isinstance(obj, dict):
+ return dict(obj)
+ if hasattr(obj, "model_dump"):
+ dumped = obj.model_dump()
+ return dict(dumped) if isinstance(dumped, dict) else {}
+ if hasattr(obj, "dict"):
+ dumped = obj.dict()
+ return dict(dumped) if isinstance(dumped, dict) else {}
+ return {}
+
+
+def as_list(value: Any) -> list[Any]:
+ return value if isinstance(value, list) else []
+
+
+def output_text(raw: dict[str, Any], output_items: list[ResponseItem]) -> str:
+ direct = raw.get("output_text")
+ if isinstance(direct, str):
+ return direct
+ pieces: list[str] = []
+ for item in output_items:
+ if item.type != "message":
+ continue
+ for block in as_list(item.data.get("content")):
+ if not isinstance(block, dict):
+ continue
+ block_type = block.get("type")
+ if block_type in {"output_text", "text", "input_text"}:
+ text = block.get("text")
+ if isinstance(text, str):
+ pieces.append(text)
+ elif block_type == "refusal":
+ refusal = block.get("refusal")
+ if isinstance(refusal, str):
+ pieces.append(refusal)
+ return "".join(pieces)
+
+
+def reasoning_text(output_items: list[ResponseItem]) -> str:
+ pieces: list[str] = []
+ for item in output_items:
+ if item.type != "reasoning":
+ continue
+ for block in as_list(item.data.get("summary")):
+ if isinstance(block, dict):
+ text = block.get("text") or block.get("reasoning")
+ if isinstance(text, str):
+ pieces.append(text)
+ elif isinstance(block, str):
+ pieces.append(block)
+ return "".join(pieces)
+
+
+def parse_arguments(raw_arguments: Any) -> dict[str, Any]:
+ if isinstance(raw_arguments, dict):
+ return raw_arguments
+ if isinstance(raw_arguments, str):
+ try:
+ parsed = json.loads(raw_arguments or "{}")
+ except Exception:
+ parsed = {"arguments": raw_arguments}
+ else:
+ parsed = {"arguments": raw_arguments}
+ return parsed if isinstance(parsed, dict) else {"arguments": parsed}
diff --git a/helpers/llm_result.py.dox.md b/helpers/llm_result.py.dox.md
index 668ac49caa0a69e77272988f25d6ae8a810c2dcd..aceaa83d158ab0b356f83141bbe0c3d014433d93 100644
--- a/helpers/llm_result.py.dox.md
+++ b/helpers/llm_result.py.dox.md
@@ -1,3 +1,39 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3c7f83898be24f92117c584dc41c20e03ba0711e19158d25b0835f5bd59c137d
-size 2116
+# llm_result.py DOX
+
+## Purpose
+
+- Own canonical LLM result metadata shared by model transports, history, and tool-result processing.
+- Preserve Responses API output items, provider response IDs, reasoning text, usage, and capability metadata in a serializable form.
+
+## Ownership
+
+- `llm_result.py` owns the runtime implementation.
+- `llm_result.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `ResponseItem`
+- `ResponseFunctionCall`
+- `LLMResult`
+- Top-level functions include metadata conversion, function-call output item construction, object normalization, output-text extraction, reasoning extraction, and function-call argument parsing.
+
+## Runtime Contracts
+
+- `LLMResult.metadata()` stores only durable provider state under `RESPONSE_METADATA_KEY`: response IDs, structured output items, provider/mode/state, usage, and capability data. Runtime prompt inputs, raw responses, and duplicated response/reasoning text are not persisted in history.
+- `from_response(...)` must preserve provider `response_id`, `previous_response_id`, raw output items, usage, and capability metadata.
+- `from_chat(...)` must produce an equivalent chat-completions result with `mode="chat_completions"` and `state="off"`, preserving optional function-call output items and provider usage when the chat transport supplies them.
+- Function-call output items must preserve `call_id` and optional acknowledged safety checks.
+- Argument parsing must tolerate JSON strings, dictionaries, and malformed values without throwing.
+
+## Work Guidance
+
+- Keep metadata backward-compatible with existing serialized chat history.
+- Treat unknown response item types as preserved built-in items unless they are local function calls, message text, or reasoning.
+- Avoid provider-specific assumptions in result parsing.
+
+## Verification
+
+- Run `pytest tests/test_responses_architecture.py -q` after changing result metadata behavior.
+- Run focused history/tool-processing tests when changing function-call serialization.
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/localization.py b/helpers/localization.py
index 35bac329285c0844afe5ec6660016674e0a4f8b7..eaf8c749a65873c8bf8489ab7a0acb9ff6d8c8c3 100644
--- a/helpers/localization.py
+++ b/helpers/localization.py
@@ -1,3 +1,215 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:71732c5b2c167b85162d70e15d719c7ee8f60fa7f30b409068bba58035e893dc
-size 8857
+from datetime import datetime, timezone as dt_timezone
+import os
+import time
+import pytz # type: ignore
+
+from helpers.print_style import PrintStyle
+from helpers.dotenv import get_dotenv_value, save_dotenv_value
+
+
+
+class Localization:
+ """
+ Localization class for handling timezone conversions around the user's IANA
+ timezone. UTC is still used when an external protocol requires an absolute
+ instant, but user-facing timestamps are formatted in the configured timezone.
+ """
+
+ # singleton
+ _instance = None
+
+ @classmethod
+ def get(cls, *args, **kwargs):
+ if cls._instance is None:
+ cls._instance = cls(*args, **kwargs)
+ return cls._instance
+
+ def __init__(self, timezone: str | None = None):
+ self.timezone: str = "UTC"
+ self._offset_minutes: int = 0
+ self._last_timezone_change: datetime | None = None
+ # Load persisted values if available.
+ persisted_tz = str(get_dotenv_value("DEFAULT_USER_TIMEZONE", os.environ.get("TZ") or "UTC"))
+ persisted_offset = get_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", None)
+ if timezone is not None:
+ # Explicit override
+ self.set_timezone(timezone)
+ else:
+ # Initialize from persisted values
+ try:
+ pytz.timezone(persisted_tz)
+ self.timezone = persisted_tz
+ except pytz.exceptions.UnknownTimeZoneError:
+ self.timezone = "UTC"
+ current_offset = self._compute_offset_minutes(self.timezone)
+ try:
+ persisted_offset_minutes = int(str(persisted_offset)) if persisted_offset is not None else None
+ except Exception:
+ persisted_offset_minutes = None
+ self._offset_minutes = current_offset
+ if persisted_offset_minutes != current_offset:
+ save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes))
+ self.apply_process_timezone()
+
+ def get_timezone(self) -> str:
+ return self.timezone
+
+ def get_tzinfo(self):
+ try:
+ return pytz.timezone(self.timezone)
+ except pytz.exceptions.UnknownTimeZoneError:
+ return pytz.timezone("UTC")
+
+ def _compute_offset_minutes(self, timezone_name: str) -> int:
+ tzinfo = pytz.timezone(timezone_name)
+ now_in_tz = datetime.now(tzinfo)
+ offset = now_in_tz.utcoffset()
+ return int(offset.total_seconds() // 60) if offset else 0
+
+ def get_offset_minutes(self) -> int:
+ return self._offset_minutes
+
+ def apply_process_timezone(self) -> None:
+ """Apply the configured timezone to this process and child processes."""
+ os.environ["TZ"] = self.timezone
+ if hasattr(time, "tzset"):
+ try:
+ time.tzset()
+ except Exception as e:
+ PrintStyle.error(f"Error applying timezone {self.timezone}: {e}")
+
+ def now(self) -> datetime:
+ """Return the current datetime in the user's configured timezone."""
+ return datetime.now(self.get_tzinfo())
+
+ def now_iso(self, sep: str = "T", timespec: str = "auto") -> str:
+ return self.now().isoformat(sep=sep, timespec=timespec)
+
+ def localize_naive_datetime(self, dt: datetime) -> datetime:
+ """Treat a naive datetime as user-local and make it timezone-aware."""
+ if dt.tzinfo is not None:
+ return dt
+ tzinfo = self.get_tzinfo()
+ try:
+ return tzinfo.localize(dt, is_dst=None)
+ except pytz.exceptions.AmbiguousTimeError:
+ return tzinfo.localize(dt, is_dst=False)
+ except pytz.exceptions.NonExistentTimeError:
+ return tzinfo.localize(dt, is_dst=True)
+
+ def set_timezone(self, timezone: str) -> None:
+ """Set the user's IANA timezone and propagate it to child processes."""
+ try:
+ # Validate timezone and compute its current offset
+ _ = pytz.timezone(timezone)
+ new_offset = self._compute_offset_minutes(timezone)
+ if timezone == self.timezone and new_offset == self._offset_minutes:
+ self.apply_process_timezone()
+ return
+
+ prev_tz = getattr(self, "timezone", "None")
+ prev_off = getattr(self, "_offset_minutes", None)
+ PrintStyle.debug(
+ f"Changing timezone from {prev_tz} (offset {prev_off}) to {timezone} (offset {new_offset})"
+ )
+ self._offset_minutes = new_offset
+ self.timezone = timezone
+ save_dotenv_value("DEFAULT_USER_TIMEZONE", timezone)
+ save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes))
+ self.apply_process_timezone()
+ self._last_timezone_change = datetime.now()
+ except pytz.exceptions.UnknownTimeZoneError:
+ fallback_timezone = self.timezone
+ try:
+ pytz.timezone(fallback_timezone)
+ except pytz.exceptions.UnknownTimeZoneError:
+ fallback_timezone = "UTC"
+
+ PrintStyle.error(f"Unknown timezone: {timezone}, keeping {fallback_timezone}")
+ self.timezone = fallback_timezone
+ self._offset_minutes = self._compute_offset_minutes(fallback_timezone)
+ self.apply_process_timezone()
+
+ def localtime_str_to_utc_dt(self, localtime_str: str | None) -> datetime | None:
+ """
+ Convert a local time ISO string to a UTC datetime object.
+ Returns None if input is None or invalid.
+ When input lacks tzinfo, assume the configured user timezone.
+ """
+ if not localtime_str:
+ return None
+
+ try:
+ localtime_str = localtime_str.strip().replace("Z", "+00:00")
+ # Handle both with and without timezone info
+ try:
+ # Try parsing with timezone info first
+ local_datetime_obj = datetime.fromisoformat(localtime_str)
+ if local_datetime_obj.tzinfo is None:
+ # If no timezone info, assume the configured user timezone.
+ local_datetime_obj = self.localize_naive_datetime(local_datetime_obj)
+ except ValueError:
+ # If timezone parsing fails, try a few common local formats.
+ cleaned = localtime_str.replace("T", " ")
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
+ try:
+ local_datetime_obj = datetime.strptime(cleaned, fmt)
+ local_datetime_obj = self.localize_naive_datetime(local_datetime_obj)
+ break
+ except ValueError:
+ continue
+ else:
+ raise
+
+ # Convert to UTC
+ return local_datetime_obj.astimezone(dt_timezone.utc)
+ except Exception as e:
+ PrintStyle.error(f"Error converting localtime string to UTC: {e}")
+ return None
+
+ def utc_dt_to_localtime_str(self, utc_dt: datetime | None, sep: str = "T", timespec: str = "auto") -> str | None:
+ """
+ Convert a UTC datetime object to a local time ISO string using the user's timezone.
+ Returns None if input is None.
+ """
+ if utc_dt is None:
+ return None
+
+ # At this point, utc_dt is definitely not None
+ assert utc_dt is not None
+
+ try:
+ # Ensure datetime is timezone aware in UTC
+ if utc_dt.tzinfo is None:
+ utc_dt = utc_dt.replace(tzinfo=dt_timezone.utc)
+ else:
+ utc_dt = utc_dt.astimezone(dt_timezone.utc)
+
+ # Convert to local time using the user's timezone.
+ local_datetime_obj = utc_dt.astimezone(self.get_tzinfo())
+ return local_datetime_obj.isoformat(sep=sep, timespec=timespec)
+ except Exception as e:
+ PrintStyle.error(f"Error converting UTC datetime to localtime string: {e}")
+ return None
+
+ def serialize_datetime(self, dt: datetime | None) -> str | None:
+ """
+ Serialize a datetime object to ISO format string using the user's timezone.
+ This ensures the frontend receives dates with the correct offset for display.
+ """
+ if dt is None:
+ return None
+
+ # At this point, dt is definitely not None
+ assert dt is not None
+
+ try:
+ # Ensure datetime is timezone aware (if not, assume the user's timezone)
+ if dt.tzinfo is None:
+ dt = self.localize_naive_datetime(dt)
+
+ local_dt = dt.astimezone(self.get_tzinfo())
+ return local_dt.isoformat()
+ except Exception as e:
+ PrintStyle.error(f"Error serializing datetime: {e}")
+ return None
diff --git a/helpers/localization.py.dox.md b/helpers/localization.py.dox.md
index 5b13f99d9a6f24bc00d34b6a6ff21a9a29dfe1f5..b33e410b834b9f69df1c5a698c5ee0fec06db5c5 100644
--- a/helpers/localization.py.dox.md
+++ b/helpers/localization.py.dox.md
@@ -1,3 +1,50 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:fe5e64f7c3272ea0bcae65f7ea0ed17e845ea0cff3da85d6ad80a2e15042c71e
-size 2546
+# localization.py DOX
+
+## Purpose
+
+- Own the `localization.py` helper module.
+- This module loads and resolves localized UI/application text.
+- Keep this file-level DOX profile synchronized with `localization.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `localization.py` owns the runtime implementation.
+- `localization.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `Localization` (no explicit base class)
+ - `get(cls, *args, **kwargs)`
+ - `get_timezone(self) -> str`
+ - `get_tzinfo(self)`
+ - `get_offset_minutes(self) -> int`
+ - `apply_process_timezone(self) -> None`
+ - `now(self) -> datetime`
+ - `now_iso(self, sep: str=..., timespec: str=...) -> str`
+ - `localize_naive_datetime(self, dt: datetime) -> datetime`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem writes, settings/state persistence.
+- Imported dependency areas include: `datetime`, `helpers.dotenv`, `helpers.print_style`, `os`, `pytz`, `time`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `get_dotenv_value`, `pytz.timezone`, `datetime.now`, `now_in_tz.utcoffset`, `self.now.isoformat`, `self.get_tzinfo`, `cls`, `self.set_timezone`, `self._compute_offset_minutes`, `self.apply_process_timezone`, `tzinfo.localize`, `PrintStyle.debug`, `save_dotenv_value`, `localtime_str.strip.replace`, `local_datetime_obj.astimezone`, `utc_dt.astimezone`, `local_datetime_obj.isoformat`, `dt.astimezone`, `local_dt.isoformat`, `time.tzset`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_timezone_regressions.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/log.py b/helpers/log.py
index eb5cb3f794daa7a4574f51a81a5af1527991324d..7a75c6343cd3c04a405c28b21dc4e64a6667d3d8 100644
--- a/helpers/log.py
+++ b/helpers/log.py
@@ -1,3 +1,443 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:2deb18d0907f64147362c76a021be5f1cc9d54e3a46640779b99a1cb8a9844bd
-size 13933
+import copy
+import json
+import threading
+import time
+import uuid
+from collections import OrderedDict
+from dataclasses import dataclass
+from typing import Any, Literal, Optional, TYPE_CHECKING, TypeVar, cast
+
+from helpers.secrets import get_secrets_manager
+from helpers.strings import truncate_text_by_ratio
+
+
+if TYPE_CHECKING:
+ from agent import AgentContext
+
+
+_MARK_DIRTY_ALL = None
+_MARK_DIRTY_FOR_CONTEXT = None
+
+
+def _lazy_mark_dirty_all(*, reason: str | None = None) -> None:
+ # Lazy import to avoid circular import at module load time (AgentContext -> Log).
+ global _MARK_DIRTY_ALL
+ if _MARK_DIRTY_ALL is None:
+ from helpers.state_monitor_integration import mark_dirty_all
+
+ _MARK_DIRTY_ALL = mark_dirty_all
+ _MARK_DIRTY_ALL(reason=reason)
+
+
+def _lazy_mark_dirty_for_context(context_id: str, *, reason: str | None = None) -> None:
+ # Lazy import to avoid circular import at module load time (AgentContext -> Log).
+ global _MARK_DIRTY_FOR_CONTEXT
+ if _MARK_DIRTY_FOR_CONTEXT is None:
+ from helpers.state_monitor_integration import mark_dirty_for_context
+
+ _MARK_DIRTY_FOR_CONTEXT = mark_dirty_for_context
+ _MARK_DIRTY_FOR_CONTEXT(context_id, reason=reason, include_collections=False)
+
+
+T = TypeVar("T")
+
+Type = Literal[
+ "agent",
+ "browser",
+ "code_exe",
+ "subagent",
+ "error",
+ "hint",
+ "info",
+ "progress",
+ "response",
+ "tool",
+ "mcp",
+ "input",
+ "user",
+ "util",
+ "warning",
+]
+
+ProgressUpdate = Literal["persistent", "temporary", "none"]
+
+
+HEADING_MAX_LEN: int = 120
+CONTENT_MAX_LEN: int = 15_000
+RESPONSE_CONTENT_MAX_LEN: int = 250_000
+KEY_MAX_LEN: int = 60
+VALUE_MAX_LEN: int = 5000
+PROGRESS_MAX_LEN: int = 120
+
+
+def _truncate_heading(text: str | None) -> str:
+ if text is None:
+ return ""
+ return truncate_text_by_ratio(str(text), HEADING_MAX_LEN, "...", ratio=1.0)
+
+
+def _truncate_progress(text: str | None) -> str:
+ if text is None:
+ return ""
+ return truncate_text_by_ratio(str(text), PROGRESS_MAX_LEN, "...", ratio=1.0)
+
+
+def _truncate_key(text: str) -> str:
+ return truncate_text_by_ratio(str(text), KEY_MAX_LEN, "...", ratio=1.0)
+
+
+def _truncate_value(val: T) -> T:
+ # If dict, recursively truncate each value
+ if isinstance(val, dict):
+ for k in list(val.keys()):
+ v = val[k]
+ del val[k]
+ val[_truncate_key(k)] = _truncate_value(v)
+ return cast(T, val)
+ # If list or tuple, recursively truncate each item
+ if isinstance(val, list):
+ for i in range(len(val)):
+ val[i] = _truncate_value(val[i])
+ return cast(T, val)
+ if isinstance(val, tuple):
+ return cast(T, tuple(_truncate_value(x) for x in val))
+
+ # Convert non-str values to json for consistent length measurement
+ if isinstance(val, str):
+ raw = val
+ else:
+ try:
+ raw = json.dumps(val, ensure_ascii=False)
+ except Exception:
+ raw = str(val)
+
+ if len(raw) <= VALUE_MAX_LEN:
+ return val # No truncation needed, preserve original type
+
+ # Do a single truncation calculation
+ removed = len(raw) - VALUE_MAX_LEN
+ replacement = f"\n\n<< {removed} Characters hidden >>\n\n"
+ truncated = truncate_text_by_ratio(raw, VALUE_MAX_LEN, replacement, ratio=0.3)
+ return cast(T, truncated)
+
+
+def _truncate_content(text: str | None, type: Type) -> str:
+
+ max_len = CONTENT_MAX_LEN if type != "response" else RESPONSE_CONTENT_MAX_LEN
+
+ if text is None:
+ return ""
+ raw = str(text)
+ if len(raw) <= max_len:
+ return raw
+
+ # Same dynamic replacement logic as value truncation
+ removed = len(raw) - max_len
+ while True:
+ replacement = f"\n\n<< {removed} Characters hidden >>\n\n"
+ truncated = truncate_text_by_ratio(raw, max_len, replacement, ratio=0.3)
+ new_removed = len(raw) - (len(truncated) - len(replacement))
+ if new_removed == removed:
+ break
+ removed = new_removed
+ return truncated
+
+
+@dataclass
+class LogItem:
+ log: "Log"
+ no: int
+ type: Type
+ heading: str = ""
+ content: str = ""
+ update_progress: Optional[ProgressUpdate] = "persistent"
+ kvps: Optional[OrderedDict] = None # Use OrderedDict for kvps
+ id: Optional[str] = None # Add id field
+ guid: str = ""
+ timestamp: float = 0.0
+ agentno: int = 0
+
+ def __post_init__(self):
+ self.guid = self.log.guid
+ self.timestamp = self.timestamp or time.time()
+
+ def update(
+ self,
+ type: Type | None = None,
+ heading: str | None = None,
+ content: str | None = None,
+ kvps: dict | None = None,
+ update_progress: ProgressUpdate | None = None,
+ **kwargs,
+ ):
+ if self.guid == self.log.guid:
+ self.log._update_item(
+ self.no,
+ type=type,
+ heading=heading,
+ content=content,
+ kvps=kvps,
+ update_progress=update_progress,
+ **kwargs,
+ )
+
+ def stream(
+ self,
+ heading: str | None = None,
+ content: str | None = None,
+ **kwargs,
+ ):
+ if heading is not None:
+ self.update(heading=self.heading + heading)
+ if content is not None:
+ self.update(content=self.content + content)
+
+ for k, v in kwargs.items():
+ prev = self.kvps.get(k, "") if self.kvps else ""
+ self.update(**{k: prev + v})
+
+ def output(self):
+ return {
+ "no": self.no,
+ "id": self.id, # Include id in output
+ "type": self.type,
+ "heading": self.heading,
+ "content": self.content,
+ "kvps": self.kvps,
+ "timestamp": self.timestamp,
+ "agentno": self.agentno,
+ }
+
+
+@dataclass(frozen=True)
+class LogOutput:
+ items: list[dict[str, Any]]
+ start: int
+ end: int
+
+
+class Log:
+
+ def __init__(self):
+ self._lock = threading.RLock()
+ self.context: "AgentContext|None" = None # set from outside
+ self.guid: str = str(uuid.uuid4())
+ self.updates: list[int] = []
+ self.logs: list[LogItem] = []
+ self.progress: str = ""
+ self.progress_no: int = 0
+ self.progress_active: bool = False
+ self.set_initial_progress()
+
+ def log(
+ self,
+ type: Type,
+ heading: str | None = None,
+ content: str | None = None,
+ kvps: dict | None = None,
+ update_progress: ProgressUpdate | None = None,
+ id: Optional[str] = None,
+ **kwargs,
+ ) -> LogItem:
+ with self._lock:
+ # add a minimal item to the log
+ # Determine agent number from streaming agent
+ agentno = 0
+ if self.context and self.context.streaming_agent:
+ agentno = self.context.streaming_agent.number
+
+ item = LogItem(
+ log=self,
+ no=len(self.logs),
+ type=type,
+ agentno=agentno,
+ )
+
+ self.logs.append(item)
+
+ # Update outside the lock - the heavy masking/truncation work should not hold
+ # the lock; we only need locking while mutating shared arrays/fields.
+ self._update_item(
+ no=item.no,
+ type=type,
+ heading=heading,
+ content=content,
+ kvps=kvps,
+ update_progress=update_progress,
+ id=id,
+ notify_state_monitor=False,
+ **kwargs,
+ )
+
+ self._notify_state_monitor()
+ return item
+
+ def _update_item(
+ self,
+ no: int,
+ type: Type | None = None,
+ heading: str | None = None,
+ content: str | None = None,
+ kvps: dict | None = None,
+ update_progress: ProgressUpdate | None = None,
+ id: Optional[str] = None,
+ notify_state_monitor: bool = True,
+ **kwargs,
+ ):
+ # Capture the effective type for truncation without holding the lock during
+ # masking/truncation work.
+ with self._lock:
+ current_type = self.logs[no].type
+ type_for_truncation = type if type is not None else current_type
+
+ heading_out: str | None = None
+ if heading is not None:
+ heading_out = _truncate_heading(self._mask_recursive(heading))
+
+ content_out: str | None = None
+ if content is not None:
+ content_out = _truncate_content(self._mask_recursive(content), type_for_truncation)
+
+ kvps_out: OrderedDict | None = None
+ if kvps is not None:
+ kvps_out_tmp = OrderedDict(copy.deepcopy(kvps))
+ kvps_out_tmp = self._mask_recursive(kvps_out_tmp)
+ kvps_out_tmp = _truncate_value(kvps_out_tmp)
+ kvps_out = OrderedDict(kvps_out_tmp)
+
+ kwargs_out: dict | None = None
+ if kwargs:
+ kwargs_out = copy.deepcopy(kwargs)
+ kwargs_out = self._mask_recursive(kwargs_out)
+ kwargs_out = _truncate_value(kwargs_out)
+
+ with self._lock:
+ item = self.logs[no]
+
+ if id is not None:
+ item.id = id
+
+ if type is not None:
+ item.type = type
+
+ if update_progress is not None:
+ item.update_progress = update_progress
+
+ if heading_out is not None:
+ item.heading = heading_out
+
+ if content_out is not None:
+ item.content = content_out
+
+ if kvps_out is not None:
+ item.kvps = kvps_out
+ elif item.kvps is None:
+ item.kvps = OrderedDict()
+
+ if kwargs_out:
+ if item.kvps is None:
+ item.kvps = OrderedDict()
+ item.kvps.update(kwargs_out)
+
+ self.updates.append(item.no)
+
+ if item.heading and item.update_progress != "none":
+ if item.no >= self.progress_no:
+ self.progress = item.heading
+ self.progress_no = (
+ item.no if item.update_progress == "persistent" else -1
+ )
+ self.progress_active = True
+ if notify_state_monitor:
+ self._notify_state_monitor_for_context_update()
+
+ def _notify_state_monitor(self) -> None:
+ ctx = self.context
+ if not ctx:
+ return
+ # Logs update both the active chat stream (sid-bound) and the global chats list
+ # (context metadata like last_message/log_version). Broadcast so all tabs refresh
+ # their chat/task lists without leaking logs (logs are still scoped per-sid).
+ _lazy_mark_dirty_all(reason="log.Log._notify_state_monitor")
+
+ def _notify_state_monitor_for_context_update(self) -> None:
+ ctx = self.context
+ if not ctx:
+ return
+ # Log item updates only need to refresh the active chat stream for any sid
+ # currently projecting this context. Avoid global fanout at high frequency.
+ _lazy_mark_dirty_for_context(ctx.id, reason="log.Log._update_item")
+
+ def set_progress(self, progress: str, no: int = 0, active: bool = True):
+ progress = self._mask_recursive(progress)
+ progress = _truncate_progress(progress)
+ changed = False
+ ctx = self.context
+ with self._lock:
+ prev_progress = self.progress
+ prev_active = self.progress_active
+
+ self.progress = progress
+ if not no:
+ no = len(self.logs)
+ self.progress_no = no
+ self.progress_active = active
+
+ changed = self.progress != prev_progress or self.progress_active != prev_active
+
+ if changed and ctx:
+ # Progress changes are included in every snapshot, but push sync requires a
+ # dirty mark even when no log items changed.
+ _lazy_mark_dirty_for_context(ctx.id, reason="log.Log.set_progress")
+
+ def set_initial_progress(self):
+ self.set_progress("Waiting for input", 0, False)
+
+ def output(self, start=None, end=None):
+ with self._lock:
+ if start is None:
+ start = 0
+ if end is None:
+ end = len(self.updates)
+ updates = self.updates[start:end]
+ logs = list(self.logs)
+
+ out = []
+ seen = set()
+ for update in updates:
+ if update not in seen and update < len(logs):
+ out.append(logs[update].output())
+ seen.add(update)
+ return LogOutput(items=out, start=start, end=end)
+
+ def reset(self):
+ with self._lock:
+ self.guid = str(uuid.uuid4())
+ self.updates = []
+ self.logs = []
+ self.set_initial_progress()
+
+ def _mask_recursive(self, obj: T) -> T:
+ """Recursively mask secrets in nested objects."""
+ try:
+ from agent import AgentContext
+ secrets_mgr = get_secrets_manager(self.context or AgentContext.current())
+
+ # debug helper to identify context mismatch
+ # self_id = self.context.id if self.context else None
+ # current_ctx = AgentContext.current()
+ # current_id = current_ctx.id if current_ctx else None
+ # if self_id != current_id:
+ # print(f"Context ID mismatch: {self_id} != {current_id}")
+
+ if isinstance(obj, str):
+ return cast(Any, secrets_mgr.mask_values(obj))
+ elif isinstance(obj, dict):
+ return {k: self._mask_recursive(v) for k, v in obj.items()} # type: ignore
+ elif isinstance(obj, list):
+ return [self._mask_recursive(item) for item in obj] # type: ignore
+ else:
+ return obj
+ except Exception:
+ # If masking fails, return original object
+ return obj
diff --git a/helpers/log.py.dox.md b/helpers/log.py.dox.md
index 598359fd9e422039c9006e8b35ecd8bf569b97ca..c6d57df6cc12dbc8796e077801b4507f4b7d7c6b 100644
--- a/helpers/log.py.dox.md
+++ b/helpers/log.py.dox.md
@@ -1,3 +1,70 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1f758886fc70bdd254fa4537db54d41ffab941082511c2b5b18bc8e749df8f48
-size 4225
+# log.py DOX
+
+## Purpose
+
+- Own the `log.py` helper module.
+- This module owns chat log items, outputs, truncation, and dirty-state integration.
+- Keep this file-level DOX profile synchronized with `log.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `log.py` owns the runtime implementation.
+- `log.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `LogItem` (no explicit base class)
+ - `update(self, type: Type | None=..., heading: str | None=..., content: str | None=..., kvps: dict | None=..., update_progress: ProgressUpdate | None=..., **kwargs)`
+ - `stream(self, heading: str | None=..., content: str | None=..., **kwargs)`
+ - `output(self)`
+- `LogOutput` (no explicit base class)
+- `Log` (no explicit base class)
+ - `log(self, type: Type, heading: str | None=..., content: str | None=..., kvps: dict | None=..., update_progress: ProgressUpdate | None=..., id: Optional[str]=..., **kwargs) -> LogItem`
+ - `set_progress(self, progress: str, no: int=..., active: bool=...)`
+ - `set_initial_progress(self)`
+ - `output(self, start=..., end=...)`
+ - `reset(self)`
+- Top-level functions:
+- `_lazy_mark_dirty_all(reason: str | None=...) -> None`
+- `_lazy_mark_dirty_for_context(context_id: str, reason: str | None=...) -> None`
+- `_truncate_heading(text: str | None) -> str`
+- `_truncate_progress(text: str | None) -> str`
+- `_truncate_key(text: str) -> str`
+- `_truncate_value(val: T) -> T`
+- `_truncate_content(text: str | None, type: Type) -> str`
+- Notable constants/configuration names: `_MARK_DIRTY_ALL`, `_MARK_DIRTY_FOR_CONTEXT`, `T`, `HEADING_MAX_LEN`, `CONTENT_MAX_LEN`, `RESPONSE_CONTENT_MAX_LEN`, `KEY_MAX_LEN`, `VALUE_MAX_LEN`, `PROGRESS_MAX_LEN`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem writes, filesystem deletion, settings/state persistence, secret handling, scheduler state.
+- Imported dependency areas include: `collections`, `copy`, `dataclasses`, `helpers.secrets`, `helpers.strings`, `json`, `threading`, `time`, `typing`, `uuid`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `TypeVar`, `dataclass`, `_MARK_DIRTY_ALL`, `_MARK_DIRTY_FOR_CONTEXT`, `truncate_text_by_ratio`, `cast`, `threading.RLock`, `self.set_initial_progress`, `self._update_item`, `self._notify_state_monitor`, `_lazy_mark_dirty_all`, `_lazy_mark_dirty_for_context`, `self._mask_recursive`, `_truncate_progress`, `self.set_progress`, `LogOutput`, `_truncate_value`, `json.dumps`, `time.time`, `self.log._update_item`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+- Keyword KVP updates use the same recursive key/value truncation as an explicit `kvps` mapping, preventing streamed reasoning and other live fields from bypassing payload limits.
+- Existing log-item and progress updates mark only the selected context stream dirty and explicitly omit unchanged context/task collections; creating a new log item still broadcasts a full collection refresh.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_browser_agent_regressions.py`
+ - `tests/test_chat_compaction.py`
+ - `tests/test_error_retry_plugin.py`
+ - `tests/test_fasta2a_client.py`
+ - `tests/test_file_tree_visualize.py`
+ - `tests/test_history_compression_wait.py`
+ - `tests/test_http_auth_csrf.py`
+ - `tests/test_mcp_handler_multimodal.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/login.py b/helpers/login.py
index f33dcb318eb9b075f5fb557c93731a10d49070f2..4c0828731c58f4a1154693d411e2c235e29995c9 100644
--- a/helpers/login.py
+++ b/helpers/login.py
@@ -1,3 +1,15 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:ba5b0285306d55c51da3297634d216185d5f595bf3d27a15bbcb525b51e5ccfa
-size 408
+from helpers import dotenv
+import hashlib
+
+
+def get_credentials_hash():
+ user = dotenv.get_dotenv_value(dotenv.KEY_AUTH_LOGIN)
+ password = dotenv.get_dotenv_value(dotenv.KEY_AUTH_PASSWORD)
+ if not user:
+ return None
+ return hashlib.sha256(f"{user}:{password}".encode()).hexdigest()
+
+
+def is_login_required():
+ user = dotenv.get_dotenv_value(dotenv.KEY_AUTH_LOGIN)
+ return bool(user)
diff --git a/helpers/login.py.dox.md b/helpers/login.py.dox.md
index d1abf6ba3f51acb413600df15846a50766959ec5..3448951980fa8a628e4a27943ea76e353f6292ea 100644
--- a/helpers/login.py.dox.md
+++ b/helpers/login.py.dox.md
@@ -1,3 +1,49 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:37b69b80abd3f77bfb31e640ae1e94720d3592592c264b4175f8a3dcb6e50c39
-size 1989
+# login.py DOX
+
+## Purpose
+
+- Own the `login.py` helper module.
+- This module checks login requirements and credential hashes.
+- Keep this file-level DOX profile synchronized with `login.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `login.py` owns the runtime implementation.
+- `login.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Top-level functions:
+- `get_credentials_hash()`
+- `is_login_required()`
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: secret handling.
+- Imported dependency areas include: `hashlib`, `helpers`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `dotenv.get_dotenv_value`, `hashlib.sha256.hexdigest`, `hashlib.sha256`, `encode`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_http_auth_csrf.py`
+ - `tests/test_oauth_gemini_api.py`
+ - `tests/test_oauth_github_copilot.py`
+ - `tests/test_oauth_providers.py`
+ - `tests/test_oauth_xai_grok.py`
+ - `tests/test_tunnel_remote_link.py`
+ - `tests/test_ws_security.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/mcp_handler.py b/helpers/mcp_handler.py
index 73b897a20fab0b1a6b4ad7f10e8ca08cb6f9b821..64565ed1ee03552fa8b8d169a825cab4fb050853 100644
--- a/helpers/mcp_handler.py
+++ b/helpers/mcp_handler.py
@@ -1,3 +1,1672 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:597c3c91770fa67a0125b31a7bb80dc882db1895b0ae9b7aec2fbef07748ac39
-size 65282
+from abc import ABC, abstractmethod
+import re
+from typing import (
+ List,
+ Dict,
+ Optional,
+ Any,
+ TextIO,
+ Union,
+ Literal,
+ Annotated,
+ ClassVar,
+ cast,
+ Callable,
+ Awaitable,
+ TypeVar,
+)
+import threading
+import asyncio
+from contextlib import AsyncExitStack
+from shutil import which
+from datetime import timedelta
+import json
+import shlex
+import uuid
+from helpers import errors
+from helpers import settings
+from helpers.log import LogItem
+
+import httpx
+
+from mcp import ClientSession, StdioServerParameters
+from mcp.client.stdio import stdio_client
+from mcp.client.sse import sse_client
+from mcp.client.streamable_http import streamablehttp_client
+from mcp.shared.message import SessionMessage
+from mcp.types import CallToolResult, ListToolsResult
+from anyio.streams.memory import (
+ MemoryObjectReceiveStream,
+ MemoryObjectSendStream,
+)
+
+from pydantic import BaseModel, Field, Discriminator, Tag, PrivateAttr
+from helpers import dirty_json, media_artifacts
+from helpers.print_style import PrintStyle
+from helpers.tool import Tool, Response
+from helpers.defer import DeferredTask
+from helpers.responses_tools import original_tool_name
+
+
+MCP_MEDIA_TOKENS_ESTIMATE = 1500
+MAX_MCP_RESOURCE_TEXT_CHARS = 12_000
+MCP_SESSION_CLEANUP_TIMEOUT_SECONDS = 5.0
+MCP_OPERATION_TIMEOUT_GRACE_SECONDS = MCP_SESSION_CLEANUP_TIMEOUT_SECONDS + 2.0
+DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}'
+
+
+def _mcp_get(item: Any, key: str, default: Any = None) -> Any:
+ if isinstance(item, dict):
+ return item.get(key, default)
+ return getattr(item, key, default)
+
+
+def normalize_name(name: str) -> str:
+ # Lowercase and strip whitespace
+ name = name.strip().lower()
+ # Replace all non-alphanumeric (unicode) chars with underscore
+ # \W matches non-alphanumeric, but also matches underscore, so use [^\w] with re.UNICODE
+ # To also replace underscores from non-latin chars, use [^a-zA-Z0-9] with re.UNICODE
+ name = re.sub(r"[^\w]", "_", name, flags=re.UNICODE)
+ return name
+
+
+def _determine_server_type(config_dict: dict) -> str:
+ """Determine the server type based on configuration, with backward compatibility."""
+ # First check if type is explicitly specified
+ if "type" in config_dict:
+ server_type = config_dict["type"].lower()
+ if server_type in ["sse", "http-stream", "streaming-http", "streamable-http", "http-streaming"]:
+ return "MCPServerRemote"
+ elif server_type == "stdio":
+ return "MCPServerLocal"
+ # For future types, we could add more cases here
+ else:
+ # For unknown types, fall back to URL-based detection
+ # This allows for graceful handling of new types
+ pass
+
+ # Backward compatibility: if no type specified, use URL-based detection
+ if "url" in config_dict or "serverUrl" in config_dict:
+ return "MCPServerRemote"
+ else:
+ return "MCPServerLocal"
+
+
+def _is_streaming_http_type(server_type: str) -> bool:
+ """Check if the server type is a streaming HTTP variant."""
+ return server_type.lower() in ["http-stream", "streaming-http", "streamable-http", "http-streaming"]
+
+
+def _split_qualified_tool_name(tool_name: str) -> tuple[str, str]:
+ """Split Agent Zero's server.tool MCP name while preserving dots in MCP tool names."""
+ if "." not in tool_name:
+ raise ValueError(f"Tool {tool_name} not found")
+ server_name_part, tool_name_part = tool_name.split(".", 1)
+ if not server_name_part or not tool_name_part:
+ raise ValueError(f"Tool {tool_name} not found")
+ return server_name_part, tool_name_part
+
+
+def _normalize_disabled_tools(value: Any) -> list[str]:
+ if not isinstance(value, list):
+ return []
+ return [str(item).strip() for item in value if str(item).strip()]
+
+
+def _split_stdio_command(command: Any) -> tuple[str, list[str]]:
+ text = str(command or "").strip()
+ if not text:
+ return "", []
+ try:
+ parts = shlex.split(text)
+ except ValueError:
+ return text, []
+ if not parts:
+ return "", []
+ return parts[0], parts[1:]
+
+
+def _split_stdio_arg_fragment(arg: str) -> list[str]:
+ try:
+ parts = shlex.split(arg)
+ except ValueError:
+ return [arg]
+ if len(parts) <= 1:
+ return parts or []
+ if parts[0].startswith("-") and "=" not in parts[0]:
+ return parts
+ if any(part.startswith("-") for part in parts[1:]):
+ return parts
+ return [arg]
+
+
+def _normalize_stdio_args(value: Any) -> list[str]:
+ if not isinstance(value, list):
+ return []
+ args: list[str] = []
+ for item in value:
+ text = str(item).strip()
+ if text:
+ args.extend(_split_stdio_arg_fragment(text))
+ return args
+
+
+def initialize_mcp(mcp_servers_config: str):
+ if not MCPConfig.get_instance().is_initialized():
+ try:
+ MCPConfig.update(mcp_servers_config)
+ except Exception as e:
+ from agent import AgentContext
+
+ AgentContext.log_to_all(
+ type="warning",
+ content=f"Failed to update MCP settings: {e}",
+ )
+
+ PrintStyle(
+ background_color="black", font_color="red", padding=True
+ ).print(f"Failed to update MCP settings: {e}")
+
+
+class MCPTool(Tool):
+ """MCP Tool wrapper"""
+
+ def get_log_object(self) -> LogItem:
+ return self.agent.context.log.log(
+ type="mcp",
+ heading=f"icon://extension {self.agent.agent_name}: Using MCP tool '{self.name}'",
+ content="",
+ kvps={"tool_name": self.name, **self.args},
+ id=str(uuid.uuid4()),
+ )
+
+ def _context_id(self) -> str:
+ return str(getattr(getattr(self.agent, "context", None), "id", "") or "").strip()
+
+ def _raw_tool_response(self, response: Response) -> str:
+ raw_tool_response = response.message.strip() if response.message else ""
+ if not raw_tool_response:
+ PrintStyle(font_color="red").print(
+ f"Warning: Tool '{self.name}' returned an empty message."
+ )
+ raw_tool_response = "[Tool returned no textual content]"
+ return raw_tool_response
+
+ def _coerce_media_token_estimate(self, value: object) -> int:
+ try:
+ estimate = int(value or 0)
+ except (TypeError, ValueError):
+ estimate = 0
+ return estimate if estimate > 0 else MCP_MEDIA_TOKENS_ESTIMATE
+
+ def _format_image_content(
+ self,
+ *,
+ encoded: str,
+ mime_type: str,
+ label: str,
+ index: int,
+ preferred_name: str = "",
+ ) -> tuple[str, dict[str, Any] | None, str]:
+ try:
+ safe_mime = media_artifacts.normalize_mime(
+ mime_type,
+ default="image/png",
+ required_prefix="image/",
+ )
+ artifact = media_artifacts.save_base64_artifact(
+ encoded,
+ mime_type=safe_mime,
+ directory_parts=self._artifact_directory_parts(),
+ preferred_name=preferred_name,
+ default_filename=self._default_artifact_filename(
+ label=label,
+ index=index,
+ mime_type=safe_mime,
+ ),
+ )
+ except media_artifacts.EmptyBase64Data:
+ return f"MCP returned an empty {label} attachment.", None, ""
+ except media_artifacts.InvalidBase64Data:
+ return (
+ f"MCP returned a {label} attachment that could not be decoded.",
+ None,
+ "",
+ )
+
+ return (
+ (
+ f"Saved MCP {label} attachment "
+ f"({artifact.mime}, {artifact.size} bytes) to {artifact.path}."
+ ),
+ {
+ "type": "image_url",
+ "image_url": {"url": artifact.path},
+ },
+ artifact.path,
+ )
+
+ def _materialize_binary_content(
+ self,
+ *,
+ encoded: str,
+ mime_type: str,
+ label: str,
+ index: int,
+ preferred_name: str = "",
+ ) -> str:
+ try:
+ safe_mime = media_artifacts.normalize_mime(mime_type)
+ artifact = media_artifacts.save_base64_artifact(
+ encoded,
+ mime_type=safe_mime,
+ directory_parts=self._artifact_directory_parts(),
+ preferred_name=preferred_name,
+ default_filename=self._default_artifact_filename(
+ label=label,
+ index=index,
+ mime_type=safe_mime,
+ ),
+ )
+ except media_artifacts.EmptyBase64Data:
+ return f"MCP returned an empty {label} attachment."
+ except media_artifacts.InvalidBase64Data:
+ return f"MCP returned a {label} attachment that could not be decoded."
+
+ return f"Saved MCP {label} attachment ({artifact.mime}, {artifact.size} bytes) to {artifact.path}."
+
+ def _artifact_directory_parts(self) -> tuple[str, ...]:
+ context_id = normalize_name(self._context_id() or "shared") or "shared"
+ tool_name = normalize_name(self.name or "mcp_tool") or "mcp_tool"
+ return ("tmp", "mcp", context_id, tool_name)
+
+ def _default_artifact_filename(self, *, label: str, index: int, mime_type: str) -> str:
+ tool_name = normalize_name(self.name or "mcp_tool") or "mcp_tool"
+ ext = media_artifacts.guess_extension(mime_type, ".bin")
+ return f"{tool_name}_{label}_{index}{ext}"
+
+ def _format_resource_text(self, text: str, uri: str = "") -> str:
+ body = str(text or "").strip()
+ if not body:
+ return ""
+ if len(body) > MAX_MCP_RESOURCE_TEXT_CHARS:
+ body = body[:MAX_MCP_RESOURCE_TEXT_CHARS].rstrip() + "\n...[truncated]"
+ if uri:
+ return f"Resource {uri}:\n{body}"
+ return body
+
+ def _content_item_dump(self, item: Any) -> dict[str, Any]:
+ if isinstance(item, dict):
+ return dict(item)
+ model_dump = getattr(item, "model_dump", None)
+ if callable(model_dump):
+ dumped = model_dump(mode="python")
+ if isinstance(dumped, dict):
+ return dumped
+ item_vars = getattr(item, "__dict__", None)
+ if isinstance(item_vars, dict):
+ return dict(item_vars)
+ return {}
+
+ def _summarize_unknown_item(self, item: Any, item_type: str) -> str:
+ dumped = self._content_item_dump(item)
+ if dumped:
+ dumped.pop("data", None)
+ resource = dumped.get("resource")
+ if isinstance(resource, dict):
+ resource.pop("blob", None)
+ summary = json.dumps(dumped, ensure_ascii=False)
+ if len(summary) > 600:
+ summary = summary[:600] + "...[truncated]"
+ return f"MCP returned unsupported content item type '{item_type}': {summary}"
+ return f"MCP returned unsupported content item type '{item_type}'."
+
+ def _format_tool_result(
+ self, response: CallToolResult
+ ) -> tuple[str, dict[str, Any] | None]:
+ text_parts: list[str] = []
+ notes: list[str] = []
+ raw_images: list[dict[str, Any]] = []
+ image_paths: list[str] = []
+ content_items = list(getattr(response, "content", []) or [])
+
+ for index, item in enumerate(content_items, start=1):
+ item_type = str(_mcp_get(item, "type", "") or "").strip().lower()
+
+ if item_type == "text":
+ text = str(_mcp_get(item, "text", "") or "").strip()
+ if text:
+ text_parts.append(text)
+ continue
+
+ if item_type == "image":
+ note, raw_content, path = self._format_image_content(
+ encoded=str(_mcp_get(item, "data", "") or ""),
+ mime_type=str(_mcp_get(item, "mimeType", "") or "image/png"),
+ label="image",
+ index=index,
+ )
+ notes.append(note)
+ if raw_content:
+ raw_images.append(raw_content)
+ if path:
+ image_paths.append(path)
+ continue
+
+ if item_type == "audio":
+ note = self._materialize_binary_content(
+ encoded=str(_mcp_get(item, "data", "") or ""),
+ mime_type=str(_mcp_get(item, "mimeType", "") or "audio/wav"),
+ label="audio",
+ index=index,
+ )
+ notes.append(note)
+ continue
+
+ if item_type == "resource":
+ resource = _mcp_get(item, "resource", None)
+ uri = str(_mcp_get(resource, "uri", "") or "").strip()
+ text = _mcp_get(resource, "text", None)
+ if isinstance(text, str) and text.strip():
+ text_parts.append(self._format_resource_text(text, uri))
+ continue
+
+ blob = str(_mcp_get(resource, "blob", "") or "").strip()
+ if blob:
+ mime_type = str(
+ _mcp_get(resource, "mimeType", "") or "application/octet-stream"
+ ).strip().lower()
+ if mime_type.startswith("image/"):
+ note, raw_content, path = self._format_image_content(
+ encoded=blob,
+ mime_type=mime_type,
+ label="resource image",
+ index=index,
+ preferred_name=uri,
+ )
+ else:
+ note = self._materialize_binary_content(
+ encoded=blob,
+ mime_type=mime_type,
+ label="resource",
+ index=index,
+ preferred_name=uri,
+ )
+ raw_content = None
+ path = ""
+ notes.append(note)
+ if raw_content:
+ raw_images.append(raw_content)
+ if path:
+ image_paths.append(path)
+ continue
+
+ if uri:
+ mime_type = str(_mcp_get(resource, "mimeType", "") or "").strip()
+ details = f" ({mime_type})" if mime_type else ""
+ notes.append(f"MCP returned a resource reference: {uri}{details}.")
+ continue
+
+ notes.append("MCP returned a resource item without text or binary data.")
+ continue
+
+ if item_type:
+ notes.append(self._summarize_unknown_item(item, item_type))
+ continue
+
+ notes.append(self._summarize_unknown_item(item, "unknown"))
+
+ message = "\n\n".join(part for part in [*text_parts, *notes] if part.strip())
+ if not message and content_items:
+ message = "MCP tool returned content that could not be rendered as text."
+
+ additional = None
+ if raw_images:
+ additional = {
+ "raw_content": raw_images,
+ "preview": f"",
+ "_tokens": MCP_MEDIA_TOKENS_ESTIMATE * len(raw_images),
+ "attachments": image_paths,
+ "media_paths": image_paths,
+ }
+
+ return message, additional
+
+ async def execute(self, **kwargs: Any):
+ from helpers.tool_policy import canonical_mcp_id, ensure_tool_allowed
+
+ if "." in self.name:
+ ensure_tool_allowed(
+ self.agent,
+ self.name,
+ canonical_id=canonical_mcp_id(self.name),
+ )
+ error = ""
+ additional: dict[str, Any] | None = None
+ try:
+ response: CallToolResult = await MCPConfig.get_for_agent(self.agent).call_tool(
+ self.name, kwargs
+ )
+ message, additional = self._format_tool_result(response)
+ if response.isError:
+ error = message or "MCP tool returned an error without textual content."
+ except Exception as e:
+ error = f"MCP Tool Exception: {str(e)}"
+ message = f"ERROR: {str(e)}"
+
+ if error:
+ PrintStyle(
+ background_color="#CC34C3", font_color="white", bold=True, padding=True
+ ).print(f"MCPTool::Failed to call mcp tool {self.name}:")
+ PrintStyle(
+ background_color="#AA4455", font_color="white", padding=False
+ ).print(error)
+
+ self.agent.context.log.log(
+ type="warning",
+ content=f"{self.name}: {error}",
+ )
+
+ return Response(message=message, break_loop=False, additional=additional)
+
+ async def before_execution(self, **kwargs: Any):
+ (
+ PrintStyle(
+ font_color="#1B4F72", padding=True, background_color="white", bold=True
+ ).print(f"{self.agent.agent_name}: Using tool '{self.name}'")
+ )
+ self.log = self.get_log_object()
+
+ for key, value in self.args.items():
+ PrintStyle(font_color="#85C1E9", bold=True).stream(
+ self.nice_key(key) + ": "
+ )
+ PrintStyle(
+ font_color="#85C1E9", padding=isinstance(value, str) and "\n" in value
+ ).stream(value)
+ PrintStyle().print()
+
+ async def after_execution(self, response: Response, **kwargs: Any):
+ final_text_for_agent = self._raw_tool_response(response)
+ additional = dict(response.additional or {})
+ raw_content = additional.pop("raw_content", None)
+ preview = str(additional.pop("preview", "") or "").strip()
+ token_estimate = self._coerce_media_token_estimate(additional.pop("_tokens", 0))
+
+ self.agent.hist_add_tool_result(
+ self.name,
+ final_text_for_agent,
+ id=self.log.id if self.log else "",
+ **additional,
+ )
+ if raw_content:
+ from helpers import history
+
+ self.agent.hist_add_message(
+ False,
+ content=history.RawMessage(
+ raw_content=raw_content,
+ preview=preview or final_text_for_agent,
+ ),
+ tokens=token_estimate,
+ )
+ (
+ PrintStyle(
+ font_color="#1B4F72", background_color="white", padding=True, bold=True
+ ).print(
+ f"{self.agent.agent_name}: Response from tool '{self.name}' (plus context added)"
+ )
+ )
+ # Print only the raw response to console for brevity, agent gets the full context.
+ PrintStyle(font_color="#85C1E9").print(
+ final_text_for_agent
+ if final_text_for_agent
+ else "[No direct textual output from tool]"
+ )
+ if self.log:
+ self.log.update(
+ content=final_text_for_agent
+ ) # Log includes the full context
+
+
+class MCPServerRemote(BaseModel):
+ name: str = Field(default_factory=str)
+ description: Optional[str] = Field(default="Remote SSE Server")
+ type: str = Field(default="sse", description="Server connection type")
+ url: str = Field(default_factory=str)
+ headers: dict[str, Any] | None = Field(default_factory=dict[str, Any])
+ init_timeout: int = Field(default=0)
+ tool_timeout: int = Field(default=0)
+ verify: bool = Field(default=True, description="Verify SSL certificates")
+ disabled: bool = Field(default=False)
+ disabled_tools: list[str] = Field(default_factory=list)
+ scope: str = Field(default="global")
+
+ __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
+ __client: Optional["MCPClientRemote"] = PrivateAttr(default=None)
+
+ def __init__(self, config: dict[str, Any]):
+ super().__init__()
+ self.__client = MCPClientRemote(self)
+ self.update(config)
+
+ def get_error(self) -> str:
+ with self.__lock:
+ return self.__client.error # type: ignore
+
+ def get_log(self) -> str:
+ with self.__lock:
+ return self.__client.get_log() # type: ignore
+
+ def get_tools(self) -> List[dict[str, Any]]:
+ """Get enabled tools from the server"""
+ with self.__lock:
+ tools = self.__client.get_tools() # type: ignore
+ disabled = set(self.disabled_tools)
+ return [tool for tool in tools if tool.get("name") not in disabled]
+
+ def get_all_tools(self) -> List[dict[str, Any]]:
+ """Get all tools from the server and mark disabled tools for UI detail views."""
+ with self.__lock:
+ tools = self.__client.get_tools() # type: ignore
+ disabled = set(self.disabled_tools)
+ return [
+ {**tool, "disabled": tool.get("name") in disabled}
+ for tool in tools
+ ]
+
+ def has_tool(self, tool_name: str) -> bool:
+ """Check if a tool is available"""
+ if tool_name in self.disabled_tools:
+ return False
+ with self.__lock:
+ return self.__client.has_tool(tool_name) # type: ignore
+
+ async def call_tool(
+ self, tool_name: str, input_data: Dict[str, Any]
+ ) -> CallToolResult:
+ """Call a tool with the given input data"""
+ client = self.__client
+ if client is None:
+ raise RuntimeError("MCP remote client is not initialized")
+ if tool_name in self.disabled_tools:
+ raise ValueError(f"Tool {tool_name} is disabled for server {self.name}.")
+ return await client.call_tool(tool_name, input_data)
+
+ def update(self, config: dict[str, Any]) -> "MCPServerRemote":
+ with self.__lock:
+ for key, value in config.items():
+ if key in [
+ "name",
+ "description",
+ "type",
+ "url",
+ "serverUrl",
+ "headers",
+ "init_timeout",
+ "tool_timeout",
+ "disabled",
+ "disabled_tools",
+ "verify",
+ "scope",
+ ]:
+ if key == "name":
+ value = normalize_name(value)
+ if key == "serverUrl":
+ key = "url" # remap serverUrl to url
+ if key == "disabled_tools":
+ value = _normalize_disabled_tools(value)
+
+ setattr(self, key, value)
+ return self
+
+ async def initialize(self) -> "MCPServerRemote":
+ await self.__client.update_tools() # type: ignore
+ return self
+
+
+class MCPServerLocal(BaseModel):
+ name: str = Field(default_factory=str)
+ description: Optional[str] = Field(default="Local StdIO Server")
+ type: str = Field(default="stdio", description="Server connection type")
+ command: str = Field(default_factory=str)
+ args: list[str] = Field(default_factory=list)
+ env: dict[str, str] | None = Field(default_factory=dict[str, str])
+ encoding: str = Field(default="utf-8")
+ encoding_error_handler: Literal["strict", "ignore", "replace"] = Field(
+ default="strict"
+ )
+ init_timeout: int = Field(default=0)
+ tool_timeout: int = Field(default=0)
+ verify: bool = Field(default=True, description="Verify SSL certificates")
+ disabled: bool = Field(default=False)
+ disabled_tools: list[str] = Field(default_factory=list)
+ scope: str = Field(default="global")
+
+ __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
+ __client: Optional["MCPClientLocal"] = PrivateAttr(default=None)
+
+ def __init__(self, config: dict[str, Any]):
+ super().__init__()
+ self.__client = MCPClientLocal(self)
+ self.update(config)
+
+ def get_error(self) -> str:
+ with self.__lock:
+ return self.__client.error # type: ignore
+
+ def get_log(self) -> str:
+ with self.__lock:
+ return self.__client.get_log() # type: ignore
+
+ def get_tools(self) -> List[dict[str, Any]]:
+ """Get enabled tools from the server"""
+ with self.__lock:
+ tools = self.__client.get_tools() # type: ignore
+ disabled = set(self.disabled_tools)
+ return [tool for tool in tools if tool.get("name") not in disabled]
+
+ def get_all_tools(self) -> List[dict[str, Any]]:
+ """Get all tools from the server and mark disabled tools for UI detail views."""
+ with self.__lock:
+ tools = self.__client.get_tools() # type: ignore
+ disabled = set(self.disabled_tools)
+ return [
+ {**tool, "disabled": tool.get("name") in disabled}
+ for tool in tools
+ ]
+
+ def has_tool(self, tool_name: str) -> bool:
+ """Check if a tool is available"""
+ if tool_name in self.disabled_tools:
+ return False
+ with self.__lock:
+ return self.__client.has_tool(tool_name) # type: ignore
+
+ async def call_tool(
+ self, tool_name: str, input_data: Dict[str, Any]
+ ) -> CallToolResult:
+ """Call a tool with the given input data"""
+ client = self.__client
+ if client is None:
+ raise RuntimeError("MCP local client is not initialized")
+ if tool_name in self.disabled_tools:
+ raise ValueError(f"Tool {tool_name} is disabled for server {self.name}.")
+ return await client.call_tool(tool_name, input_data)
+
+ def update(self, config: dict[str, Any]) -> "MCPServerLocal":
+ with self.__lock:
+ command = self.command
+ command_args: list[str] = []
+ args = list(self.args)
+ if "command" in config:
+ command, command_args = _split_stdio_command(config.get("command"))
+ args = [*command_args, *args]
+ if "args" in config:
+ args = [*command_args, *_normalize_stdio_args(config.get("args"))]
+
+ for key, value in config.items():
+ if key in [
+ "name",
+ "description",
+ "type",
+ "command",
+ "args",
+ "env",
+ "encoding",
+ "encoding_error_handler",
+ "init_timeout",
+ "tool_timeout",
+ "disabled",
+ "disabled_tools",
+ "scope",
+ ]:
+ if key in ["command", "args"]:
+ continue
+ if key == "name":
+ value = normalize_name(value)
+ if key == "disabled_tools":
+ value = _normalize_disabled_tools(value)
+ setattr(self, key, value)
+ self.command = command
+ self.args = args
+ return self
+
+ async def initialize(self) -> "MCPServerLocal":
+ await self.__client.update_tools() # type: ignore
+ return self
+
+
+MCPServer = Annotated[
+ Union[
+ Annotated[MCPServerRemote, Tag("MCPServerRemote")],
+ Annotated[MCPServerLocal, Tag("MCPServerLocal")],
+ ],
+ Discriminator(_determine_server_type),
+]
+
+
+class MCPConfig(BaseModel):
+ servers: list[MCPServer] = Field(default_factory=list)
+ disconnected_servers: list[dict[str, Any]] = Field(default_factory=list)
+ config_scope: str = Field(default="global")
+ __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
+ __instance: ClassVar[Any] = PrivateAttr(default=None)
+ __initialized: ClassVar[bool] = PrivateAttr(default=False)
+ __project_instances: ClassVar[dict[str, tuple[str, "MCPConfig"]]] = {}
+
+ @classmethod
+ def get_instance(cls) -> "MCPConfig":
+ with cls.__lock:
+ if cls.__instance is None:
+ cls.__instance = cls(servers_list=[], config_scope="global")
+ return cls.__instance
+
+ @classmethod
+ def clear_project_instances(cls):
+ with cls.__lock:
+ cls.__project_instances = {}
+
+ @classmethod
+ def parse_config_string(cls, config_str: str) -> List[Dict[str, Any]]:
+ servers_data: List[Dict[str, Any]] = []
+
+ if not (config_str and config_str.strip()):
+ return servers_data
+
+ try:
+ parsed_value = dirty_json.try_parse(config_str)
+ normalized = cls.normalize_config(parsed_value)
+
+ if isinstance(normalized, list):
+ for item in normalized:
+ if isinstance(item, dict):
+ servers_data.append(dict(item))
+ else:
+ PrintStyle(
+ background_color="yellow",
+ font_color="black",
+ padding=True,
+ ).print(
+ f"Warning: MCP config item was not a dictionary and was ignored: {item}"
+ )
+ else:
+ PrintStyle(
+ background_color="red", font_color="white", padding=True
+ ).print(
+ f"Error: Parsed MCP config top-level structure is not a list. Config string was: '{config_str}'"
+ )
+ except Exception as e_json:
+ PrintStyle.error(
+ f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'"
+ )
+
+ return servers_data
+
+ @classmethod
+ def merge_config_strings(
+ cls, global_config: str, project_config: str
+ ) -> tuple[list[dict[str, Any]], str]:
+ merged: dict[str, dict[str, Any]] = {}
+ unnamed: list[dict[str, Any]] = []
+
+ def add_servers(config_str: str, scope: str):
+ for server in cls.parse_config_string(config_str):
+ server_copy = dict(server)
+ server_copy["scope"] = scope
+ name = str(server_copy.get("name", "") or "").strip()
+ if not name:
+ unnamed.append(server_copy)
+ continue
+ normalized_name = normalize_name(name)
+ server_copy["name"] = normalized_name
+ merged[normalized_name] = server_copy
+
+ add_servers(global_config or DEFAULT_MCP_SERVERS_CONFIG, "global")
+ add_servers(project_config or DEFAULT_MCP_SERVERS_CONFIG, "project")
+
+ servers = [*unnamed, *merged.values()]
+ cache_key = dirty_json.stringify(
+ {
+ "mcpServers": {
+ s.get("name", f"unnamed_{i}"): s
+ for i, s in enumerate(servers)
+ }
+ }
+ )
+ return servers, cache_key
+
+ @classmethod
+ def get_project_instance(cls, project_name: str | None, *, force: bool = False) -> "MCPConfig":
+ project_key = str(project_name or "").strip()
+ if not project_key:
+ return cls.get_instance()
+
+ from helpers import projects
+ project_key = projects.validate_project_name(project_key)
+
+ global_config = settings.get_settings().get(
+ "mcp_servers", DEFAULT_MCP_SERVERS_CONFIG
+ )
+ project_config = projects.load_project_mcp_servers(project_key)
+ servers_data, cache_key = cls.merge_config_strings(global_config, project_config)
+
+ with cls.__lock:
+ cached = cls.__project_instances.get(project_key)
+ if cached and cached[0] == cache_key and not force:
+ return cached[1]
+
+ instance = cls(servers_list=servers_data, config_scope=f"project:{project_key}")
+ with cls.__lock:
+ cls.__project_instances[project_key] = (cache_key, instance)
+ return instance
+
+ @classmethod
+ def refresh_project(cls, project_name: str) -> "MCPConfig":
+ project_key = str(project_name or "").strip()
+ with cls.__lock:
+ cls.__project_instances.pop(project_key, None)
+ return cls.get_project_instance(project_key, force=True)
+
+ @classmethod
+ def get_for_agent(cls, agent: Any) -> "MCPConfig":
+ try:
+ from helpers import projects
+
+ project_name = projects.get_context_project_name(agent.context)
+ if project_name:
+ return cls.get_project_instance(project_name)
+ except Exception:
+ pass
+ return cls.get_instance()
+
+ @classmethod
+ def wait_for_lock(cls):
+ with cls.__lock:
+ return
+
+ @classmethod
+ def update(cls, config_str: str) -> Any:
+ servers_data = cls.parse_config_string(config_str)
+ new_instance = cls(servers_list=servers_data, config_scope="global")
+ with cls.__lock:
+ # Build and initialize outside the class lock so a slow or wedged MCP
+ # server cannot freeze status reads, prompts, or later tool calls.
+ instance = cls.__instance
+ if instance is None:
+ instance = new_instance
+ cls.__instance = instance
+ else:
+ instance.servers = new_instance.servers
+ instance.disconnected_servers = new_instance.disconnected_servers
+ instance.config_scope = new_instance.config_scope
+ cls.__project_instances = {}
+ cls.__initialized = True
+ return instance
+
+ @classmethod
+ def normalize_config(cls, servers: Any):
+ normalized = []
+ if isinstance(servers, list):
+ for server in servers:
+ if isinstance(server, dict):
+ normalized.append(dict(server))
+ elif isinstance(servers, dict):
+ if "mcpServers" in servers:
+ if isinstance(servers["mcpServers"], dict):
+ for key, value in servers["mcpServers"].items():
+ if isinstance(value, dict):
+ server = dict(value)
+ server["name"] = key
+ normalized.append(server)
+ elif isinstance(servers["mcpServers"], list):
+ for server in servers["mcpServers"]:
+ if isinstance(server, dict):
+ normalized.append(dict(server))
+ else:
+ normalized.append(dict(servers)) # single server?
+ return normalized
+
+ def __init__(self, servers_list: List[Dict[str, Any]], config_scope: str = "global"):
+ from collections.abc import Mapping, Iterable
+
+ # # DEBUG: Print the received servers_list
+ # if servers_list:
+ # PrintStyle(background_color="blue", font_color="white", padding=True).print(
+ # f"MCPConfig.__init__ received servers_list: {servers_list}"
+ # )
+
+ # This empties the servers list if MCPConfig is a Pydantic model and servers is a field.
+ # If servers is a field like `servers: List[MCPServer] = Field(default_factory=list)`,
+ # then super().__init__() might try to initialize it.
+ # We are re-assigning self.servers later in this __init__.
+ super().__init__()
+
+ # Clear any servers potentially initialized by super().__init__() before we populate based on servers_list
+ self.servers = []
+ self.config_scope = config_scope
+ # initialize failed servers list
+ self.disconnected_servers = []
+
+ if not isinstance(servers_list, Iterable):
+ (
+ PrintStyle(
+ background_color="grey", font_color="red", padding=True
+ ).print("MCPConfig::__init__::servers_list must be a list")
+ )
+ return
+
+ for server_item in servers_list:
+ if not isinstance(server_item, Mapping):
+ # log the error
+ error_msg = "server_item must be a mapping"
+ (
+ PrintStyle(
+ background_color="grey", font_color="red", padding=True
+ ).print(f"MCPConfig::__init__::{error_msg}")
+ )
+ # add to failed servers with generic name
+ self.disconnected_servers.append(
+ {
+ "config": (
+ server_item
+ if isinstance(server_item, dict)
+ else {"raw": str(server_item)}
+ ),
+ "error": error_msg,
+ "name": "invalid_server_config",
+ }
+ )
+ continue
+
+ server_item = dict(server_item)
+ server_item["disabled_tools"] = _normalize_disabled_tools(
+ server_item.get("disabled_tools")
+ )
+
+ if server_item.get("disabled", False):
+ # get server name if available
+ server_name = server_item.get("name", "unnamed_server")
+ # normalize server name if it exists
+ if server_name != "unnamed_server":
+ server_name = normalize_name(server_name)
+
+ # add to failed servers
+ self.disconnected_servers.append(
+ {
+ "config": server_item,
+ "error": "Disabled in config",
+ "name": server_name,
+ }
+ )
+ continue
+
+ server_name = server_item.get("name", "__not__found__")
+ if server_name == "__not__found__":
+ # log the error
+ error_msg = "server_name is required"
+ (
+ PrintStyle(
+ background_color="grey", font_color="red", padding=True
+ ).print(f"MCPConfig::__init__::{error_msg}")
+ )
+ # add to failed servers
+ self.disconnected_servers.append(
+ {
+ "config": server_item,
+ "error": error_msg,
+ "name": "unnamed_server",
+ }
+ )
+ continue
+
+ try:
+ # not generic MCPServer because: "Annotated can not be instatioated"
+ if server_item.get("url", None) or server_item.get("serverUrl", None):
+ self.servers.append(MCPServerRemote(server_item))
+ else:
+ self.servers.append(MCPServerLocal(server_item))
+ except Exception as e:
+ # log the error
+ error_msg = str(e)
+ (
+ PrintStyle(
+ background_color="grey", font_color="red", padding=True
+ ).print(
+ f"MCPConfig::__init__: Failed to create MCPServer '{server_name}': {error_msg}"
+ )
+ )
+ # add to failed servers
+ self.disconnected_servers.append(
+ {"config": server_item, "error": error_msg, "name": server_name}
+ )
+
+ # Initialize all servers in parallel (fetch tools concurrently)
+ if self.servers:
+ async def _init_server(server):
+ try:
+ await server.initialize()
+ except Exception as e:
+ error_msg = str(e)
+ PrintStyle(
+ background_color="grey", font_color="red", padding=True
+ ).print(
+ f"MCPConfig::__init__: Failed to initialize MCPServer '{server.name}': {error_msg}"
+ )
+
+ async def _init_all():
+ await asyncio.gather(*[_init_server(s) for s in self.servers])
+
+ asyncio.run(_init_all())
+
+ def get_server_log(self, server_name: str) -> str:
+ with self.__lock:
+ for server in self.servers:
+ if server.name == server_name:
+ return server.get_log() # type: ignore
+ return ""
+
+ def get_servers_status(self) -> list[dict[str, Any]]:
+ """Get status of all servers"""
+ result = []
+ with self.__lock:
+ # add connected/working servers
+ for server in self.servers:
+ # get server name
+ name = server.name
+ # get tool count
+ tool_count = len(server.get_tools())
+ # get error message if any
+ error = server.get_error()
+ # A server object can exist while its initialization failed.
+ connected = not bool(error)
+ # get log bool
+ has_log = server.get_log() != ""
+
+ # add server status to result
+ result.append(
+ {
+ "name": name,
+ "scope": getattr(server, "scope", self.config_scope),
+ "type": getattr(server, "type", ""),
+ "description": getattr(server, "description", ""),
+ "connected": connected,
+ "error": error,
+ "tool_count": tool_count,
+ "has_log": has_log,
+ }
+ )
+
+ # add failed servers
+ for disconnected in self.disconnected_servers:
+ result.append(
+ {
+ "name": disconnected["name"],
+ "scope": disconnected.get("config", {}).get("scope", self.config_scope),
+ "type": disconnected.get("config", {}).get("type", ""),
+ "description": disconnected.get("config", {}).get("description", ""),
+ "connected": False,
+ "error": disconnected["error"],
+ "tool_count": 0,
+ "has_log": False,
+ }
+ )
+
+ return result
+
+ def get_server_detail(self, server_name: str) -> dict[str, Any]:
+ with self.__lock:
+ for server in self.servers:
+ if server.name == server_name:
+ try:
+ get_all_tools = getattr(server, "get_all_tools", None)
+ tools = get_all_tools() if callable(get_all_tools) else server.get_tools()
+ except Exception:
+ tools = []
+ return {
+ "name": server.name,
+ "description": server.description,
+ "scope": getattr(server, "scope", self.config_scope),
+ "type": getattr(server, "type", ""),
+ "tools": tools,
+ }
+ return {}
+
+ def is_initialized(self) -> bool:
+ """Check if the client is initialized"""
+ with self.__lock:
+ return self.__initialized
+
+ def get_tools(self) -> List[dict[str, dict[str, Any]]]:
+ """Get all tools from all servers"""
+ with self.__lock:
+ tools = []
+ for server in self.servers:
+ for tool in server.get_tools():
+ tool_copy = tool.copy()
+ tool_copy["server"] = server.name
+ tools.append({f"{server.name}.{tool['name']}": tool_copy})
+ return tools
+
+ def get_tools_prompt(self, server_name: str = "", agent: Any | None = None) -> str:
+ """Get a prompt for all tools"""
+
+ # just to wait for pending initialization
+ with self.__lock:
+ pass
+
+ prompt = '## "Remote (MCP Server) Agent Tools" available:\n\n'
+ server_names = []
+ for server in self.servers:
+ if not server_name or server.name == server_name:
+ server_names.append(server.name)
+
+ if server_name and server_name not in server_names:
+ raise ValueError(f"Server {server_name} not found")
+
+ for server in self.servers:
+ if server.name in server_names:
+ server_name = server.name
+ prompt += f"### {server_name}\n"
+ prompt += f"{server.description}\n"
+ tools = server.get_tools()
+
+ for tool in tools:
+ qualified_name = f"{server_name}.{tool['name']}"
+ if agent is not None:
+ from helpers.tool_policy import canonical_mcp_id, resolve_tool
+
+ if not resolve_tool(
+ agent,
+ qualified_name,
+ canonical_id=canonical_mcp_id(qualified_name),
+ ).allowed:
+ continue
+ prompt += (
+ f"\n### {qualified_name}:\n"
+ f"{tool['description']}\n\n"
+ # f"#### Categories:\n"
+ # f"* kind: MCP Server Tool\n"
+ # f'* server: "{server_name}" ({server.description})\n\n'
+ # f"#### Arguments:\n"
+ )
+
+ input_schema = (
+ json.dumps(tool["input_schema"]) if tool["input_schema"] else ""
+ )
+
+ prompt += f"#### Input schema for tool_args:\n{input_schema}\n"
+
+ prompt += "\n"
+
+ prompt += (
+ f"#### Usage:\n"
+ f"{{\n"
+ # f' "observations": ["..."],\n' # TODO: this should be a prompt file with placeholders
+ f' "thoughts": ["..."],\n'
+ # f' "reflection": ["..."],\n' # TODO: this should be a prompt file with placeholders
+ f" \"tool_name\": \"{qualified_name}\",\n"
+ f' "tool_args": !follow schema above\n'
+ f"}}\n"
+ )
+
+ return prompt
+
+ def has_tool(self, tool_name: str) -> bool:
+ """Check if a tool is available"""
+ try:
+ server_name_part, tool_name_part = _split_qualified_tool_name(tool_name)
+ except ValueError:
+ return False
+ with self.__lock:
+ for server in self.servers:
+ if server.name == server_name_part:
+ return server.has_tool(tool_name_part)
+ return False
+
+ def get_tool(self, agent: Any, tool_name: str) -> MCPTool | None:
+ effective_config = MCPConfig.get_for_agent(agent)
+ if effective_config is not self:
+ return effective_config.get_tool(agent, tool_name)
+ if not self.has_tool(tool_name):
+ get_data = getattr(agent, "get_data", None)
+ name_map_key = getattr(agent, "DATA_NAME_RESPONSES_TOOL_NAME_MAP", "")
+ tool_name = original_tool_name(
+ tool_name,
+ get_data(name_map_key)
+ if name_map_key and callable(get_data)
+ else None,
+ )
+ if not self.has_tool(tool_name):
+ return None
+ return MCPTool(agent=agent, name=tool_name, method=None, args={}, message="", loop_data=None)
+
+ async def call_tool(
+ self, tool_name: str, input_data: Dict[str, Any]
+ ) -> CallToolResult:
+ """Call a tool with the given input data"""
+ server_name_part, tool_name_part = _split_qualified_tool_name(tool_name)
+ matched_server = None
+ with self.__lock:
+ for server in self.servers:
+ if server.name == server_name_part and server.has_tool(tool_name_part):
+ matched_server = server
+ break
+ if matched_server is None:
+ raise ValueError(f"Tool {tool_name} not found")
+ return await matched_server.call_tool(tool_name_part, input_data)
+
+
+T = TypeVar("T")
+
+
+class MCPClientBase(ABC):
+ # server: Union[MCPServerLocal, MCPServerRemote] # Defined in __init__
+ # tools: List[dict[str, Any]] # Defined in __init__
+ # No self.session, self.exit_stack, self.stdio, self.write as persistent instance fields
+
+ __lock: ClassVar[threading.Lock] = threading.Lock()
+
+ def __init__(self, server: Union[MCPServerLocal, MCPServerRemote]):
+ self.server = server
+ self.tools: List[dict[str, Any]] = [] # Tools are cached on the client instance
+ self.error: str = ""
+ self.log: List[str] = []
+ self.log_file: Optional[TextIO] = None
+
+ def _operation_timeout_seconds(self, read_timeout_seconds: float) -> float:
+ try:
+ seconds = float(read_timeout_seconds)
+ except (TypeError, ValueError):
+ seconds = 60.0
+ if seconds <= 0:
+ seconds = 60.0
+ return seconds + MCP_OPERATION_TIMEOUT_GRACE_SECONDS
+
+ def _operation_thread_name(self, operation_name: str) -> str:
+ server_name = normalize_name(str(getattr(self.server, "name", "") or "server"))
+ return f"MCPClient-{server_name[:32] or 'server'}-{operation_name}-{uuid.uuid4().hex[:8]}"
+
+ async def _run_isolated_operation(
+ self,
+ operation_name: str,
+ operation: Callable[[], Awaitable[T]],
+ timeout_seconds: float,
+ ) -> T:
+ worker = DeferredTask(thread_name=self._operation_thread_name(operation_name))
+ timed_out = False
+ try:
+ return await asyncio.wait_for(
+ worker.execute_inside(operation),
+ timeout=timeout_seconds,
+ )
+ except asyncio.TimeoutError as exc:
+ timed_out = True
+ message = (
+ f"MCPClientBase ({self.server.name} - {operation_name}): "
+ f"operation did not finish within {timeout_seconds:.1f}s; "
+ "abandoning the isolated worker so Agent Zero can continue."
+ )
+ PrintStyle.warning(message)
+ with self.__lock:
+ self.error = message
+ raise TimeoutError(message) from exc
+ finally:
+ if timed_out:
+ worker.kill(terminate_thread=False)
+ else:
+ worker.kill(terminate_thread=True)
+
+ # Protected method
+ @abstractmethod
+ async def _create_stdio_transport(
+ self, current_exit_stack: AsyncExitStack
+ ) -> tuple[
+ MemoryObjectReceiveStream[SessionMessage | Exception],
+ MemoryObjectSendStream[SessionMessage],
+ ]:
+ """Create stdio/write streams using the provided exit_stack."""
+ ...
+
+ async def _execute_with_session(
+ self,
+ coro_func: Callable[[ClientSession], Awaitable[T]],
+ read_timeout_seconds=60,
+ ) -> T:
+ """
+ Manages the lifecycle of an MCP session for a single operation.
+ Creates a temporary session, executes coro_func with it, and ensures cleanup.
+ """
+ operation_name = coro_func.__name__ # For logging
+ # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Creating new session for operation '{operation_name}'...")
+ original_exception = None
+ result: T | None = None
+ has_result = False
+ temp_stack = AsyncExitStack()
+ try:
+ stdio, write = await self._create_stdio_transport(temp_stack)
+ # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name} - {operation_name}): Transport created. Initializing session...")
+ session = await temp_stack.enter_async_context(
+ ClientSession(
+ stdio, # type: ignore
+ write, # type: ignore
+ read_timeout_seconds=timedelta(
+ seconds=read_timeout_seconds
+ ),
+ )
+ )
+ await session.initialize()
+
+ result = await coro_func(session)
+ has_result = True
+ except Exception as e:
+ excs = getattr(e, "exceptions", None) # Python 3.11+ ExceptionGroup
+ if excs:
+ original_exception = excs[0]
+ else:
+ original_exception = e
+ try:
+ await asyncio.wait_for(
+ temp_stack.aclose(),
+ timeout=MCP_SESSION_CLEANUP_TIMEOUT_SECONDS,
+ )
+ except asyncio.TimeoutError:
+ PrintStyle.warning(
+ f"MCPClientBase ({self.server.name} - {operation_name}): "
+ f"session cleanup exceeded {MCP_SESSION_CLEANUP_TIMEOUT_SECONDS:.1f}s."
+ )
+ except Exception as cleanup_exception:
+ PrintStyle.warning(
+ f"MCPClientBase ({self.server.name} - {operation_name}): "
+ f"session cleanup failed: {type(cleanup_exception).__name__}: {cleanup_exception}"
+ )
+ if original_exception is not None:
+ PrintStyle(
+ background_color="#AA4455", font_color="white", padding=False
+ ).print(
+ f"MCPClientBase ({self.server.name} - {operation_name}): Error during operation: {type(original_exception).__name__}: {original_exception}"
+ )
+ raise original_exception
+ if has_result:
+ return cast(T, result)
+ raise RuntimeError(
+ f"MCPClientBase ({self.server.name} - {operation_name}): _execute_with_session exited 'async with' block unexpectedly."
+ )
+
+ async def update_tools(self) -> "MCPClientBase":
+ # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Starting 'update_tools' operation...")
+
+ async def list_tools_op(current_session: ClientSession):
+ response: ListToolsResult = await current_session.list_tools()
+ with self.__lock:
+ self.tools = [
+ {
+ "name": tool.name,
+ "description": tool.description,
+ "input_schema": tool.inputSchema,
+ }
+ for tool in response.tools
+ ]
+ self.error = ""
+ PrintStyle(font_color="green").print(
+ f"MCPClientBase ({self.server.name}): Tools updated. Found {len(self.tools)} tools."
+ )
+
+ try:
+ current_settings = settings.get_settings()
+ init_timeout = (
+ self.server.init_timeout
+ or current_settings.get("mcp_client_init_timeout", 10)
+ or 10
+ )
+ await self._run_isolated_operation(
+ "update_tools",
+ lambda: self._execute_with_session(
+ list_tools_op,
+ read_timeout_seconds=init_timeout,
+ ),
+ timeout_seconds=self._operation_timeout_seconds(init_timeout),
+ )
+ except Exception as e:
+ # e = eg.exceptions[0]
+ error_text = errors.format_error(e, 0, 0)
+ # Error already logged by _execute_with_session, this is for specific handling if needed
+ PrintStyle(
+ background_color="#CC34C3", font_color="white", bold=True, padding=True
+ ).print(
+ f"MCPClientBase ({self.server.name}): 'update_tools' operation failed: {error_text}"
+ )
+ with self.__lock:
+ self.tools = [] # Ensure tools are cleared on failure
+ self.error = f"Failed to initialize. {error_text[:200]}{'...' if len(error_text) > 200 else ''}" # store error from tools fetch
+ return self
+
+ def has_tool(self, tool_name: str) -> bool:
+ """Check if a tool is available (uses cached tools)"""
+ with self.__lock:
+ for tool in self.tools:
+ if tool["name"] == tool_name:
+ return True
+ return False
+
+ def get_tools(self) -> List[dict[str, Any]]:
+ """Get all tools from the server (uses cached tools)"""
+ with self.__lock:
+ return [dict(tool) for tool in self.tools]
+
+ async def call_tool(
+ self, tool_name: str, input_data: Dict[str, Any]
+ ) -> CallToolResult:
+ # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Preparing for 'call_tool' operation for tool '{tool_name}'.")
+ if not self.has_tool(tool_name):
+ PrintStyle(font_color="orange").print(
+ f"MCPClientBase ({self.server.name}): Tool '{tool_name}' not in cache for 'call_tool', refreshing tools..."
+ )
+ await self.update_tools() # This will use its own properly managed session
+ if not self.has_tool(tool_name):
+ PrintStyle(font_color="red").print(
+ f"MCPClientBase ({self.server.name}): Tool '{tool_name}' not found after refresh. Raising ValueError."
+ )
+ raise ValueError(
+ f"Tool {tool_name} not found after refreshing tool list for server {self.server.name}."
+ )
+ PrintStyle(font_color="green").print(
+ f"MCPClientBase ({self.server.name}): Tool '{tool_name}' found after updating tools."
+ )
+
+ current_settings = settings.get_settings()
+ tool_timeout = (
+ self.server.tool_timeout
+ or current_settings.get("mcp_client_tool_timeout", 120)
+ or 120
+ )
+
+ async def call_tool_op(current_session: ClientSession):
+ # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Executing 'call_tool' for '{tool_name}' via MCP session...")
+ response: CallToolResult = await current_session.call_tool(
+ tool_name,
+ input_data,
+ read_timeout_seconds=timedelta(seconds=tool_timeout),
+ )
+ # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' call successful via session.")
+ return response
+
+ try:
+ response = await self._run_isolated_operation(
+ "call_tool",
+ lambda: self._execute_with_session(
+ call_tool_op,
+ read_timeout_seconds=tool_timeout,
+ ),
+ timeout_seconds=self._operation_timeout_seconds(tool_timeout),
+ )
+ with self.__lock:
+ self.error = ""
+ return response
+ except Exception as e:
+ # Error logged by _execute_with_session. Re-raise a specific error for the caller.
+ PrintStyle(
+ background_color="#AA4455", font_color="white", padding=True
+ ).print(
+ f"MCPClientBase ({self.server.name}): 'call_tool' operation for '{tool_name}' failed: {type(e).__name__}: {e}"
+ )
+ raise ConnectionError(
+ f"MCPClientBase::Failed to call tool '{tool_name}' on server '{self.server.name}'. Original error: {type(e).__name__}: {e}"
+ )
+
+ def get_log(self):
+ # read and return lines from self.log_file, do not close it
+ if not hasattr(self, "log_file") or self.log_file is None:
+ return ""
+ self.log_file.seek(0)
+ try:
+ log = self.log_file.read()
+ except Exception:
+ log = ""
+ return log
+
+
+class MCPClientLocal(MCPClientBase):
+ def __del__(self):
+ # close the log file if it exists
+ if hasattr(self, "log_file") and self.log_file is not None:
+ try:
+ self.log_file.close()
+ except Exception:
+ pass
+ self.log_file = None
+
+ async def _create_stdio_transport(
+ self, current_exit_stack: AsyncExitStack
+ ) -> tuple[
+ MemoryObjectReceiveStream[SessionMessage | Exception],
+ MemoryObjectSendStream[SessionMessage],
+ ]:
+ """Connect to an MCP server, init client and save stdio/write streams"""
+ server: MCPServerLocal = cast(MCPServerLocal, self.server)
+
+ if not server.command:
+ raise ValueError("Command not specified")
+ if not which(server.command):
+ raise ValueError(f"Command '{server.command}' not found")
+
+ server_params = StdioServerParameters(
+ command=server.command,
+ args=server.args,
+ env=server.env,
+ encoding=server.encoding,
+ encoding_error_handler=server.encoding_error_handler,
+ )
+ # create a custom error log handler that will capture error output
+ import tempfile
+
+ # use a temporary file for error logging (text mode) if not already present
+ if not hasattr(self, "log_file") or self.log_file is None:
+ self.log_file = tempfile.TemporaryFile(mode="w+", encoding="utf-8")
+
+ # use the stdio_client with our error log file
+ stdio_transport = await current_exit_stack.enter_async_context(
+ stdio_client(server_params, errlog=self.log_file)
+ )
+ # do not read or close the file here, as stdio is async
+ return stdio_transport
+
+class CustomHTTPClientFactory(ABC):
+ def __init__(self, verify: bool = True):
+ self.verify = verify
+
+ def __call__(
+ self,
+ headers: dict[str, str] | None = None,
+ timeout: httpx.Timeout | None = None,
+ auth: httpx.Auth | None = None,
+ ) -> httpx.AsyncClient:
+ # Set MCP defaults
+ kwargs: dict[str, Any] = {
+ "follow_redirects": True,
+ }
+
+ # Handle timeout
+ if timeout is None:
+ kwargs["timeout"] = httpx.Timeout(30.0)
+ else:
+ kwargs["timeout"] = timeout
+
+ # Handle headers
+ if headers is not None:
+ kwargs["headers"] = headers
+
+ # Handle authentication
+ if auth is not None:
+ kwargs["auth"] = auth
+
+ return httpx.AsyncClient(**kwargs, verify=self.verify)
+
+class MCPClientRemote(MCPClientBase):
+
+ def __init__(self, server: Union[MCPServerLocal, MCPServerRemote]):
+ super().__init__(server)
+ self.session_id: Optional[str] = None # Track session ID for streaming HTTP clients
+ self.session_id_callback: Optional[Callable[[], Optional[str]]] = None
+
+ async def _create_stdio_transport(
+ self, current_exit_stack: AsyncExitStack
+ ) -> tuple[
+ MemoryObjectReceiveStream[SessionMessage | Exception],
+ MemoryObjectSendStream[SessionMessage],
+ ]:
+ """Connect to an MCP server, init client and save stdio/write streams"""
+ server: MCPServerRemote = cast(MCPServerRemote, self.server)
+ current_settings = settings.get_settings()
+
+ # Resolve timeout: check server config first, then settings, defaulting to 5s/10s
+ init_timeout = (
+ server.init_timeout
+ or current_settings.get("mcp_client_init_timeout", 10)
+ or 10
+ )
+ tool_timeout = (
+ server.tool_timeout
+ or current_settings.get("mcp_client_tool_timeout", 120)
+ or 120
+ )
+
+ client_factory = CustomHTTPClientFactory(verify=server.verify)
+ # Check if this is a streaming HTTP type
+ if _is_streaming_http_type(server.type):
+ # Use streamable HTTP client
+ transport_result = await current_exit_stack.enter_async_context(
+ streamablehttp_client(
+ url=server.url,
+ headers=server.headers,
+ timeout=timedelta(seconds=init_timeout),
+ sse_read_timeout=timedelta(seconds=tool_timeout),
+ httpx_client_factory=client_factory,
+ )
+ )
+ # streamablehttp_client returns (read_stream, write_stream, get_session_id_callback)
+ read_stream, write_stream, get_session_id_callback = transport_result
+
+ # Store session ID callback for potential future use
+ self.session_id_callback = get_session_id_callback
+
+ return read_stream, write_stream
+ else:
+ # Use traditional SSE client (default behavior)
+ stdio_transport = await current_exit_stack.enter_async_context(
+ sse_client(
+ url=server.url,
+ headers=server.headers,
+ timeout=init_timeout,
+ sse_read_timeout=tool_timeout,
+ httpx_client_factory=client_factory,
+ )
+ )
+ return stdio_transport
+
+ def get_session_id(self) -> Optional[str]:
+ """Get the current session ID if available (for streaming HTTP clients)."""
+ if self.session_id_callback is not None:
+ return self.session_id_callback()
+ return None
diff --git a/helpers/mcp_handler.py.dox.md b/helpers/mcp_handler.py.dox.md
index 472418ee5ebfb60eaf85a1be1e43c5117157c241..d5240fb250eff6655779a33c57a3b749f0bad26d 100644
--- a/helpers/mcp_handler.py.dox.md
+++ b/helpers/mcp_handler.py.dox.md
@@ -1,3 +1,120 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:46d20090c177f94a8f383fa08b61fc474195cc9db8338ac7893c863c900f6bfc
-size 9058
+# mcp_handler.py DOX
+
+## Purpose
+
+- Own the `mcp_handler.py` helper module.
+- This module loads global and project-scoped MCP server configuration and exposes MCP tools to agents.
+- Keep this file-level DOX profile synchronized with `mcp_handler.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `mcp_handler.py` owns the runtime implementation.
+- `mcp_handler.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `MCPTool` (`Tool`)
+ - `get_log_object(self) -> LogItem`
+ - `async execute(self, **kwargs)`
+ - `async before_execution(self, **kwargs)`
+ - `async after_execution(self, response: Response, **kwargs)`
+- `MCPServerRemote` (`BaseModel`)
+ - `get_error(self) -> str`
+ - `get_log(self) -> str`
+ - `get_tools(self) -> List[dict[str, Any]]`
+ - `get_all_tools(self) -> List[dict[str, Any]]`
+ - `has_tool(self, tool_name: str) -> bool`
+ - `async call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult`
+ - `update(self, config: dict[str, Any]) -> 'MCPServerRemote'`
+ - `async initialize(self) -> 'MCPServerRemote'`
+- `MCPServerLocal` (`BaseModel`)
+ - `get_error(self) -> str`
+ - `get_log(self) -> str`
+ - `get_tools(self) -> List[dict[str, Any]]`
+ - `get_all_tools(self) -> List[dict[str, Any]]`
+ - `has_tool(self, tool_name: str) -> bool`
+ - `async call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult`
+ - `update(self, config: dict[str, Any]) -> 'MCPServerLocal'`
+ - `async initialize(self) -> 'MCPServerLocal'`
+- `MCPConfig` (`BaseModel`)
+ - `get_instance(cls) -> 'MCPConfig'`
+ - `clear_project_instances(cls)`
+ - `parse_config_string(cls, config_str: str) -> List[Dict[str, Any]]`
+ - `merge_config_strings(cls, global_config: str, project_config: str) -> tuple[List[Dict[str, Any]], str]`
+ - `get_project_instance(cls, project_name: str | None, *, force: bool = False) -> 'MCPConfig'`
+ - `refresh_project(cls, project_name: str) -> 'MCPConfig'`
+ - `get_for_agent(cls, agent: Any) -> 'MCPConfig'`
+ - `wait_for_lock(cls)`
+ - `update(cls, config_str: str) -> Any`
+ - `normalize_config(cls, servers: Any)`
+ - `get_server_log(self, server_name: str) -> str`
+ - `get_servers_status(self) -> list[dict[str, Any]]`
+ - `get_server_detail(self, server_name: str) -> dict[str, Any]`
+ - `is_initialized(self) -> bool`
+- `MCPClientBase` (`ABC`)
+ - `async update_tools(self) -> 'MCPClientBase'`
+ - `has_tool(self, tool_name: str) -> bool`
+ - `get_tools(self) -> List[dict[str, Any]]`
+ - `async call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult`
+ - `get_log(self)`
+- `MCPClientLocal` (`MCPClientBase`)
+- `CustomHTTPClientFactory` (`ABC`)
+- `MCPClientRemote` (`MCPClientBase`)
+ - `get_session_id(self) -> Optional[str]`
+- Top-level functions:
+- `_mcp_get(item: Any, key: str, default: Any=...) -> Any`
+- `normalize_name(name: str) -> str`
+- `_determine_server_type(config_dict: dict) -> str`: Determine the server type based on configuration, with backward compatibility.
+- `_is_streaming_http_type(server_type: str) -> bool`: Check if the server type is a streaming HTTP variant.
+- `_split_qualified_tool_name(tool_name: str) -> tuple[str, str]`: Split `server.tool` names while preserving dots inside MCP tool names.
+- `_normalize_disabled_tools(value: Any) -> list[str]`: Normalize the optional per-server disabled tool list.
+- `_split_stdio_command(command: Any) -> tuple[str, list[str]]`: Split shell-style local MCP command lines into an executable plus leading arguments.
+- `_split_stdio_arg_fragment(arg: str) -> list[str]`: Split collapsed option/value argument fragments while preserving obvious single values with spaces.
+- `_normalize_stdio_args(value: Any) -> list[str]`: Normalize local MCP argument lists after manager/raw JSON parsing.
+- `initialize_mcp(mcp_servers_config: str)`
+- Notable constants/configuration names: `DEFAULT_MCP_SERVERS_CONFIG`, `MCP_MEDIA_TOKENS_ESTIMATE`, `MAX_MCP_RESOURCE_TEXT_CHARS`, `MCP_SESSION_CLEANUP_TIMEOUT_SECONDS`, `MCP_OPERATION_TIMEOUT_GRACE_SECONDS`, `T`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- `MCPTool` is a `Tool`.
+- `MCPTool` defines `execute(...)`.
+- Global MCP configuration remains backed by settings; project MCP configuration is loaded through `helpers.projects` and merged with global config when an active agent context has `context.project`.
+- Project-scoped MCP servers overlay global servers by normalized name. The resulting `MCPConfig` cache key is derived from both config strings so project instances refresh when either scope changes.
+- Server status and detail responses include `scope`, and MCP tools resolve through `MCPConfig.get_for_agent(agent)` before execution.
+- MCP tool names are qualified as `server_name.tool_name`; server names are normalized without dots, and the tool portion may contain dots.
+- Agent-facing MCP prompt descriptions filter through the central profile tool
+ policy, and `MCPTool.execute()` rechecks the same policy with the explicit MCP
+ canonical ID before invocation.
+- `MCPConfig.get_tool()` tries the supplied qualified name first, then restores an advertised Responses alias from the calling agent's name map; names that still do not identify an MCP tool return `None` unchanged for downstream local-tool resolution.
+- Servers may define `disabled_tools` as a list of MCP tool names. Disabled tools are omitted from agent-facing prompts, status counts, `has_tool`, and calls, while detail views can still retrieve them through `get_all_tools()` with a `disabled` flag so users can re-enable them.
+- Server-specific `init_timeout` and `tool_timeout` override global MCP client timeout settings for list-tools and call-tool operations.
+- Local stdio server configs accept either strict MCP JSON (`command: "uvx", args: [...]`) or manager-style command lines (`command: "uvx package"`) and normalize them before spawning the process.
+- MCP image and image-resource content is materialized to scoped artifact files and returned both as model-visible image attachments and as path metadata (`attachments`/`media_paths`) for downstream delivery.
+- MCP config locks must not be held across awaited server initialization or tool-call operations. Slow or wedged MCP servers must not block status reads, prompt construction, unrelated MCP servers, or later tool calls through the shared config lock.
+- MCP client session work runs inside disposable isolated `DeferredTask` workers with an outer timeout. Normal `AsyncExitStack` cleanup is also bounded; if cleanup or transport shutdown does not finish, the operation reports failure or warning while Agent Zero keeps control of the agent loop.
+- Server status marks initialized server objects with cached initialization errors as disconnected, even if the config object exists.
+- Observed side-effect areas: filesystem writes, network calls, WebSocket state, settings/state persistence, secret handling.
+- Imported dependency areas include: `abc`, `anyio.streams.memory`, `asyncio`, `contextlib`, `datetime`, `helpers`, `helpers.defer`, `helpers.log`, `helpers.print_style`, `helpers.tool`, `httpx`, `json`, `mcp`, `mcp.client.sse`, `mcp.client.stdio`, `mcp.client.streamable_http`, `mcp.shared.message`, `shlex`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `TypeVar`, `name.strip.lower`, `re.sub`, `Field`, `PrivateAttr`, `threading.Lock`, `DeferredTask`, `_split_qualified_tool_name`, `config_dict.lower`, `server_type.lower`, `MCPConfig.get_instance.is_initialized`, `MCPConfig.get_for_agent`, `projects.validate_project_name`, `projects.load_project_mcp_servers`, `settings.get_settings`, `self.agent.context.log.log`, `str.strip`, `media_artifacts.guess_extension`, `callable`, `self._content_item_dump`, `join`, `Response`, `self.get_log_object`, `self._raw_tool_response`, `additional.pop`, `self._coerce_media_token_estimate`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+- Keep MCP timeout and cleanup changes covered by deterministic tests that do not require real MCP servers or network credentials.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_mcp_handler_multimodal.py`
+ - `tests/test_tool_policy.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/mcp_server.py b/helpers/mcp_server.py
index 8e774b3bd7d9a3f3d98a3697ae0d9e785f407a97..94b70c1d5c6e29c53e69b974817cd92a5bf3122f 100644
--- a/helpers/mcp_server.py
+++ b/helpers/mcp_server.py
@@ -1,3 +1,489 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6b6cf74525f4a21b01ce28e2dd629346f4ffc3378d6951b4307f159b3b6c7037
-size 17091
+import os
+import asyncio
+from typing import Annotated, Literal, Union
+from urllib.parse import urlparse
+from openai import BaseModel
+from pydantic import Field
+import fastmcp
+from fastmcp import FastMCP
+import contextvars
+
+from agent import AgentContext, AgentContextType, UserMessage
+from helpers.persist_chat import remove_chat
+from initialize import initialize_agent
+from helpers.print_style import PrintStyle
+from helpers import settings, projects
+from starlette.middleware import Middleware
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.exceptions import HTTPException as StarletteHTTPException
+from starlette.types import ASGIApp, Receive, Scope, Send
+from fastmcp.server.http import create_sse_app, create_base_app, build_resource_metadata_url # type: ignore
+from starlette.routing import Mount # type: ignore
+from starlette.requests import Request
+import threading
+
+_PRINTER = PrintStyle(italic=True, font_color="green", padding=False)
+
+# Context variable to store project name from URL (per-request)
+_mcp_project_name: contextvars.ContextVar[str | None] = contextvars.ContextVar('mcp_project_name', default=None)
+
+mcp_server: FastMCP = FastMCP(
+ name="Agent Zero integrated MCP Server",
+ instructions="""
+ Connect to remote Agent Zero instance.
+ Agent Zero is a general AI assistant controlling it's linux environment.
+ Agent Zero can install software, manage files, execute commands, code, use internet, etc.
+ Agent Zero's environment is isolated unless configured otherwise.
+ """,
+)
+
+
+class ToolResponse(BaseModel):
+ status: Literal["success"] = Field(
+ description="The status of the response", default="success"
+ )
+ response: str = Field(
+ description="The response from the remote Agent Zero Instance"
+ )
+ chat_id: str = Field(description="The id of the chat this message belongs to.")
+
+
+class ToolError(BaseModel):
+ status: Literal["error"] = Field(
+ description="The status of the response", default="error"
+ )
+ error: str = Field(
+ description="The error message from the remote Agent Zero Instance"
+ )
+ chat_id: str = Field(description="The id of the chat this message belongs to.")
+
+
+SEND_MESSAGE_DESCRIPTION = """
+Send a message to the remote Agent Zero Instance.
+This tool is used to send a message to the remote Agent Zero Instance connected remotely via MCP.
+"""
+
+
+@mcp_server.tool(
+ name="send_message",
+ description=SEND_MESSAGE_DESCRIPTION,
+ tags={
+ "agent_zero",
+ "chat",
+ "remote",
+ "communication",
+ "dialogue",
+ "sse",
+ "send",
+ "message",
+ "start",
+ "new",
+ "continue",
+ },
+ annotations={
+ "remote": True,
+ "readOnlyHint": False,
+ "destructiveHint": False,
+ "idempotentHint": False,
+ "openWorldHint": False,
+ "title": SEND_MESSAGE_DESCRIPTION,
+ },
+)
+async def send_message(
+ message: Annotated[
+ str,
+ Field(
+ description="The message to send to the remote Agent Zero Instance",
+ title="message",
+ ),
+ ],
+ attachments: (
+ Annotated[
+ list[str],
+ Field(
+ description="Optional: A list of attachments (file paths or web urls) to send to the remote Agent Zero Instance with the message. Default: Empty list",
+ title="attachments",
+ ),
+ ]
+ | None
+ ) = None,
+ chat_id: (
+ Annotated[
+ str,
+ Field(
+ description="Optional: ID of the chat. Used to continue a chat. This value is returned in response to sending previous message. Default: Empty string",
+ title="chat_id",
+ ),
+ ]
+ | None
+ ) = None,
+ persistent_chat: (
+ Annotated[
+ bool,
+ Field(
+ description="Optional: Whether to use a persistent chat. If true, the chat will be saved and can be continued later. Default: False.",
+ title="persistent_chat",
+ ),
+ ]
+ | None
+ ) = None,
+) -> Annotated[
+ Union[ToolResponse, ToolError],
+ Field(
+ description="The response from the remote Agent Zero Instance", title="response"
+ ),
+]:
+ # Get project name from context variable (set in proxy __call__)
+ project_name = _mcp_project_name.get()
+
+ context: AgentContext | None = None
+ if chat_id:
+ context = AgentContext.get(chat_id)
+ if not context:
+ return ToolError(error="Chat not found", chat_id=chat_id)
+ else:
+ # If the chat is found, we use the persistent chat flag to determine
+ # whether we should save the chat or delete it afterwards
+ # If we continue a conversation, it must be persistent
+ persistent_chat = True
+
+ # Validation: if project is in URL but context has different project
+ if project_name:
+ existing_project = context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
+ if existing_project and existing_project != project_name:
+ return ToolError(
+ error=f"Chat belongs to project '{existing_project}' but URL specifies '{project_name}'",
+ chat_id=chat_id
+ )
+ else:
+ config = initialize_agent()
+ context = AgentContext(config=config, type=AgentContextType.BACKGROUND)
+
+ # Activate project if specified in URL
+ if project_name:
+ try:
+ projects.activate_project(context.id, project_name)
+ except Exception as e:
+ return ToolError(error=f"Failed to activate project: {str(e)}", chat_id="")
+
+ if not message:
+ return ToolError(
+ error="Message is required", chat_id=context.id if persistent_chat else ""
+ )
+
+ try:
+ response = await _run_chat(context, message, attachments)
+ if not persistent_chat:
+ context.reset()
+ AgentContext.remove(context.id)
+ remove_chat(context.id)
+ return ToolResponse(
+ response=response, chat_id=context.id if persistent_chat else ""
+ )
+ except Exception as e:
+ return ToolError(error=str(e), chat_id=context.id if persistent_chat else "")
+
+
+FINISH_CHAT_DESCRIPTION = """
+Finish a chat with the remote Agent Zero Instance.
+This tool is used to finish a persistent chat (send_message with persistent_chat=True) with the remote Agent Zero Instance connected remotely via MCP.
+If you want to continue the chat, use the send_message tool instead.
+Always use this tool to finish persistent chat conversations with remote Agent Zero.
+"""
+
+
+@mcp_server.tool(
+ name="finish_chat",
+ description=FINISH_CHAT_DESCRIPTION,
+ tags={
+ "agent_zero",
+ "chat",
+ "remote",
+ "communication",
+ "dialogue",
+ "sse",
+ "finish",
+ "close",
+ "end",
+ "stop",
+ },
+ annotations={
+ "remote": True,
+ "readOnlyHint": False,
+ "destructiveHint": True,
+ "idempotentHint": False,
+ "openWorldHint": False,
+ "title": FINISH_CHAT_DESCRIPTION,
+ },
+)
+async def finish_chat(
+ chat_id: Annotated[
+ str,
+ Field(
+ description="ID of the chat to be finished. This value is returned in response to sending previous message.",
+ title="chat_id",
+ ),
+ ]
+) -> Annotated[
+ Union[ToolResponse, ToolError],
+ Field(
+ description="The response from the remote Agent Zero Instance", title="response"
+ ),
+]:
+ if not chat_id:
+ return ToolError(error="Chat ID is required", chat_id="")
+
+ context = AgentContext.get(chat_id)
+ if not context:
+ return ToolError(error="Chat not found", chat_id=chat_id)
+ else:
+ context.reset()
+ AgentContext.remove(context.id)
+ remove_chat(context.id)
+ return ToolResponse(response="Chat finished", chat_id=chat_id)
+
+
+async def _run_chat(
+ context: AgentContext, message: str, attachments: list[str] | None = None
+):
+ try:
+ _PRINTER.print("MCP Chat message received")
+
+ # Attachment filenames for logging
+ attachment_filenames = []
+ if attachments:
+ for attachment in attachments:
+ if os.path.exists(attachment):
+ attachment_filenames.append(attachment)
+ else:
+ try:
+ url = urlparse(attachment)
+ if url.scheme in ["http", "https", "ftp", "ftps", "sftp"]:
+ attachment_filenames.append(attachment)
+ else:
+ _PRINTER.print(f"Skipping attachment: [{attachment}]")
+ except Exception:
+ _PRINTER.print(f"Skipping attachment: [{attachment}]")
+
+ _PRINTER.print("User message:")
+ _PRINTER.print(f"> {message}")
+ if attachment_filenames:
+ _PRINTER.print("Attachments:")
+ for filename in attachment_filenames:
+ _PRINTER.print(f"- {filename}")
+
+ task = context.communicate(
+ UserMessage(
+ message=message, system_message=[], attachments=attachment_filenames
+ )
+ )
+ result = await task.result()
+
+ # Success
+ _PRINTER.print(f"MCP Chat message completed: {result}")
+
+ return result
+
+ except Exception as e:
+ # Error
+ _PRINTER.print(f"MCP Chat message failed: {e}")
+
+ raise RuntimeError(f"MCP Chat message failed: {e}") from e
+
+
+class DynamicMcpProxy:
+ _instance: "DynamicMcpProxy | None" = None
+
+ """A dynamic proxy that allows swapping the underlying MCP applications on the fly."""
+
+ def __init__(self):
+ cfg = settings.get_settings()
+ self.token = ""
+ self.sse_app: ASGIApp | None = None
+ self.http_app: ASGIApp | None = None
+ self.http_session_manager = None
+ self.http_session_task_group = None
+ self._lock = threading.RLock() # Use RLock to avoid deadlocks
+ self.reconfigure(cfg["mcp_server_token"])
+
+ @staticmethod
+ def get_instance():
+ if DynamicMcpProxy._instance is None:
+ DynamicMcpProxy._instance = DynamicMcpProxy()
+ return DynamicMcpProxy._instance
+
+ def reconfigure(self, token: str):
+ if self.token == token:
+ return
+
+ self.token = token
+ sse_path = f"/t-{self.token}/sse"
+ http_path = f"/t-{self.token}/http"
+ message_path = f"/t-{self.token}/messages/"
+
+ # Update settings in the MCP server instance if provided
+ # Keep FastMCP settings synchronized so downstream helpers that read these
+ # values (including deprecated accessors) resolve the runtime paths.
+ fastmcp.settings.message_path = message_path
+ fastmcp.settings.sse_path = sse_path
+ fastmcp.settings.streamable_http_path = http_path
+
+ # Create new MCP apps with updated settings
+ with self._lock:
+ middleware = [Middleware(BaseHTTPMiddleware, dispatch=mcp_middleware)]
+
+ self.sse_app = create_sse_app(
+ server=mcp_server,
+ message_path=message_path,
+ sse_path=sse_path,
+ auth=mcp_server.auth,
+ debug=fastmcp.settings.debug,
+ middleware=list(middleware),
+ )
+
+ self.http_app = self._create_custom_http_app(
+ http_path,
+ middleware=list(middleware),
+ )
+
+ def _create_custom_http_app(
+ self,
+ streamable_http_path: str,
+ *,
+ middleware: list[Middleware],
+ ) -> ASGIApp:
+ """Create a Streamable HTTP app with manual session manager lifecycle."""
+
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager # type: ignore
+ from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware # type: ignore
+ import anyio
+
+ server_routes = []
+ server_middleware = []
+
+ self.http_session_task_group = None
+ self.http_session_manager = StreamableHTTPSessionManager(
+ app=mcp_server._mcp_server,
+ event_store=None,
+ json_response=True,
+ stateless=False,
+ )
+
+ async def handle_streamable_http(scope, receive, send):
+ if self.http_session_task_group is None:
+ self.http_session_task_group = anyio.create_task_group()
+ await self.http_session_task_group.__aenter__()
+ if self.http_session_manager:
+ self.http_session_manager._task_group = self.http_session_task_group
+
+ if self.http_session_manager:
+ await self.http_session_manager.handle_request(scope, receive, send)
+
+ auth_provider = mcp_server.auth
+
+ if auth_provider:
+ server_routes.extend(auth_provider.get_routes(mcp_path=streamable_http_path))
+ server_middleware.extend(auth_provider.get_middleware())
+
+ resource_url = auth_provider._get_resource_url(streamable_http_path)
+ resource_metadata_url = (
+ build_resource_metadata_url(resource_url) if resource_url else None
+ )
+
+ server_routes.append(
+ Mount(
+ streamable_http_path,
+ app=RequireAuthMiddleware(
+ handle_streamable_http,
+ auth_provider.required_scopes,
+ resource_metadata_url,
+ ),
+ )
+ )
+ else:
+ server_routes.append(
+ Mount(
+ streamable_http_path,
+ app=handle_streamable_http,
+ )
+ )
+
+ additional_routes = mcp_server._get_additional_http_routes()
+ if additional_routes:
+ server_routes.extend(additional_routes)
+
+ server_middleware.extend(middleware)
+
+ return create_base_app(
+ routes=server_routes,
+ middleware=server_middleware,
+ debug=fastmcp.settings.debug,
+ )
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ """Forward the ASGI calls to the appropriate app based on the URL path"""
+ with self._lock:
+ sse_app = self.sse_app
+ http_app = self.http_app
+
+ if not sse_app or not http_app:
+ raise RuntimeError("MCP apps not initialized")
+
+ # Route based on path
+ path = scope.get("path", "")
+
+ # Check for token in path (with or without project segment)
+ # Patterns: /t-{token}/sse, /t-{token}/p-{project}/sse, etc.
+ has_token = f"/t-{self.token}/" in path or f"t-{self.token}/" in path
+
+ # Extract project from path BEFORE cleaning and set in context variable
+ project_name = None
+ if "/p-" in path:
+ try:
+ parts = path.split("/p-")
+ if len(parts) > 1:
+ project_part = parts[1].split("/")[0]
+ if project_part:
+ project_name = project_part
+ _PRINTER.print(f"[MCP] Proxy extracted project from URL: {project_name}")
+ except Exception as e:
+ _PRINTER.print(f"[MCP] Failed to extract project in proxy: {e}")
+
+ # Store project in context variable (will be available in send_message)
+ _mcp_project_name.set(project_name)
+
+ # Strip project segment from path if present (e.g., /p-project_name/)
+ # This is needed because the underlying MCP apps were configured without project paths
+ cleaned_path = path
+ if "/p-" in path:
+ # Remove /p-{project}/ segment: /t-TOKEN/p-PROJECT/sse -> /t-TOKEN/sse
+ import re
+ cleaned_path = re.sub(r'/p-[^/]+/', '/', path)
+
+ # Update scope with cleaned path for the underlying app
+ modified_scope = dict(scope)
+ modified_scope['path'] = cleaned_path
+
+ if has_token and ("/sse" in path or "/messages" in path):
+ # Route to SSE app with cleaned path
+ await sse_app(modified_scope, receive, send)
+ elif has_token and "/http" in path:
+ # Route to HTTP app with cleaned path
+ await http_app(modified_scope, receive, send)
+ else:
+ raise StarletteHTTPException(
+ status_code=403, detail="MCP forbidden"
+ )
+
+
+async def mcp_middleware(request: Request, call_next):
+ """Middleware to check if MCP server is enabled."""
+ # check if MCP server is enabled
+ cfg = settings.get_settings()
+ if not cfg["mcp_server_enabled"]:
+ PrintStyle.error("[MCP] Access denied: MCP server is disabled in settings.")
+ raise StarletteHTTPException(
+ status_code=403, detail="MCP server is disabled in settings."
+ )
+
+ return await call_next(request)
diff --git a/helpers/mcp_server.py.dox.md b/helpers/mcp_server.py.dox.md
index e759ff2c209606e25117b2d0686539851c933c61..4151d3d89c84f2ed0235f5e8e3f0c5cd341bc64c 100644
--- a/helpers/mcp_server.py.dox.md
+++ b/helpers/mcp_server.py.dox.md
@@ -1,3 +1,54 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:03a24752d4f96f24f803dff628fa1a27d9e86c644704c0f2bc4ea9bf65457c38
-size 4214
+# mcp_server.py DOX
+
+## Purpose
+
+- Own the `mcp_server.py` helper module.
+- This module serves Agent Zero chats through a dynamic MCP proxy.
+- Keep this file-level DOX profile synchronized with `mcp_server.py` because this directory is intentionally flat.
+
+## Ownership
+
+- `mcp_server.py` owns the runtime implementation.
+- `mcp_server.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
+- Classes:
+- `ToolResponse` (`BaseModel`)
+- `ToolError` (`BaseModel`)
+- `DynamicMcpProxy` (no explicit base class)
+ - `get_instance()`
+ - `reconfigure(self, token: str)`
+- Top-level functions:
+- `async send_message(message: Annotated[str, Field(description='The message to send to the remote Agent Zero Instance', title='message')], attachments: Annotated[list[str], Field(description='Optional: A list of attachments (file paths or web urls) to send to the remote Agent Zero Instance with the message. Default: Empty list', title='attachments')] | None=..., chat_id: Annotated[str, Field(description='Optional: ID of the chat. Used to continue a chat. This value is returned in response to sending previous message. Default: Empty string', title='chat_id')] | None=..., persistent_chat: Annotated[bool, Field(description='Optional: Whether to use a persistent chat. If true, the chat will be saved and can be continued later. Default: False.', title='persistent_chat')] | None=...) -> Annotated[Union[ToolResponse, ToolError], Field(description='The response from the remote Agent Zero Instance', title='response')]`
+- `async finish_chat(chat_id: Annotated[str, Field(description='ID of the chat to be finished. This value is returned in response to sending previous message.', title='chat_id')]) -> Annotated[Union[ToolResponse, ToolError], Field(description='The response from the remote Agent Zero Instance', title='response')]`
+- `async _run_chat(context: AgentContext, message: str, attachments: list[str] | None=...)`
+- `async mcp_middleware(request: Request, call_next)`: Middleware to check if MCP server is enabled.
+- Notable constants/configuration names: `_PRINTER`, `SEND_MESSAGE_DESCRIPTION`, `FINISH_CHAT_DESCRIPTION`.
+
+## Runtime Contracts
+
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
+- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, network calls, settings/state persistence, secret handling, scheduler state.
+- Imported dependency areas include: `agent`, `asyncio`, `contextvars`, `fastmcp`, `fastmcp.server.http`, `helpers`, `helpers.persist_chat`, `helpers.print_style`, `initialize`, `openai`, `os`, `pydantic`, `starlette.exceptions`, `starlette.middleware`, `starlette.middleware.base`, `starlette.requests`.
+
+## Key Concepts
+
+- Important called helpers/classes observed in the source: `PrintStyle`, `contextvars.ContextVar`, `FastMCP`, `mcp_server.tool`, `Field`, `settings.get_settings`, `initialize_agent`, `AgentContext`, `ToolError`, `ToolResponse`, `context.reset`, `AgentContext.remove`, `remove_chat`, `context.communicate`, `threading.RLock`, `self.reconfigure`, `StreamableHTTPSessionManager`, `mcp_server._get_additional_http_routes`, `create_base_app`, `PrintStyle.error`.
+- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
+
+## Work Guidance
+
+- Preserve public helper APIs used by core code and plugins unless every caller is updated.
+- Keep path, auth, secret, persistence, network, and subprocess behavior explicit and bounded.
+- Prefer adding cohesive helper functions here only when behavior is reused across modules.
+
+## Verification
+
+- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
+- Related tests observed by source search:
+ - `tests/test_default_prompt_budget.py`
+ - `tests/test_fasta2a_client.py`
+ - `tests/test_ws_security.py`
+
+## Child DOX Index
+
+No child DOX files.
diff --git a/helpers/media_artifacts.py b/helpers/media_artifacts.py
index ffc565c142c5d15b94b0b575aa2dae62f34da526..cfd89673ea67be9e0a3c14ff53604bcf9111551c 100644
--- a/helpers/media_artifacts.py
+++ b/helpers/media_artifacts.py
@@ -1,3 +1,186 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:30bf28b8456e6320762170a5084dc924c4bea16ed677aa5685875d79cc2a85b8
-size 4872
+from __future__ import annotations
+
+import base64
+import binascii
+import mimetypes
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+from urllib.parse import urlparse
+
+from helpers import files
+
+
+DEFAULT_MAX_ARTIFACT_SIZE_BYTES = 25 * 1024 * 1024
+
+
+class MediaArtifactError(ValueError):
+ pass
+
+
+class EmptyBase64Data(MediaArtifactError):
+ pass
+
+
+class InvalidBase64Data(MediaArtifactError):
+ pass
+
+
+class ArtifactTooLarge(MediaArtifactError):
+ def __init__(self, size: int, limit: int):
+ super().__init__(f"artifact is too large ({size} bytes, limit {limit} bytes)")
+ self.size = size
+ self.limit = limit
+
+
+@dataclass(frozen=True)
+class Base64Payload:
+ data: str
+ payload: bytes
+ size: int
+
+
+@dataclass(frozen=True)
+class ImageDataUrl:
+ url: str
+ mime: str
+ size: int
+
+
+@dataclass(frozen=True)
+class SavedArtifact:
+ path: str
+ mime: str
+ size: int
+
+
+def compact_base64(data: str) -> str:
+ return "".join(char for char in str(data or "") if not char.isspace())
+
+
+def estimated_base64_decoded_size(data: str) -> int:
+ compact_length = len(compact_base64(data))
+ return (compact_length * 3) // 4
+
+
+def decode_base64_payload(
+ data: str,
+ *,
+ max_bytes: int | None = None,
+) -> Base64Payload:
+ compact = compact_base64(data)
+ if not compact:
+ raise EmptyBase64Data("base64 data is empty")
+
+ if max_bytes is not None:
+ estimated_size = estimated_base64_decoded_size(compact)
+ if estimated_size > max_bytes:
+ raise ArtifactTooLarge(estimated_size, max_bytes)
+
+ try:
+ payload = base64.b64decode(compact, validate=True)
+ except (binascii.Error, ValueError) as exc:
+ raise InvalidBase64Data("base64 data could not be decoded") from exc
+
+ if max_bytes is not None and len(payload) > max_bytes:
+ raise ArtifactTooLarge(len(payload), max_bytes)
+
+ return Base64Payload(data=compact, payload=payload, size=len(payload))
+
+
+def normalize_mime(
+ mime_type: str,
+ *,
+ default: str = "application/octet-stream",
+ required_prefix: str = "",
+) -> str:
+ value = str(mime_type or "").strip().lower()
+ if not value:
+ return default
+ if required_prefix and not value.startswith(required_prefix):
+ return default
+ return value
+
+
+def guess_extension(mime_type: str, fallback: str = ".bin") -> str:
+ ext = mimetypes.guess_extension(str(mime_type or "").strip().lower()) or fallback
+ return ".jpg" if ext == ".jpe" else ext
+
+
+def filename_from_uri(uri: str) -> str:
+ value = str(uri or "").strip()
+ if not value:
+ return ""
+ parsed = urlparse(value)
+ return Path(parsed.path or value).name
+
+
+def safe_filename(
+ value: str,
+ *,
+ default: str = "artifact.bin",
+ default_extension: str = ".bin",
+) -> str:
+ source = str(value or "").strip() or default
+ cleaned = "".join(
+ char if char.isalnum() or char in {"-", "_", "."} else "_"
+ for char in source
+ )
+ cleaned = cleaned.strip("._") or default
+ if "." not in cleaned:
+ ext = default_extension if default_extension.startswith(".") else f".{default_extension}"
+ cleaned += ext
+ return cleaned
+
+
+def image_data_url_from_base64(
+ data: str,
+ *,
+ mime_type: str = "image/png",
+ max_bytes: int | None = None,
+) -> ImageDataUrl:
+ payload = decode_base64_payload(data, max_bytes=max_bytes)
+ safe_mime = normalize_mime(
+ mime_type,
+ default="image/png",
+ required_prefix="image/",
+ )
+ return ImageDataUrl(
+ url=f"data:{safe_mime};base64,{payload.data}",
+ mime=safe_mime,
+ size=payload.size,
+ )
+
+
+def save_base64_artifact(
+ data: str,
+ *,
+ mime_type: str = "application/octet-stream",
+ directory_parts: tuple[str, ...],
+ preferred_name: str = "",
+ default_filename: str = "artifact.bin",
+ max_bytes: int | None = None,
+) -> SavedArtifact:
+ payload = decode_base64_payload(data, max_bytes=max_bytes)
+ safe_mime = normalize_mime(mime_type)
+ preferred_filename = filename_from_uri(preferred_name) or default_filename
+ default_extension = guess_extension(safe_mime, Path(default_filename).suffix or ".bin")
+ filename = safe_filename(
+ preferred_filename,
+ default=default_filename,
+ default_extension=default_extension,
+ )
+ filename_path = Path(filename)
+ stem = filename_path.stem or Path(default_filename).stem or "artifact"
+ suffix = filename_path.suffix or default_extension
+
+ artifact_dir = Path(files.get_abs_path(*directory_parts))
+ artifact_dir.mkdir(parents=True, exist_ok=True)
+ path = artifact_dir / f"{stem}_{uuid.uuid4().hex[:8]}{suffix}"
+ path.write_bytes(payload.payload)
+
+ return SavedArtifact(
+ path=files.normalize_a0_path(str(path)),
+ mime=safe_mime,
+ size=payload.size,
+ )