diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 105dd8ff3..5a9c3686b 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -9,13 +9,16 @@ - `workflows/` contains GitHub Actions workflow definitions. - `scripts/` contains Python helpers called by workflows. -- Root-level release rules remain in the root `AGENTS.md`; this file owns automation-specific details. +- This file owns release automation rules; user-facing release documentation belongs under `docs/`. ## Local Contracts - Docker publishing lives in `workflows/docker-publish.yml` and delegates planning to `scripts/docker_release_plan.py`. - Releasable tags are `vX.Y` tags at or above `v1.0`, matching the workflow environment. +- On `main`, the newest eligible tag publishes both the version tag and `latest`, then creates or updates its GitHub release after the image push succeeds; other allowed branches publish only their branch tag. +- Manual dispatch without a tag backfills missing Docker Hub tags. Manual dispatch with a tag rebuilds that target and refreshes `latest` and the GitHub release only when it remains the newest eligible tag on `main`. - Release-note generation reads `scripts/openrouter_release_notes_system_prompt.md` from the repository root and requires OpenRouter credentials from workflow environment variables. +- Release notes compare against the previous published GitHub release tag and fall back to `No release notes.` when no meaningful summary is generated. - Keep workflow secrets in GitHub Actions secrets or environment variables. Do not commit credentials, tokens, or generated release bodies containing private data. - Workflow scripts must fail loudly with actionable messages when required environment variables or git refs are missing. @@ -23,7 +26,7 @@ - Prefer deterministic, testable Python for workflow planning logic instead of complex inline shell in YAML. - Preserve manual dispatch behavior when changing Docker publishing. -- Keep branch, tag, and release behavior synchronized between workflow YAML, release scripts, tests, and root documentation. +- Keep branch, tag, and release behavior synchronized between workflow YAML, release scripts, tests, and user-facing release documentation. ## Verification diff --git a/AGENTS.md b/AGENTS.md index 31ab6a620..704ab7554 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,364 +1,83 @@ -# Agent Zero - AGENTS.md +# Agent Zero DOX -[Generated using reconnaissance on 2026-02-22] +## Purpose -## Quick Reference -Tech Stack: Framework Python 3.12+ | Agent execution Python 3.13 in Docker | Flask | Alpine.js | LiteLLM | WebSocket (Socket.io) -Dev Server: python run_ui.py (discover host/port from startup output or runtime configuration; do not assume a default port) -Run Tests: pytest (standard) or pytest tests/test_name.py (file-scoped) -Documentation: README.md | docs/ -Frontend & Plugin DOX: [WebUI](webui/AGENTS.md) | [Components](webui/components/AGENTS.md) | [Frontend JS](webui/js/AGENTS.md) | [Plugins](plugins/AGENTS.md) +- Own project-wide engineering rules and the top-level DOX index. +- Keep detailed contracts in the closest applicable child `AGENTS.md`. ---- +## Project -## Table of Contents -1. [Project Overview](#project-overview) -2. [Core Commands](#core-commands) -3. [Docker Environment](#docker-environment) -4. [Project Structure](#project-structure) -5. [Development Patterns & Conventions](#development-patterns--conventions) -6. [Safety and Permissions](#safety-and-permissions) -7. [Code Examples](#code-examples) -8. [Git Workflow](#git-workflow) -9. [Release Notes](#release-notes) -10. [Troubleshooting](#troubleshooting) +- Stack: Python 3.12+ framework, Python 3.13 agent execution runtime, Flask, Alpine.js, LiteLLM, and Socket.IO. +- Start the WebUI with `python run_ui.py`; discover its URL from startup output, Docker mappings, or explicit configuration rather than assuming a port. +- Run the full test suite with `pytest` or a focused file with `pytest tests/test_name.py`. +- Human-facing documentation lives in `README.md` and `docs/`. ---- +## Root Ownership -## Project Overview +- `agent.py` owns `Agent`, `AgentContext`, and loop data. +- `initialize.py` owns framework initialization. +- `models.py` owns model-provider configuration and LiteLLM integration. +- `run_ui.py` is the WebUI entry point. +- `DockerfileLocal` must remain compatible with the contracts under `docker/`. +- Runtime or user state under `usr/` and `tmp/` is intentionally outside tracked DOX unless the user explicitly asks otherwise. -Agent Zero is a dynamic, organic agentic framework designed to grow and learn. It uses the operating system as a tool, featuring a multi-agent cooperation model where every agent can create subordinates to break down tasks. +## Project-Wide Contracts -Type: Full-Stack Agentic Framework (Python Backend + Alpine.js Frontend) -Status: Active Development -Primary Language(s): Python, JavaScript (ES Modules) +- Import `AgentContext` and `AgentContextType` from `agent`, not `helpers.context`. +- Never commit secrets, `.env` files, API keys, tokens, or private user data. +- Preserve authentication and CSRF protections. +- Use Linux paths and commands in examples. +- Treat the Docker container exposed at `localhost:32080` as the live plugin/backend runtime when that target is named. +- Copy live core-plugin changes back into tracked source under `plugins/`. +- Develop new custom plugins under ignored `usr/plugins/`; tracked bundled plugins live under `plugins/`. +- Use the framework runtime for backend and plugin-hook verification, not the separate agent execution runtime. ---- +## Permissions -## Core Commands +Allowed without asking: -### Setup -Do not combine these commands; run them individually: -```bash -pip install -r requirements.txt -``` -- Start WebUI: python run_ui.py -- Discover the WebUI URL from startup output, launcher/Docker port mappings, or explicit `--host`/`--port`/`WEB_UI_PORT` configuration; do not hardcode a default port. +- Read repository files. +- Update files under `usr/`. ---- +Ask before: -## Docker Environment +- Installing dependencies. +- Deleting core files outside `usr/` or `tmp/`. +- Modifying `agent.py` or `initialize.py`. +- Creating commits or pushing branches. -When running in Docker, Agent Zero uses two distinct Python runtimes to isolate the framework itself from code executed on behalf of the agent: +## DOX Workflow -### 1. Framework Runtime (/opt/venv-a0) -- Version: Python 3.12.4 -- Purpose: Runs the Agent Zero framework itself: WebUI backend, API, core loop, scheduler, framework imports, and plugin hooks/tools that execute inside the framework process. -- Packages: Contains framework dependencies from requirements.txt. -- Verification: Use this runtime for framework/backend import checks, WebUI startup checks, and plugin hook behavior unless the code explicitly switches environments. - -### 2. Agent Execution Runtime (/opt/venv) -- Version: Python 3.13 -- Purpose: Default Python environment for the agent's terminal/code-execution tasks and user code run by the agent. -- Behavior: Packages installed by the agent during a task belong here so task dependencies do not pollute or prove the framework runtime. Do not use this runtime as evidence that framework imports, WebUI startup, or plugin hooks work. - ---- - -## Project Structure - -``` -/ -├── agent.py # Core Agent and AgentContext definitions -├── initialize.py # Framework initialization logic -├── models.py # LLM provider configurations -├── run_ui.py # WebUI server entry point -├── api/ # API Handlers (ApiHandler subclasses) + WsHandler subclasses (ws_*.py) -├── extensions/ # Backend lifecycle extensions -├── helpers/ # Shared Python utilities (plugins, files, etc.) -├── tools/ # Agent tools (Tool subclasses) -├── webui/ -│ ├── components/ # Alpine.js components -│ ├── js/ # Core frontend logic (modals, stores, etc.) -│ └── index.html # Main UI shell -├── usr/ # User data directory (isolated from core) -│ ├── plugins/ # Custom user plugins -│ ├── settings.json # User-specific configuration -│ └── workdir/ # Default agent workspace -├── plugins/ # Core system plugins -├── agents/ # Agent profiles (prompts and config) -├── prompts/ # System and message prompt templates -├── knowledge/ -│ └── main/about/ # Agent self-knowledge reference material -│ ├── identity.md # Philosophy, principles, project context -│ ├── architecture.md # Agent loop, multi-agent coordination, extensions -│ ├── capabilities.md # Detailed capabilities and limitations -│ ├── configuration.md # LLM roles, providers, profiles, plugins, settings -│ └── setup-and-deployment.md # Docker deployment, updates, troubleshooting -└── tests/ # Pytest suite -``` - -Key Files: -- agent.py: Defines AgentContext, LoopData virtual prompt areas (Protocol before history and Extras after history), and the main Agent class. -- helpers/plugins.py: Plugin discovery and configuration logic. -- webui/js/AlpineStore.js: Store factory for reactive frontend state. -- helpers/api.py: Base class for all API endpoints. -- models.py: LLM provider configuration and LiteLLM wrappers; framework LiteLLM defaults such as `drop_params=True` are merged with `litellm_global_kwargs`, configured values override framework defaults, documented module-level switches such as `drop_params` are applied to LiteLLM, and merged kwargs are passed per call. -- scripts/openrouter_release_notes_system_prompt.md: Editable system prompt used to generate GitHub release notes during Docker publishing. -- knowledge/main/about/: Agent self-knowledge files. Not user-facing docs - written for the agent's internal reference. -- webui/components/AGENTS.md: DOX contract for Alpine component architecture. -- webui/js/AGENTS.md: DOX contract for frontend infrastructure, modal stack, API helpers, and extension loading. -- plugins/AGENTS.md: DOX contract for bundled and custom plugin architecture; `usr/plugins/` remains ignored user state. - ---- - -## Development Patterns & Conventions - -### Backend (Python) -- Context Access: Use from agent import AgentContext, AgentContextType (not helpers.context). -- Communication: Use mq from helpers.messages to log proactive UI messages: - mq.log_user_message(context.id, "Message", source="Plugin") -- API Handlers: Derive from ApiHandler in helpers/api.py. -- Extensions: Use the extension framework in helpers/extension.py for lifecycle hooks. -- Error Handling: Use RepairableException for errors the LLM might be able to fix. - -### Frontend (Alpine.js) -- Store Gating: Always wrap store-dependent content in a template: -```html -
-
@@ -268,17 +263,6 @@ It is not a replacement for Git or backups. It is a practical safety layer for t
- **Client/project isolation:** keep memory, secrets, instructions, files, and model choices separated by project.
- **Scheduled operations:** run recurring checks and monitoring tasks with project-scoped context and credentials.
-## Safety Model
-
-Agent Zero is powerful because it can use a real environment.
-
-- Keep it running inside Docker or another isolated environment.
-- Do not mount your entire home directory unless you understand the risk.
-- Grant A0 CLI Read+Write access and remote code execution only for machines and workspaces you trust.
-- Store credentials in project secrets or settings, not in prompts or public files.
-- Review actions that touch accounts, money, production systems, or private data.
-- Keep backups for important workspaces.
-
## Documentation
| I want to... | Start here |
@@ -314,3 +298,16 @@ You can help by improving docs, creating skills, publishing plugins, testing mod
- [YouTube](https://www.youtube.com/@AgentZeroFW) for demos and tutorials.
- [X](https://x.com/Agent0ai), [LinkedIn](https://www.linkedin.com/company/109758317), and [Warpcast](https://warpcast.com/agent-zero) for updates.
- [GitHub Issues](https://github.com/agent0ai/agent-zero/issues) for bugs and feature requests.
+
+[Space Agent](https://github.com/agent0ai/space-agent) is the related, more polished product direction for the agent-shaped workspace. Agent Zero remains the open framework and Linux-powered workbench.
+
+## Safety Model
+
+Agent Zero is powerful because it can use a real environment.
+
+- Keep it running inside Docker or another isolated environment.
+- Do not mount your entire home directory unless you understand the risk.
+- Grant A0 CLI Read+Write access and remote code execution only for machines and workspaces you trust.
+- Store credentials in project secrets or settings, not in prompts or public files.
+- Review actions that touch accounts, money, production systems, or private data.
+- Keep backups for important workspaces.
diff --git a/api/settings_get.py.dox.md b/api/settings_get.py.dox.md
index e584283fd..46b2ccdd7 100644
--- a/api/settings_get.py.dox.md
+++ b/api/settings_get.py.dox.md
@@ -28,6 +28,7 @@
## Key Concepts
- Important called helpers/classes observed in the source: `settings.get_settings`, `settings.convert_out`.
+- The returned settings map includes normalized `ui_control_visibility` mobile and desktop flags for the configurable WebUI controls.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance
diff --git a/api/settings_set.py.dox.md b/api/settings_set.py.dox.md
index 0f08f9862..0d3e62e6c 100644
--- a/api/settings_set.py.dox.md
+++ b/api/settings_set.py.dox.md
@@ -26,6 +26,7 @@
## Key Concepts
- Important called helpers/classes observed in the source: `settings.convert_in`, `settings.set_settings`, `settings.convert_out`, `settings.Settings`.
+- The settings payload accepts normalized `ui_control_visibility` mobile and desktop flags and returns the persisted map with the rest of the settings.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance
diff --git a/conf/model_providers.yaml b/conf/model_providers.yaml
index 541a4888b..5e90efa3b 100644
--- a/conf/model_providers.yaml
+++ b/conf/model_providers.yaml
@@ -84,6 +84,7 @@ chat:
endpoint_url: "/v1/models"
default_base: "http://host.docker.internal:1234"
kwargs:
+ a0_api_mode: chat
api_base: "http://host.docker.internal:1234/v1"
api_key: "lm-studio"
llama_cpp:
@@ -93,6 +94,7 @@ chat:
endpoint_url: "/v1/models"
default_base: "http://host.docker.internal:8080"
kwargs:
+ a0_api_mode: chat
api_base: "http://host.docker.internal:8080/v1"
api_key: "llama-cpp"
mistral:
@@ -112,6 +114,11 @@ chat:
endpoint_url: "/models"
kwargs:
api_base: https://api.tokenfactory.nebius.com/v1
+ nvidia_nim:
+ name: NVIDIA NIM
+ litellm_provider: nvidia_nim
+ models_list:
+ endpoint_url: "https://integrate.api.nvidia.com/v1/models"
ollama:
name: Ollama
litellm_provider: ollama
@@ -120,6 +127,7 @@ chat:
format: "ollama"
default_base: "http://host.docker.internal:11434"
kwargs:
+ a0_api_mode: chat
api_base: "http://host.docker.internal:11434"
omlx:
name: oMLX
@@ -128,6 +136,7 @@ chat:
endpoint_url: "/v1/models"
default_base: "http://host.docker.internal:8000"
kwargs:
+ a0_api_mode: chat
api_base: "http://host.docker.internal:8000/v1"
api_key: "omlx"
ollama_cloud:
@@ -136,6 +145,7 @@ chat:
models_list:
endpoint_url: "/models"
kwargs:
+ a0_api_mode: chat
api_base: https://ollama.com/v1
openai:
name: OpenAI
@@ -173,6 +183,7 @@ chat:
models_list:
endpoint_url: "https://api.venice.ai/api/v1/models"
kwargs:
+ a0_api_mode: chat
api_base: https://api.venice.ai/api/v1
venice_parameters:
include_venice_system_prompt: false
@@ -183,6 +194,7 @@ chat:
endpoint_url: "/v1/models"
default_base: "http://host.docker.internal:8000"
kwargs:
+ a0_api_mode: chat
api_base: "http://host.docker.internal:8000/v1"
api_key: "vllm"
xai:
@@ -207,6 +219,8 @@ chat:
other:
name: Other OpenAI compatible
litellm_provider: openai
+ kwargs:
+ a0_api_mode: chat
embedding:
huggingface:
@@ -230,6 +244,11 @@ embedding:
mistral:
name: Mistral AI
litellm_provider: mistral
+ nvidia_nim:
+ name: NVIDIA NIM
+ litellm_provider: nvidia_nim
+ models_list:
+ endpoint_url: "https://integrate.api.nvidia.com/v1/models"
ollama:
name: Ollama
litellm_provider: ollama
diff --git a/docker/AGENTS.md b/docker/AGENTS.md
index 757608953..8fafcaaf5 100644
--- a/docker/AGENTS.md
+++ b/docker/AGENTS.md
@@ -13,7 +13,8 @@
## Local Contracts
-- Preserve the two-runtime model documented in the root contract: framework runtime under `/opt/venv-a0` and agent execution runtime under `/opt/venv`.
+- Preserve the two-runtime model: the Python 3.12 framework runtime under `/opt/venv-a0` runs the WebUI, APIs, scheduler, framework imports, and plugin hooks; the Python 3.13 agent execution runtime under `/opt/venv` runs agent terminal tasks and user code.
+- Verify backend imports and plugin hooks with `/opt/venv-a0`; packages installed into `/opt/venv` do not prove framework compatibility.
- Do not bake secrets, local `.env` values, or user data into images.
- Keep compose mounts aligned with `usr/`, `logs/`, and other runtime-state expectations.
- Image changes that affect GitHub publishing must stay synchronized with `.github/workflows/docker-publish.yml`.
diff --git a/docker/run/AGENTS.md b/docker/run/AGENTS.md
index 9077e5a90..bc07f5f5a 100644
--- a/docker/run/AGENTS.md
+++ b/docker/run/AGENTS.md
@@ -22,6 +22,9 @@
- Do not bake secrets, local `.env` values, or user data into the image.
- Runtime startup must ensure `/a0/usr/uploads` exists before supervised services start.
- Runtime startup raises the soft open-file limit toward `A0_NOFILE_LIMIT` (default `65535`) before supervisord starts, bounded by the container hard limit.
+- Self-update user-data backups skip Time Travel shadow history under `usr/.time_travel/` and transient Desktop agent state.
+- Self-update waits up to 180 seconds for the updated or restored WebUI health check by default; `A0_SELF_UPDATE_HEALTH_TIMEOUT_SECONDS` may override it.
+- Successful or already-current self-updates refresh an installed Codex CLI with npm on a best-effort basis; missing CLIs and registry failures must not block Agent Zero startup.
## Work Guidance
diff --git a/docker/run/fs/exe/self_update_manager.py b/docker/run/fs/exe/self_update_manager.py
index abf4e595f..c0d008831 100644
--- a/docker/run/fs/exe/self_update_manager.py
+++ b/docker/run/fs/exe/self_update_manager.py
@@ -36,7 +36,7 @@ DEFAULT_HEALTH_URL = os.environ.get(
"http://127.0.0.1:80/api/health",
)
DEFAULT_HEALTH_TIMEOUT_SECONDS = int(
- os.environ.get("A0_SELF_UPDATE_HEALTH_TIMEOUT_SECONDS", "120")
+ os.environ.get("A0_SELF_UPDATE_HEALTH_TIMEOUT_SECONDS", "180")
)
DEFAULT_HEALTH_POLL_INTERVAL_SECONDS = float(
os.environ.get("A0_SELF_UPDATE_HEALTH_POLL_INTERVAL_SECONDS", "2")
@@ -405,6 +405,11 @@ def should_exclude_from_usr_backup(
logger: AttemptLogger,
) -> bool:
parts = relative_dir.parts
+ if parts and parts[0] == ".time_travel":
+ logger.log(
+ f"Skipping Time Travel history during usr backup: {Path('usr') / relative_dir}"
+ )
+ return True
if (
len(parts) >= 6
and parts[0] == "plugins"
@@ -630,6 +635,29 @@ def clean_uv_cache(logger: AttemptLogger) -> None:
logger.log(f"uv cache clean skipped after error: {exc}")
+def refresh_codex_cli(logger: AttemptLogger) -> None:
+ codex_path = shutil.which("codex")
+ if not codex_path:
+ logger.log("Codex CLI not installed, skipping Codex refresh.")
+ return
+
+ npm_path = shutil.which("npm")
+ if not npm_path:
+ logger.log("npm executable not found, skipping Codex refresh.")
+ return
+
+ logger.log("Refreshing the installed Codex CLI after self-update.")
+ try:
+ run_command(
+ [npm_path, "install", "--global", "@openai/codex@latest"],
+ cwd=None,
+ logger=logger,
+ error_message="Failed to refresh the installed Codex CLI.",
+ )
+ except Exception as exc:
+ logger.log(f"Codex CLI refresh skipped after error: {exc}")
+
+
def has_local_rollback_changes(repo_dir: Path) -> bool:
status = git_output(repo_dir, "status", "--porcelain=v1", "--untracked-files=all")
return bool(status.strip())
@@ -1143,6 +1171,7 @@ def execute_pending_update(
logger=logger,
)
if healthy:
+ refresh_codex_cli(logger)
record_result(
status="success",
message=f"Updated Agent Zero to branch {branch}, {resolved_target['target_description']}.",
@@ -1420,6 +1449,7 @@ def docker_run_ui() -> int:
logger.log(
"Requested tag already matches the installed version, skipping file replacement."
)
+ refresh_codex_cli(logger)
record_result(
status="skipped",
message="Requested tag already matches the installed version.",
@@ -1463,8 +1493,11 @@ def main(argv: list[str] | None = None) -> int:
return docker_run_ui()
if args[0] == "trigger-update":
return trigger_update_command(args[1:])
+ if args[0] == "refresh-codex":
+ refresh_codex_cli(AttemptLogger(LOG_FILE))
+ return 0
if args[0] in {"-h", "--help"}:
- print("Usage: self_update_manager.py [docker-run-ui | trigger-update ...]")
+ print("Usage: self_update_manager.py [docker-run-ui | trigger-update ... | refresh-codex]")
return 0
print(f"Unknown command: {args[0]}", file=sys.stderr)
return 1
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index 3f3002b01..c81fb55bd 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -9,6 +9,7 @@
- `README.md`, `quickstart.md`, `guides/`, and `setup/` cover user-facing setup and workflows.
- `developer/` covers compact developer references and source handoffs.
+- `plans/` covers implementation plans, migration notes, and staged technical roadmaps.
- `res/` contains documentation images and other documentation assets.
## Local Contracts
diff --git a/docs/guides/a0-cli-connector.md b/docs/guides/a0-cli-connector.md
index 6dd4c2edc..f0812efc8 100644
--- a/docs/guides/a0-cli-connector.md
+++ b/docs/guides/a0-cli-connector.md
@@ -132,15 +132,22 @@ machine.
- [ ] Keep A0 CLI connected to the Agent Zero chat.
- [ ] In Agent Zero Web UI, open Browser plugin settings and choose **Bring Your
Own Browser**.
-- [ ] If you want Agent Zero to use an already-open personal Chrome window, open
- that browser first.
-- [ ] In that browser, go to `chrome://inspect/#remote-debugging`.
+- [ ] If you want Agent Zero to use an already-open personal browser window,
+ open that browser first.
+- [ ] In that browser, open its remote debugging page.
- [ ] Enable **Allow remote debugging for this browser instance**.
+Remote debugging pages:
+
+| Browser | Page |
+| --- | --- |
+| Chrome, Edge, Brave, Vivaldi, Chromium | `chrome://inspect/#remote-debugging` |
+| Opera | `opera://inspect/#remote-debugging` |
+

When Agent Zero performs its first Browser action against that host browser,
-Chrome asks for confirmation. Click **Allow** if you trust this Agent Zero
+the browser asks for confirmation. Click **Allow** if you trust this Agent Zero
instance and A0 CLI connection.

@@ -149,11 +156,30 @@ A0 CLI does not take over the browser while it is only checking status. Browser
control starts when Agent Zero actually needs to use the browser.
> [!IMPORTANT]
-> Remote debugging gives the connected app full control of that Chrome session,
+> Remote debugging gives the connected app full control of that browser session,
> including access to saved data, cookies, site data, and navigation. Use it only
> with trusted Agent Zero instances and browser windows you intend the agent to
> control.
+The **Host browser** list in Browser settings comes from the connected local A0
+CLI, not from the Agent Zero Web UI server. It shows Automatic, currently
+advertised debug endpoints, and **Custom endpoint**. If a newly authorized
+browser does not appear, restart or reconnect A0 CLI.
+
+If the inspect checkbox is not enough for your browser build, launch it with an
+explicit remote debugging port and a separate profile:
+
+```bash
+opera --remote-debugging-port=9222 --user-data-dir="$HOME/.config/a0-opera-debug"
+```
+
+Then choose **Custom endpoint** in Browser settings, run `/browser ws://...` in
+A0 CLI, or pass the full DevTools websocket endpoint to A0 CLI:
+
+```bash
+export A0_HOST_BROWSER_REMOTE_DEBUGGING_ENDPOINTS="ws://127.0.0.1:9222/devtools/browser/..."
+```
+
### Browser Profiles
```bash
diff --git a/docs/guides/browser.md b/docs/guides/browser.md
index ed406327a..33d1b719f 100644
--- a/docs/guides/browser.md
+++ b/docs/guides/browser.md
@@ -137,8 +137,8 @@ development, Agent Zero can install it the first time it is needed.
## Bring Your Own Browser
-Bring Your Own Browser lets Agent Zero use Chrome, Edge, or Chromium on your own
-computer through A0 CLI.
+Bring Your Own Browser lets Agent Zero use Chrome, Edge, Brave, Opera, Vivaldi,
+or Chromium on your own computer through A0 CLI.
Use it when the page, login, or browser profile should stay on your machine.
@@ -146,8 +146,35 @@ Requirements:
- [ ] Keep A0 CLI connected to the Agent Zero chat.
- [ ] Choose **Bring Your Own Browser** in Browser settings.
-- [ ] Use Chrome, Edge, or Chromium on the host.
-- [ ] For personal Chrome remote debugging, open the host browser first, go to `chrome://inspect/#remote-debugging`, and enable **Allow remote debugging for this browser instance**.
+- [ ] Use a Chromium-family browser on the host: Chrome, Edge, Brave, Opera, Vivaldi, or Chromium.
+- [ ] For an already-open browser, open its remote debugging page and enable **Allow remote debugging for this browser instance**.
+
+Remote debugging pages:
+
+| Browser | Page |
+| --- | --- |
+| Chrome, Edge, Brave, Vivaldi, Chromium | `chrome://inspect/#remote-debugging` |
+| Opera | `opera://inspect/#remote-debugging` |
+
+The **Host browser** list shows Automatic, currently advertised debug endpoints,
+and **Custom endpoint**. If a browser does not appear after enabling remote
+debugging, restart or reconnect the local A0 CLI. Restarting only the Agent Zero
+Web UI server does not refresh the browser inventory; the list comes from the
+connected CLI.
+
+As a fallback, launch the browser with an explicit debugging port and profile
+directory:
+
+```bash
+opera --remote-debugging-port=9222 --user-data-dir="$HOME/.config/a0-opera-debug"
+```
+
+Then choose **Custom endpoint** in Browser settings, or pass the full DevTools
+websocket endpoint to the CLI:
+
+```bash
+export A0_HOST_BROWSER_REMOTE_DEBUGGING_ENDPOINTS="ws://127.0.0.1:9222/devtools/browser/..."
+```

diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md
index e41a7141a..21f8e979e 100644
--- a/docs/guides/troubleshooting.md
+++ b/docs/guides/troubleshooting.md
@@ -41,6 +41,7 @@ PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium
If **Bring Your Own Browser** mode fails:
- keep A0 CLI connected to the chat;
+- restart or reconnect A0 CLI after enabling remote debugging in a browser;
- run `/browser status` in A0 CLI;
- check that Browser settings still say **Bring Your Own Browser**;
- check **Page content access** if page text or screenshots are blocked.
diff --git a/extensions/python/_functions/AGENTS.md b/extensions/python/_functions/AGENTS.md
index 4a7331962..db75fd4af 100644
--- a/extensions/python/_functions/AGENTS.md
+++ b/extensions/python/_functions/AGENTS.md
@@ -16,6 +16,7 @@
- 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.
+- Recovery-loop circuit breakers must stop at the General Settings limit and render their user-visible cost warning from a core framework prompt.
## Work Guidance
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
new file mode 100644
index 000000000..2c44a910c
--- /dev/null
+++ b/extensions/python/_functions/agent/Agent/hist_add_warning/end/_90_stop_unusable_response_loop.py
@@ -0,0 +1,55 @@
+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"),
+ }:
+ 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/message_loop_end/AGENTS.md b/extensions/python/message_loop_end/AGENTS.md
index 8927890a9..2200c4aa3 100644
--- a/extensions/python/message_loop_end/AGENTS.md
+++ b/extensions/python/message_loop_end/AGENTS.md
@@ -12,6 +12,7 @@
- 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
diff --git a/extensions/python/message_loop_end/_10_organize_history.py b/extensions/python/message_loop_end/_10_organize_history.py
index d91c9ef80..0cc205bd6 100644
--- a/extensions/python/message_loop_end/_10_organize_history.py
+++ b/extensions/python/message_loop_end/_10_organize_history.py
@@ -1,10 +1,18 @@
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:
@@ -17,6 +25,6 @@ class OrganizeHistory(Extension):
# start task
task = DeferredTask(thread_name=THREAD_BACKGROUND)
- task.start_task(self.agent.history.compress)
+ 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_prompts_after/AGENTS.md b/extensions/python/message_loop_prompts_after/AGENTS.md
index ba8118a2f..d18b1c5a1 100644
--- a/extensions/python/message_loop_prompts_after/AGENTS.md
+++ b/extensions/python/message_loop_prompts_after/AGENTS.md
@@ -2,20 +2,21 @@
## Purpose
-- Own prompt protocol and extras appended around primary message-loop prompt construction.
+- Own prompt protocol, prompt extras, and history reattachment around primary message-loop prompt construction.
## Ownership
-- Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection.
-- Active skill instructions belong in prompt protocol.
+- 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.
## Work Guidance
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 c4b2a45e8..a810cece4 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
@@ -8,9 +8,13 @@ class RecallRelevantSkills(Extension):
if not self.agent or loop_data.iteration != 0:
return
- user_instruction = (
- loop_data.user_message.output_text() if loop_data.user_message else ""
- ).strip()
+ 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
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 d7effd627..6c604005a 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
@@ -14,9 +14,6 @@ class IncludeLoadedSkills(Extension):
if not self.agent:
return
- loop_data.protocol_persistent.pop("loaded_skills", None)
- loop_data.extras_persistent.pop("loaded_skills", None)
-
skill_names = skills.get_loaded_skill_names(self.agent)
if not skill_names:
return
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 b2c925fe8..08966d2b1 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,6 +1,9 @@
from helpers.extension import Extension
from agent import LoopData
-from extensions.python.message_loop_end._10_organize_history import DATA_NAME_TASK
+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
@@ -33,7 +36,7 @@ class OrganizeHistoryWait(Extension):
else:
# no task was running, start and wait
self.agent.context.log.set_progress("Compressing history...")
- compressed = await self.agent.history.compress()
+ compressed = await compress_history(self.agent)
after_tokens = self.agent.history.get_tokens()
if not compressed or after_tokens >= before_tokens:
diff --git a/extensions/python/startup_migration/AGENTS.md b/extensions/python/startup_migration/AGENTS.md
index fc75b8a8b..d3ffc39a0 100644
--- a/extensions/python/startup_migration/AGENTS.md
+++ b/extensions/python/startup_migration/AGENTS.md
@@ -14,6 +14,7 @@
- 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
diff --git a/extensions/python/startup_migration/_10_self_update_manager.py b/extensions/python/startup_migration/_10_self_update_manager.py
index 8a188d6d0..fff24cc1c 100644
--- a/extensions/python/startup_migration/_10_self_update_manager.py
+++ b/extensions/python/startup_migration/_10_self_update_manager.py
@@ -3,6 +3,8 @@ from __future__ import annotations
import os
import shutil
import stat
+import subprocess
+import sys
from pathlib import Path
from typing import Any
@@ -25,6 +27,8 @@ REQUIRED_RUNTIME_MARKERS = (
"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)",
)
@@ -33,6 +37,9 @@ class SelfUpdateManagerRuntimeSync(Extension):
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"])
@@ -84,6 +91,20 @@ def ensure_self_update_manager_runtime_current(
}
+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]
diff --git a/extensions/python/system_prompt/AGENTS.md b/extensions/python/system_prompt/AGENTS.md
index 34b90f78c..c4aa1bfd8 100644
--- a/extensions/python/system_prompt/AGENTS.md
+++ b/extensions/python/system_prompt/AGENTS.md
@@ -7,7 +7,7 @@
## Ownership
- Ordered Python files own main, tools, MCP, secrets, skills, and project prompt sections.
-- Active project instruction bodies are moved into prompt protocol; the system prompt keeps project metadata and stable project rules.
+- 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
diff --git a/extensions/python/system_prompt/_14_project_prompt.py b/extensions/python/system_prompt/_14_project_prompt.py
index cd0363b4c..4afa620cd 100644
--- a/extensions/python/system_prompt/_14_project_prompt.py
+++ b/extensions/python/system_prompt/_14_project_prompt.py
@@ -25,9 +25,16 @@ 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",
diff --git a/helpers/AGENTS.md b/helpers/AGENTS.md
index dc7584d32..39d24d71e 100644
--- a/helpers/AGENTS.md
+++ b/helpers/AGENTS.md
@@ -15,7 +15,7 @@
- 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 and project instruction file content is injected with an explicit source path.
+- 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.
diff --git a/helpers/backup.py b/helpers/backup.py
index 47e41c5f8..70ad7b082 100644
--- a/helpers/backup.py
+++ b/helpers/backup.py
@@ -63,6 +63,7 @@ class BackupService:
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:
diff --git a/helpers/backup.py.dox.md b/helpers/backup.py.dox.md
index e08f7a74e..5612447ec 100644
--- a/helpers/backup.py.dox.md
+++ b/helpers/backup.py.dox.md
@@ -26,6 +26,7 @@
- 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/**`.
## Key Concepts
diff --git a/helpers/history.py b/helpers/history.py
index c579c501d..1ff802d87 100644
--- a/helpers/history.py
+++ b/helpers/history.py
@@ -723,6 +723,28 @@ 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
diff --git a/helpers/history.py.dox.md b/helpers/history.py.dox.md
index 0e07d4a9e..53a7149e0 100644
--- a/helpers/history.py.dox.md
+++ b/helpers/history.py.dox.md
@@ -65,6 +65,7 @@
- `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`
@@ -77,6 +78,7 @@
- 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.
- 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`.
diff --git a/helpers/mcp_handler.py b/helpers/mcp_handler.py
index b1f888ec1..e30c3916c 100644
--- a/helpers/mcp_handler.py
+++ b/helpers/mcp_handler.py
@@ -21,6 +21,7 @@ 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
@@ -112,6 +113,44 @@ def _normalize_disabled_tools(value: Any) -> list[str]:
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:
@@ -649,6 +688,15 @@ class MCPServerLocal(BaseModel):
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",
@@ -665,11 +713,15 @@ class MCPServerLocal(BaseModel):
"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":
diff --git a/helpers/mcp_handler.py.dox.md b/helpers/mcp_handler.py.dox.md
index 4faff9fe1..1cab10612 100644
--- a/helpers/mcp_handler.py.dox.md
+++ b/helpers/mcp_handler.py.dox.md
@@ -66,6 +66,9 @@
- `_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`.
@@ -81,12 +84,13 @@
- MCP tool names are qualified as `server_name.tool_name`; server names are normalized without dots, and the tool portion may contain dots.
- 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`.
+- 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
diff --git a/helpers/parallel_tools.py b/helpers/parallel_tools.py
index e9d830bef..0b9c9f72a 100644
--- a/helpers/parallel_tools.py
+++ b/helpers/parallel_tools.py
@@ -487,37 +487,36 @@ async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str:
worker_agent = worker_context.agent0
worker_agent.loop_data = LoopData()
- return await execute_tool_call(worker_agent, job.tool_name, job.tool_args)
+ return await execute_tool_call(
+ worker_agent,
+ job.tool_name,
+ job.tool_args,
+ log_item=job.log_item,
+ )
finally:
if worker_context:
await _remove_context(worker_context.id)
-async def execute_tool_call(agent: "Agent", tool_name: str, tool_args: dict[str, Any]) -> str:
+async def execute_tool_call(
+ agent: "Agent",
+ tool_name: str,
+ tool_args: dict[str, Any],
+ *,
+ log_item: "LogItem | None" = None,
+) -> str:
if tool_name == "parallel":
raise ValueError("`parallel` cannot be nested inside a parallel worker.")
- tool = None
- try:
- import helpers.mcp_handler as mcp_helper
-
- tool = mcp_helper.MCPConfig.get_instance().get_tool(agent, tool_name)
- except ImportError:
- tool = None
- except Exception as exc:
- PrintStyle.warning(f"Failed to initialize MCP tool '{tool_name}' for parallel job: {exc}")
-
- if not tool:
- tool = agent.get_tool(
- name=tool_name,
- method=None,
- args=tool_args,
- message=json.dumps({"tool_name": tool_name, "tool_args": tool_args}),
- loop_data=agent.loop_data,
- )
+ tool = _resolve_parallel_tool(agent, tool_name, tool_args, strict=True)
if not tool:
raise ValueError(f"Tool '{tool_name}' not found or could not be initialized.")
+ original_get_log_object = None
+ if log_item is not None:
+ original_get_log_object = tool.get_log_object
+ tool.get_log_object = lambda: log_item
+
agent.loop_data.current_tool = tool
try:
await agent.handle_intervention()
@@ -541,9 +540,63 @@ async def execute_tool_call(agent: "Agent", tool_name: str, tool_args: dict[str,
await agent.handle_intervention()
return response.message
finally:
+ if original_get_log_object is not None:
+ tool.get_log_object = original_get_log_object
agent.loop_data.current_tool = None
+def _resolve_parallel_tool(
+ agent: "Agent",
+ tool_name: str,
+ tool_args: dict[str, Any],
+ *,
+ strict: bool = False,
+):
+ message = json.dumps({"tool_name": tool_name, "tool_args": tool_args})
+
+ tool = None
+ try:
+ import helpers.mcp_handler as mcp_helper
+
+ tool = mcp_helper.MCPConfig.get_instance().get_tool(agent, tool_name)
+ except ImportError:
+ tool = None
+ except Exception as exc:
+ if strict:
+ raise
+ PrintStyle.warning(f"Failed to initialize MCP tool '{tool_name}' for parallel job: {exc}")
+
+ if not tool:
+ get_tool = getattr(agent, "get_tool", None)
+ if not callable(get_tool):
+ if strict:
+ raise ValueError(f"Tool '{tool_name}' not found or could not be initialized.")
+ return None
+ try:
+ tool = get_tool(
+ name=tool_name,
+ method=None,
+ args=tool_args,
+ message=message,
+ loop_data=getattr(agent, "loop_data", None),
+ )
+ except Exception as exc:
+ if strict:
+ raise
+ PrintStyle.warning(f"Failed to initialize tool '{tool_name}' for parallel job: {exc}")
+ tool = None
+
+ if not tool:
+ return None
+
+ try:
+ tool.args = dict(tool_args)
+ except Exception:
+ pass
+
+ return tool
+
+
async def _cancel_job(
job: ParallelJob,
*,
@@ -600,6 +653,17 @@ def _log_parallel_child_started(agent: "Agent", job: ParallelJob) -> None:
)
return
+ tool = _resolve_parallel_tool(agent, job.tool_name, job.tool_args)
+ if tool is not None:
+ try:
+ job.log_item = tool.get_log_object()
+ if job.log_item is not None:
+ return
+ except Exception as exc:
+ PrintStyle.warning(
+ f"Failed to derive parallel child log for {job.tool_name}: {exc}"
+ )
+
heading = f"icon://construction {agent.agent_name}: Using tool '{job.tool_name}'"
job.log_item = agent.context.log.log(
type="tool",
diff --git a/helpers/parallel_tools.py.dox.md b/helpers/parallel_tools.py.dox.md
index 39e65c4cc..d2456f2f2 100644
--- a/helpers/parallel_tools.py.dox.md
+++ b/helpers/parallel_tools.py.dox.md
@@ -31,6 +31,8 @@
- Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
- Parent-visible child log items are created for each wrapped call so the WebUI can inspect concurrent children separately while the wrapper result remains model-history-only.
- Child tool logs mirror normal tool-call visible args; job ids remain available through wrapper results and prompt extras rather than visible process-step args.
+- Wrapped tool child logs use each tool's native `get_log_object()` output when available, preserving special log rendering (for example: `code_execution_tool` uses `code_exe`, `wait` uses `progress`, MCP tools use `mcp`, and regular tools use `tool`).
+- Direct parallel worker execution reuses the parent-visible child log item so tool `before_execution()` cannot create a second generic worker log or lose the native badge type.
- Job IDs are stable handles for later await, collect, or cancel operations.
- Prompt extras must stay bounded and expose only job IDs, tool names, status, and compact result/error summaries.
diff --git a/helpers/persist_chat.py b/helpers/persist_chat.py
index 199fa3ef5..47479bd35 100644
--- a/helpers/persist_chat.py
+++ b/helpers/persist_chat.py
@@ -1,12 +1,15 @@
+import json
+import os
+import tempfile
+import uuid
from collections import OrderedDict
from datetime import datetime
from typing import Any
-import uuid
+
from agent import Agent, AgentConfig, AgentContext, AgentContextType
from helpers import files, history
from helpers.litellm_transport import delete_stored_response_ids
from helpers.localization import Localization
-import json
from initialize import initialize_agent
from helpers.log import Log, LogItem
@@ -51,10 +54,9 @@ def save_tmp_chat(context: AgentContext):
return
path = _get_chat_file_path(context.id)
- files.make_dirs(path)
data = _serialize_context(context)
js = _safe_json_serialize(data, ensure_ascii=False)
- files.write_file(path, js)
+ _write_atomic(path, js)
mark_chat_saved(context)
@@ -94,6 +96,28 @@ def _get_chat_file_path(ctxid: str):
return files.get_abs_path(CHATS_FOLDER, ctxid, CHAT_FILE_NAME)
+def _write_atomic(path: str, content: str) -> None:
+ directory = os.path.dirname(path)
+ os.makedirs(directory, exist_ok=True)
+ fd, tmp_path = tempfile.mkstemp(
+ prefix=f".{os.path.basename(path)}.", suffix=".tmp", dir=directory
+ )
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ handle.write(content.encode("utf-8", "replace").decode("utf-8"))
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(tmp_path, path)
+ directory_fd = os.open(directory, os.O_RDONLY)
+ try:
+ os.fsync(directory_fd)
+ finally:
+ os.close(directory_fd)
+ finally:
+ if os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+
+
def mark_chat_saved(context: AgentContext) -> None:
context.data[SAVED_CHAT_CONTEXT_DATA_KEY] = True
diff --git a/helpers/persist_chat.py.dox.md b/helpers/persist_chat.py.dox.md
index e75fe80d5..09205e7d0 100644
--- a/helpers/persist_chat.py.dox.md
+++ b/helpers/persist_chat.py.dox.md
@@ -45,6 +45,7 @@
- Serialized chats store `agent_profile` both at the context level for the main chat and on each serialized agent so subordinate profiles survive server restart.
- Deserialization must rebuild each agent with its serialized profile when present, falling back to the context profile for older chat files.
- Chat loading skips directories that do not contain `chat.json`; malformed existing chat files still report load errors.
+- Chat saves write and fsync a same-directory temporary file, atomically replace `chat.json`, and fsync the directory so an interrupted save cannot truncate the previous chat.
- Contexts are marked with private `SAVED_CHAT_CONTEXT_DATA_KEY` only after a successful save or load from disk so snapshot code can detect deleted chat files without hiding fresh unsaved chats.
## Key Concepts
diff --git a/helpers/projects.py b/helpers/projects.py
index f146d02a6..41bbf9d44 100644
--- a/helpers/projects.py
+++ b/helpers/projects.py
@@ -1,5 +1,5 @@
import os
-from typing import Literal, NotRequired, TypedDict, TYPE_CHECKING, cast
+from typing import NotRequired, TypedDict, TYPE_CHECKING, cast
from helpers import files, dirty_json, persist_chat, file_tree, extension
from helpers.print_style import PrintStyle
@@ -15,7 +15,13 @@ PROJECT_KNOWLEDGE_DIR = "knowledge"
PROJECT_SKILLS_DIR = "skills"
PROJECT_HEADER_FILE = "project.json"
PROJECT_MCP_SERVERS_FILE = "mcp_servers.json"
-PROJECT_AGENTS_MD_FILES = ("AGENTS.md", "Agents.md", "agents.md")
+PROJECT_AGENTS_MD_FILES = (
+ "AGENTS.override.md",
+ "AGENTS.Override.md",
+ "AGENTS.md",
+ "Agents.md",
+ "agents.md",
+)
DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}'
CONTEXT_DATA_KEY_PROJECT = "project"
@@ -466,9 +472,10 @@ def deactivate_project_in_chats(name: str):
def build_system_prompt_vars(name: str):
project_data = load_basic_project_data(name)
main_instructions = project_data.get("instructions", "") or ""
+ include_agents_md = project_data.get("include_agents_md", True)
instruction_files = get_project_instruction_files(
name,
- include_agents_md=project_data.get("include_agents_md", True),
+ include_agents_md=include_agents_md,
)
instruction_parts = [
main_instructions,
@@ -481,11 +488,75 @@ def build_system_prompt_vars(name: str):
"project_name": project_data.get("title", ""),
"project_description": project_data.get("description", ""),
"project_instructions": complete_instructions or "",
+ "include_agents_md": include_agents_md,
"project_path": files.normalize_a0_path(get_project_folder(name)),
"project_git_url": project_data.get("git_url", ""),
}
+def get_agents_md_chain(root: str, target: str) -> list[tuple[str, str]]:
+ root_real = os.path.realpath(files.fix_dev_path(root))
+ target_real = os.path.realpath(files.fix_dev_path(target))
+ if os.path.isfile(target_real):
+ target_real = os.path.dirname(target_real)
+
+ if files.is_in_dir(target_real, root_real):
+ dirs = []
+ cursor = target_real
+ while True:
+ dirs.append(cursor)
+ if cursor == root_real:
+ break
+ parent = os.path.dirname(cursor)
+ if parent == cursor:
+ break
+ cursor = parent
+ dirs.reverse()
+ else:
+ dirs = [root_real]
+
+ chain = []
+ for dir_path in dirs:
+ for filename in PROJECT_AGENTS_MD_FILES:
+ matches = files.read_text_files_in_dir(dir_path, pattern=filename)
+ if filename not in matches:
+ continue
+ chain.append((files.get_abs_path(dir_path, filename), matches[filename]))
+ break
+ return chain
+
+
+def build_agents_md_protocol(name: str, target: str | None = None) -> str:
+ project_folder = get_project_folder(name)
+ project_agents_md = get_project_agents_md_instruction_file(name)
+ project_agents_md_path = (
+ os.path.realpath(files.fix_dev_path(project_agents_md[0]))
+ if project_agents_md
+ else ""
+ )
+ entries = [
+ (path, content)
+ for path, content in get_agents_md_chain(
+ files.get_abs_path(""),
+ target or project_folder,
+ )
+ if os.path.realpath(path) != project_agents_md_path
+ ]
+ if not entries:
+ return ""
+
+ instructions = []
+ for path, content in entries:
+ instructions.append(
+ f"### path: {files.normalize_a0_path(path)}\n\n{content.strip()}"
+ )
+ return files.read_prompt_file(
+ "agent.protocol.projects.agents_md.md",
+ _directories=["prompts"],
+ agents_md_instructions="\n\n".join(instructions),
+ ).strip()
+
+
def get_additional_instructions_files(name: str):
instructions_folder = files.get_abs_path(
get_project_folder(name), PROJECT_META_DIR, PROJECT_INSTRUCTIONS_DIR
@@ -522,11 +593,8 @@ def get_project_instruction_files(
def get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None:
project_folder = get_project_folder(name)
- for filename in PROJECT_AGENTS_MD_FILES:
- matches = files.read_text_files_in_dir(project_folder, pattern=filename)
- if filename in matches:
- path = files.get_abs_path(project_folder, filename)
- return (files.normalize_a0_path(path), matches[filename])
+ for path, content in get_agents_md_chain(project_folder, project_folder):
+ return (files.normalize_a0_path(path), content)
return None
diff --git a/helpers/projects.py.dox.md b/helpers/projects.py.dox.md
index aae7514d0..e1e1b592e 100644
--- a/helpers/projects.py.dox.md
+++ b/helpers/projects.py.dox.md
@@ -47,6 +47,8 @@
- `reactivate_project_in_chats(name: str)`
- `deactivate_project_in_chats(name: str)`
- `build_system_prompt_vars(name: str)`
+- `get_agents_md_chain(root: str, target: str) -> list[tuple[str, str]]`
+- `build_agents_md_protocol(name: str, target: str | None=...) -> str`
- `get_additional_instructions_files(name: str)`
- `get_project_instruction_files(name: str, include_agents_md: bool=...) -> list[tuple[str, str]]`
- `get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None`
@@ -63,6 +65,8 @@
- Project extension data may add named top-level sections such as `llm`, but it must not overwrite core project fields owned by `EditProjectData`.
- Project extension save payloads exclude core project fields and transient inputs such as `git_token`; plugins needing core metadata should load it by project name.
- Project metadata setup creates and repairs `.a0proj/instructions`, `.a0proj/knowledge`, and `.a0proj/skills` so settings surfaces can open those folders consistently.
+- AGENTS.md discovery is a linear root-to-target chain walk with `AGENTS.override.md` precedence; sibling directories are not scanned.
+- Active-project AGENTS.md protocol guidance excludes the exact project root AGENTS.md because `build_system_prompt_vars(...)` already loads it into project instructions; prose for that protocol block lives in `prompts/agent.protocol.projects.agents_md.md`.
- Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`.
- Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`.
- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling.
diff --git a/helpers/settings.py b/helpers/settings.py
index 35b3f7beb..fcd66eef6 100644
--- a/helpers/settings.py
+++ b/helpers/settings.py
@@ -56,8 +56,10 @@ class Settings(TypedDict):
agent_profile: str
agent_knowledge_subdir: str
+ max_consecutive_unusable_responses: int
timezone: str
time_format: str
+ ui_control_visibility: dict[str, dict[str, bool]]
workdir_path: str
workdir_show: bool
@@ -163,6 +165,12 @@ API_KEY_PLACEHOLDER = "************"
TIMEZONE_AUTO = "auto"
TIME_FORMAT_12H = "12h"
TIME_FORMAT_24H = "24h"
+UI_CONTROL_VISIBILITY_DEFAULTS = {
+ "projectSelector": {"mobile": True, "desktop": True},
+ "time": {"mobile": False, "desktop": True},
+ "connectionStatus": {"mobile": True, "desktop": True},
+ "rightCanvasRail": {"mobile": True, "desktop": True},
+}
SETTINGS_FILE = files.get_abs_path("usr/settings.json")
_settings: Settings | None = None
@@ -209,6 +217,22 @@ def _normalize_time_format(value: Any, default: str = TIME_FORMAT_12H) -> str:
return default if default in {TIME_FORMAT_12H, TIME_FORMAT_24H} else TIME_FORMAT_12H
+def _normalize_ui_control_visibility(value: Any) -> dict[str, dict[str, bool]]:
+ submitted = value if isinstance(value, dict) else {}
+ normalized = {}
+ for control, devices in UI_CONTROL_VISIBILITY_DEFAULTS.items():
+ submitted_devices = submitted.get(control, {})
+ if not isinstance(submitted_devices, dict):
+ submitted_devices = {}
+ normalized[control] = {
+ device: submitted_devices.get(device)
+ if isinstance(submitted_devices.get(device), bool)
+ else default
+ for device, default in devices.items()
+ }
+ return normalized
+
+
def _resolve_runtime_timezone(setting_value: str, browser_timezone: str | None = None) -> str:
if setting_value == TIMEZONE_AUTO:
candidate = str(browser_timezone or "").strip()
@@ -401,8 +425,12 @@ def normalize_settings(settings: Settings) -> Settings:
# mcp server token is set automatically
copy["mcp_server_token"] = create_auth_token()
+ copy["max_consecutive_unusable_responses"] = max(
+ 1, copy["max_consecutive_unusable_responses"]
+ )
copy["timezone"] = _normalize_timezone_setting(copy.get("timezone"), default["timezone"])
copy["time_format"] = _normalize_time_format(copy.get("time_format"), default["time_format"])
+ copy["ui_control_visibility"] = _normalize_ui_control_visibility(copy.get("ui_control_visibility"))
return copy
@@ -498,8 +526,14 @@ def get_default_settings() -> Settings:
root_password="",
agent_profile=get_default_value("agent_profile", "agent0"),
agent_knowledge_subdir=get_default_value("agent_knowledge_subdir", "custom"),
+ max_consecutive_unusable_responses=get_default_value(
+ "max_consecutive_unusable_responses", 2
+ ),
timezone=_normalize_timezone_setting(get_default_value("timezone", TIMEZONE_AUTO)),
time_format=_normalize_time_format(get_default_value("time_format", TIME_FORMAT_12H)),
+ ui_control_visibility=_normalize_ui_control_visibility(
+ get_default_value("ui_control_visibility", UI_CONTROL_VISIBILITY_DEFAULTS)
+ ),
workdir_path=get_default_value("workdir_path", files.get_abs_path_dockerized("usr/workdir")),
workdir_show=get_default_value("workdir_show", True),
workdir_max_depth=get_default_value("workdir_max_depth", 5),
diff --git a/helpers/settings.py.dox.md b/helpers/settings.py.dox.md
index d5b61ce92..d4bdb974c 100644
--- a/helpers/settings.py.dox.md
+++ b/helpers/settings.py.dox.md
@@ -25,6 +25,7 @@
- `_is_valid_timezone(value: str) -> bool`
- `_normalize_timezone_setting(value: Any, default: str=...) -> str`
- `_normalize_time_format(value: Any, default: str=...) -> str`
+- `_normalize_ui_control_visibility(value: Any) -> dict[str, dict[str, bool]]`
- `_resolve_runtime_timezone(setting_value: str, browser_timezone: str | None=...) -> str`
- `_timezone_options() -> list[FieldOption]`
- `convert_out(settings: Settings) -> SettingsOutput`
@@ -50,7 +51,7 @@
- `_dict_to_env(data_dict)`
- `set_root_password(password: str)`
- `get_runtime_config(set: Settings)`
-- Notable constants/configuration names: `T`, `PASSWORD_PLACEHOLDER`, `API_KEY_PLACEHOLDER`, `TIMEZONE_AUTO`, `TIME_FORMAT_12H`, `TIME_FORMAT_24H`, `SETTINGS_FILE`.
+- Notable constants/configuration names: `T`, `PASSWORD_PLACEHOLDER`, `API_KEY_PLACEHOLDER`, `TIMEZONE_AUTO`, `TIME_FORMAT_12H`, `TIME_FORMAT_24H`, `UI_CONTROL_VISIBILITY_DEFAULTS`, `SETTINGS_FILE`.
## Runtime Contracts
@@ -64,6 +65,8 @@
- Important called helpers/classes observed in the source: `TypeVar`, `files.get_abs_path`, `dotenv.get_dotenv_value`, `opts.insert`, `str.strip`, `_is_valid_timezone`, `str.strip.lower`, `_normalize_timezone_setting`, `SettingsOutput`, `get_default_settings`, `_ensure_option_present`, `_resolve_runtime_timezone`, `get_default_secrets_manager`, `get_settings`, `normalize_settings`, `_load_sensitive_settings`, `settings.copy`, `_write_settings_file`, `reload_settings`, `set_settings`, `initialize_agent`.
- Applying settings refreshes active context configs while preserving each subordinate agent's own profile.
- Applying settings starts a deferred `MCPConfig.update(...)` with the current `mcp_servers` string when global MCP server settings change.
+- `max_consecutive_unusable_responses` defaults to `2` and controls the cost circuit breaker for malformed or repeated main-model outputs.
+- `ui_control_visibility` stores validated mobile and desktop visibility flags for the project selector, clock, connection status, and right canvas rail; missing or malformed values fall back per device.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance
diff --git a/helpers/skills.py b/helpers/skills.py
index 74488da9d..1eeaa8504 100644
--- a/helpers/skills.py
+++ b/helpers/skills.py
@@ -543,11 +543,15 @@ def search_skills(
if not q:
return []
- raw_terms = [t for t in re.split(r"\s+", q) if t]
+ raw_terms = re.findall(r"[a-z0-9][a-z0-9_-]*", q)
terms = [
t for t in raw_terms
- if len(t) >= 3 or any(ch.isdigit() for ch in t)
- ] or raw_terms
+ if len(t) >= 4 or any(ch.isdigit() for ch in t)
+ ]
+ long_terms = [
+ t for t in raw_terms
+ if len(t) >= 6 or any(ch.isdigit() for ch in t)
+ ]
candidates = list_skills(agent, include_hidden=include_hidden)
scored: List[Tuple[int, Skill]] = []
@@ -568,14 +572,13 @@ def search_skills(
score += 4
if any(q in tag for tag in tags):
score += 3
- if any(q in trigger for trigger in triggers):
+ if any(q in trigger or trigger in q for trigger in triggers):
score += 8
for term in terms:
if term in name:
score += 3
- if term in desc:
- score += 2
+ for term in long_terms:
if any(term in tag for tag in tags):
score += 1
if any(term in trigger for trigger in triggers):
@@ -1132,7 +1135,6 @@ def hide_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS,
visible_entries,
)
- unload_agent_skill(agent, normalized)
return get_hidden_skills(agent)
@@ -1183,8 +1185,7 @@ def clear_chat_skill_overrides(agent: Agent) -> list[ActiveSkillEntry]:
def build_active_skills_prompt(agent: Agent | None) -> str:
- items = _resolve_active_skill_entries(agent, get_active_skills(agent))
- return "\n\n".join(item["content"] for item in items if item.get("content")).strip()
+ return ""
def _format_skill_prompt(skill: Skill) -> str:
diff --git a/helpers/skills.py.dox.md b/helpers/skills.py.dox.md
index d269bb205..902caf3fc 100644
--- a/helpers/skills.py.dox.md
+++ b/helpers/skills.py.dox.md
@@ -56,6 +56,9 @@
- 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.
- Loaded skill names are chat-wide context data under `CONTEXT_DATA_NAME_LOADED_SKILLS`; legacy agent-local `loaded_skills` lists are migrated into context data and cleared when read.
+- Loaded skill bodies live in chat history; hiding a skill changes catalog visibility but does not remove the loaded-skill ledger.
+- `build_active_skills_prompt()` returns empty because selected skills are loaded through history, not prompt protocol.
+- `search_skills()` normalizes query words, scores normal terms against skill names, and scores only long terms against tags/triggers; descriptions match only full query phrases so generic prose does not produce irrelevant suggestions.
- Invalid `SKILL.md` frontmatter emits a once-per-path scan warning with the skipped skill path/name and a line number when the parser can identify one directly.
- Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling.
- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
diff --git a/helpers/ui_server.py b/helpers/ui_server.py
index cbf14a054..fdea68b01 100644
--- a/helpers/ui_server.py
+++ b/helpers/ui_server.py
@@ -1,6 +1,7 @@
from dataclasses import dataclass, field
from datetime import timedelta
import asyncio
+import json
import logging
import os
import secrets
@@ -262,6 +263,13 @@ class UiRouteHandlers:
user_time_format_setting = str(settings_helper.get_settings().get("time_format", "12h"))
except Exception:
user_time_format_setting = "12h"
+ try:
+ user_ui_control_visibility = json.dumps(
+ settings_helper.get_settings()["ui_control_visibility"],
+ separators=(",", ":"),
+ )
+ except Exception:
+ user_ui_control_visibility = json.dumps(settings_helper.UI_CONTROL_VISIBILITY_DEFAULTS)
index = files.read_file("webui/index.html")
return files.replace_placeholders_text(
@@ -273,6 +281,7 @@ class UiRouteHandlers:
logged_in=("true" if login.get_credentials_hash() else "false"),
user_timezone_setting=user_timezone_setting,
user_time_format_setting=user_time_format_setting,
+ user_ui_control_visibility=user_ui_control_visibility,
)
@requires_auth
diff --git a/helpers/ui_server.py.dox.md b/helpers/ui_server.py.dox.md
index 4c993c84d..ae430843f 100644
--- a/helpers/ui_server.py.dox.md
+++ b/helpers/ui_server.py.dox.md
@@ -41,6 +41,7 @@
## Key Concepts
- Important called helpers/classes observed in the source: `logging.getLogger.setLevel`, `Localization.get.apply_process_timezone`, `_positive_int_env`, `field`, `Flask`, `threading.RLock`, `socketio.AsyncServer`, `WsManager`, `set_shared_ws_manager`, `cls`, `server_runtime.refresh_runtime_settings`, `settings_helper.get_settings`, `settings_helper.set_runtime_settings_snapshot`, `self.ws_manager.set_server_restart_broadcast`, `UiRouteHandlers`, `self.webapp.add_url_rule`, `register_api_route`, `register_ws_namespace`, `files.read_file`, `render_template_string`, `session.pop`.
+- `serve_index()` bootstraps the normalized UI control visibility map alongside timezone and time-format preferences so controls render correctly before Settings is opened.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance
diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md
index 768a203b0..0d42f9124 100644
--- a/plugins/AGENTS.md
+++ b/plugins/AGENTS.md
@@ -70,6 +70,7 @@ Direct child DOX files:
| [_browser/AGENTS.md](_browser/AGENTS.md) | Playwright browser tool, helpers, viewer, and browser panel UI. |
| [_chat_branching/AGENTS.md](_chat_branching/AGENTS.md) | Chat branching from an existing message. |
| [_chat_compaction/AGENTS.md](_chat_compaction/AGENTS.md) | Full-chat compaction into a summary message. |
+| [_commands/AGENTS.md](_commands/AGENTS.md) | Built-in slash command manager, command file discovery, and chat composer slash picker. |
| [_code_execution/AGENTS.md](_code_execution/AGENTS.md) | Terminal, Python, and Node.js execution tools and shell runtimes. |
| [_desktop/AGENTS.md](_desktop/AGENTS.md) | Linux desktop runtime, sessions, and desktop surface. |
| [_discovery/AGENTS.md](_discovery/AGENTS.md) | Welcome-screen plugin discovery cards and promotions. |
@@ -77,6 +78,7 @@ Direct child DOX files:
| [_editor/AGENTS.md](_editor/AGENTS.md) | Native Markdown editor surface and sessions. |
| [_email_integration/AGENTS.md](_email_integration/AGENTS.md) | IMAP/Exchange polling and SMTP reply integration. |
| [_error_retry/AGENTS.md](_error_retry/AGENTS.md) | Critical exception retry lifecycle hooks. |
+| [_goal/AGENTS.md](_goal/AGENTS.md) | Built-in chat goal strip, `/goal` slash command, and agent-facing goal tools. |
| [_infection_check/AGENTS.md](_infection_check/AGENTS.md) | Prompt-injection safety analysis before tool execution. |
| [_kokoro_tts/AGENTS.md](_kokoro_tts/AGENTS.md) | Kokoro text-to-speech integration. |
| [_memory/AGENTS.md](_memory/AGENTS.md) | Optional persistent recall plugin, knowledge import, tools, and dashboard; do not assume it is enabled outside this plugin. |
@@ -84,6 +86,7 @@ Direct child DOX files:
| [_oauth/AGENTS.md](_oauth/AGENTS.md) | OAuth-backed model-provider connections and local proxy routes. |
| [_office/AGENTS.md](_office/AGENTS.md) | LibreOffice office artifacts and office canvas sessions. |
| [_onboarding/AGENTS.md](_onboarding/AGENTS.md) | First-time model onboarding wizard. |
+| [_orchestrator/AGENTS.md](_orchestrator/AGENTS.md) | External terminal coding-agent orchestration skill, adapter status, and settings UI. |
| [_plugin_installer/AGENTS.md](_plugin_installer/AGENTS.md) | Plugin install and update flows from ZIP, Git, and Plugin Index. |
| [_plugin_scan/AGENTS.md](_plugin_scan/AGENTS.md) | LLM-guided security scanner for third-party plugins. |
| [_plugin_validator/AGENTS.md](_plugin_validator/AGENTS.md) | Plugin manifest, structure, convention, and security validator. |
diff --git a/plugins/_a0_connector/AGENTS.md b/plugins/_a0_connector/AGENTS.md
index e013c73cf..b81567a24 100644
--- a/plugins/_a0_connector/AGENTS.md
+++ b/plugins/_a0_connector/AGENTS.md
@@ -24,6 +24,7 @@
- File operation results may arrive as chunked JSON/base64
`connector_file_op_result` frames; resolve the pending file operation only
after all chunks for the `op_id` are assembled.
+- Host browser status metadata may advertise `available_browsers` entries with browser ids, labels, CDP endpoints, status, and enabled state; keep older CLI payloads without those fields compatible.
## Work Guidance
diff --git a/plugins/_a0_connector/api/v1/browser_runtime.py b/plugins/_a0_connector/api/v1/browser_runtime.py
index 72467e954..ae9b63829 100644
--- a/plugins/_a0_connector/api/v1/browser_runtime.py
+++ b/plugins/_a0_connector/api/v1/browser_runtime.py
@@ -24,6 +24,11 @@ def _normalize_requested_backend(value: object) -> str:
return ""
+def _normalize_host_browser_selection(value: object) -> str:
+ raw = _string(value).lower().replace(" ", "_")
+ return "".join(ch for ch in raw if ch.isalnum() or ch in {"_", "-", ":", ".", "/"})[:200]
+
+
def _normalize_profile_mode(value: object) -> str:
normalized = _string(value).lower().replace("-", "_").replace(" ", "_")
if normalized in {"agent", "clean", "clean_agent", "a0", "dedicated"}:
@@ -68,6 +73,10 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler):
mimetype="application/json",
)
settings["runtime_backend"] = runtime_backend
+ if "host_browser_selection" in input or "browser_selection" in input:
+ settings["host_browser_selection"] = _normalize_host_browser_selection(
+ input.get("host_browser_selection", input.get("browser_selection"))
+ )
if "host_browser_profile_mode" in input or "profile_mode" in input:
profile_mode = _normalize_profile_mode(
input.get("host_browser_profile_mode", input.get("profile_mode"))
@@ -86,11 +95,13 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler):
runtime_backend = settings.get("runtime_backend") or "container"
profile_mode = _normalize_profile_mode(settings.get("host_browser_profile_mode")) or "existing"
+ browser_selection = _normalize_host_browser_selection(settings.get("host_browser_selection"))
return {
"ok": True,
"runtime_backend": runtime_backend,
"host_browser_profile_mode": profile_mode,
+ "host_browser_selection": browser_selection,
"label": _runtime_label(runtime_backend),
"project_name": project_name,
"agent_profile": "",
diff --git a/plugins/_a0_connector/helpers/ws_runtime.py b/plugins/_a0_connector/helpers/ws_runtime.py
index 2e6b7034f..e35948359 100644
--- a/plugins/_a0_connector/helpers/ws_runtime.py
+++ b/plugins/_a0_connector/helpers/ws_runtime.py
@@ -80,6 +80,9 @@ class HostBrowserMetadata:
profile_label: str
profile_path: str
cdp_endpoint: str
+ browser_id: str
+ browser_label: str
+ available_browsers: tuple[dict[str, Any], ...]
content_helper_sha256: str
features: tuple[str, ...]
support_reason: str
@@ -416,6 +419,9 @@ def store_sid_host_browser_metadata(sid: str, payload: dict[str, Any]) -> HostBr
profile_label=str(payload.get("profile_label", "") or "").strip(),
profile_path=str(payload.get("profile_path", "") or "").strip(),
cdp_endpoint=str(payload.get("cdp_endpoint", "") or "").strip(),
+ browser_id=str(payload.get("browser_id", payload.get("browser_selection", "")) or "").strip(),
+ browser_label=str(payload.get("browser_label", "") or "").strip(),
+ available_browsers=_normalize_available_host_browsers(payload.get("available_browsers")),
content_helper_sha256=str(payload.get("content_helper_sha256", "") or "").strip().lower(),
features=features,
support_reason=support_reason,
@@ -445,6 +451,32 @@ def _host_browser_can_prepare(
)
+def _normalize_available_host_browsers(value: Any) -> tuple[dict[str, Any], ...]:
+ if not isinstance(value, (list, tuple)):
+ return ()
+ browsers: list[dict[str, Any]] = []
+ for item in value:
+ if not isinstance(item, dict):
+ continue
+ browser_id = str(item.get("id", item.get("browser_id", item.get("selection", ""))) or "").strip()
+ family = str(item.get("family", item.get("browser_family", "")) or "").strip()
+ label = str(item.get("label", item.get("name", "")) or "").strip()
+ cdp_endpoint = str(item.get("cdp_endpoint", "") or "").strip()
+ status = str(item.get("status", "") or "").strip()
+ enabled = bool(item.get("enabled", True))
+ if not any((browser_id, family, label, cdp_endpoint)):
+ continue
+ browsers.append({
+ "id": browser_id or family or cdp_endpoint,
+ "family": family,
+ "label": label or family or browser_id or cdp_endpoint,
+ "cdp_endpoint": cdp_endpoint,
+ "status": status,
+ "enabled": enabled,
+ })
+ return tuple(browsers)
+
+
def clear_sid_host_browser_metadata(sid: str) -> None:
with _state_lock:
_sid_host_browser_metadata.pop(sid, None)
@@ -464,6 +496,9 @@ def host_browser_metadata_for_sid(sid: str) -> dict[str, Any] | None:
"profile_label": metadata.profile_label,
"profile_path": metadata.profile_path,
"cdp_endpoint": metadata.cdp_endpoint,
+ "browser_id": metadata.browser_id,
+ "browser_label": metadata.browser_label,
+ "available_browsers": copy.deepcopy(list(metadata.available_browsers)),
"content_helper_sha256": metadata.content_helper_sha256,
"features": list(metadata.features),
"support_reason": metadata.support_reason,
@@ -529,6 +564,10 @@ def all_host_browser_metadata() -> list[dict[str, Any]]:
"browser_family": metadata.browser_family,
"profile_label": metadata.profile_label,
"profile_path": metadata.profile_path,
+ "cdp_endpoint": metadata.cdp_endpoint,
+ "browser_id": metadata.browser_id,
+ "browser_label": metadata.browser_label,
+ "available_browsers": copy.deepcopy(list(metadata.available_browsers)),
"content_helper_sha256": metadata.content_helper_sha256,
"features": list(metadata.features),
"support_reason": metadata.support_reason,
diff --git a/plugins/_browser/AGENTS.md b/plugins/_browser/AGENTS.md
index 70211d3a0..a0e570a9d 100644
--- a/plugins/_browser/AGENTS.md
+++ b/plugins/_browser/AGENTS.md
@@ -19,7 +19,15 @@
- Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
- Keep the WebUI Browser inside its own modal/canvas affordance; do not replace it with page-level navigation.
- Default the visible WebUI Browser to live CDP screencast for responsiveness. Keep lightweight CDP/DOM state snapshots as the fallback transport.
+- Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the `