diff --git a/AGENTS.md b/AGENTS.md index 00a1570f9..31ab6a620 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,8 @@ [Generated using reconnaissance on 2026-02-22] ## Quick Reference -Tech Stack: Python 3.12+ | Flask | Alpine.js | LiteLLM | WebSocket (Socket.io) -Dev Server: python run_ui.py (runs on http://localhost:50001 by default) +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) @@ -41,25 +41,26 @@ Primary Language(s): Python, JavaScript (ES Modules) Do not combine these commands; run them individually: ```bash pip install -r requirements.txt -pip install -r requirements2.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. --- ## Docker Environment -When running in Docker, Agent Zero uses two distinct Python runtimes to isolate the framework from the code being executed: +When running in Docker, Agent Zero uses two distinct Python runtimes to isolate the framework itself from code executed on behalf of the agent: ### 1. Framework Runtime (/opt/venv-a0) - Version: Python 3.12.4 -- Purpose: Runs the Agent Zero backend, API, and core logic. -- Packages: Contains all dependencies from requirements.txt. +- 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. Execution Runtime (/opt/venv) +### 2. Agent Execution Runtime (/opt/venv) - Version: Python 3.13 -- Purpose: Default environment for the interactive terminal and the agent's code execution tool. -- Behavior: This is the environment active when you docker exec into the container. Packages installed by the agent via pip install during a task are stored here. +- 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. --- @@ -87,9 +88,9 @@ When running in Docker, Agent Zero uses two distinct Python runtimes to isolate ├── agents/ # Agent profiles (prompts and config) ├── prompts/ # System and message prompt templates ├── knowledge/ -│ └── main/about/ # Agent self-knowledge (indexed into vector DB for runtime recall) +│ └── main/about/ # Agent self-knowledge reference material │ ├── identity.md # Philosophy, principles, project context -│ ├── architecture.md # Agent loop, memory pipeline, multi-agent, extensions +│ ├── 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 @@ -97,12 +98,13 @@ When running in Docker, Agent Zero uses two distinct Python runtimes to isolate ``` Key Files: -- agent.py: Defines AgentContext and the main Agent class. +- 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, indexed into the vector DB for runtime recall. Not user-facing docs - written for the agent's internal reference. +- 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. @@ -240,7 +242,6 @@ If pip install fails, try running in a clean virtual environment: python -m venv .venv source .venv/bin/activate pip install -r requirements.txt -pip install -r requirements2.txt ``` ### WebSocket Connection Failures @@ -273,7 +274,7 @@ pip install -r requirements2.txt 6. Use the nearest AGENTS.md as the local contract and parent docs for repo-wide rules 7. If docs conflict, the closer doc controls local work details, but no child doc may weaken DOX -Do not rely on memory. Re-read the applicable DOX chain in the current session before editing. +Do not rely on prior context. Re-read the applicable DOX chain in the current session before editing. ## Update After Editing diff --git a/README.md b/README.md index 9b1dc4acf..45efd23d1 100644 --- a/README.md +++ b/README.md @@ -3,36 +3,62 @@ Agent Zero Banner # Agent Zero -### A full Linux system for your AI agent. +### Give your agent a full Linux computer. -Agent Zero is an open, dynamic, organic agentic framework. One Docker container ships a full Linux system with a desktop and a plugin hub that the agent can extend using Skills. +Agent Zero is an open agent framework for work that needs more than chat: a Dockerized Linux desktop, a browser with DOM annotation, live document cowork, projects, skills, plugins, and a bridge back to your host machine. [![Website](https://img.shields.io/badge/Website-agent--zero.ai-0A192F?style=for-the-badge&logo=vercel&logoColor=white)](https://agent-zero.ai) [![Docs](https://img.shields.io/badge/Docs-Read%20the%20guides-1F6FEB?style=for-the-badge&logo=readthedocs&logoColor=white)](./docs/) [![Discord](https://img.shields.io/badge/Discord-Join%20us-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/B8KZKNsPpj) [![GitHub Sponsors](https://img.shields.io/badge/Sponsors-Thank%20you-FF69B4?style=for-the-badge&logo=githubsponsors&logoColor=white)](https://github.com/sponsors/agent0ai) - -[Install](#how-to-install) | -[What's Different](#what-makes-agent-zero-different) | -[A0 CLI](#a0-cli-connector-extend-onto-your-host-machine) | -[Docs](#documentation) - [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/agent0ai/agent-zero) -[Ask ChatGPT](https://chatgpt.com/?q=Analyze%20this%3A%20https%3A%2F%2Fgithub.com%2Fagent0ai%2Fagent-zero) | -[Ask Claude](https://claude.ai/new?q=Analyze%20this%3A%20https%3A%2F%2Fgithub.com%2Fagent0ai%2Fagent-zero) +[Quick Start](#quick-start) | +[Why Agent Zero](#why-agent-zero) | +[Try These First](#try-these-first) | +[Deep Dives](#deep-dives) | +[Docs](#documentation)
- -Agent Zero Installation Guide - +Agent Zero driving Blender in its built-in XFCE desktop
-# What Makes Agent Zero Different +# Why Agent Zero -## How To Install +| Feature | Why it matters | +| --- | --- | +| **Full Linux desktop** | The agent can use real GUI software, terminals, files, and desktop apps inside the Canvas. | +| **Browser DOM annotation** | Click page elements and turn them into inspect, change, lift, or review instructions. | +| **Live document cowork** | Edit Markdown, Writer, Spreadsheet, and Presentation files together instead of losing work in chat. | +| **Plugin Hub** | Install 100+ community plugins or publish your own extension points. | +| **Projects and memory** | Keep files, instructions, secrets, memories, repositories, and model presets isolated per project. | +| **Host-machine bridge** | Connect with the A0 CLI so the same agent can work in your real local repositories. | +| **Multi-agent cooperation** | Let agents delegate research, coding, analysis, or review tasks to focused subagents. | +| **Transparent internals** | Prompts, tools, plugins, skills, and settings are inspectable and editable. | + +# Quick Start + +## Recommended: A0 Launcher + +The desktop **A0 Launcher** is the fastest guided path on a personal machine. Download it, open it, and let it check Docker, create Instances, manage ports, and connect to local or remote Agent Zero installs. + +Agent Zero runs wherever Docker runs, from a $6 VPS or Raspberry Pi to a local workstation or GPU server. + +| Architecture | macOS | Linux | Windows | +| --- | --- | --- | --- | +| x86 | [Mac Intel](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-macos-x64.dmg) | [Linux x86](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-linux-x64.AppImage) | [Windows x86](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-windows-x64.exe) | +| ARM64 | [Mac Apple Silicon](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-macos-arm64.dmg) | [Linux ARM64](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-linux-arm64.AppImage) | [Windows ARM64](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-windows-arm64.exe) | + +See the [A0 Launcher v1.2 release](https://github.com/agent0ai/a0-launcher/releases/tag/v1.2) for release notes and updater metadata. See the [Launcher guide](./docs/guides/launcher.md) for the first-run walkthrough. + +
+Other install paths + +## A0 Install + +Use **A0 Install** when you want the terminal path: SSH sessions, servers, recovery shells, or a scriptable setup. It creates Dockerized Agent Zero instances, mounts each instance's data into `/a0/usr` inside the container, and uses a reuse-before-setup policy: it tries your current Docker CLI configuration, `DOCKER_HOST`, Docker contexts, and known local Docker-compatible endpoints before setting up a runtime. ### macOS / Linux @@ -46,7 +72,21 @@ curl -fsSL https://bash.agent-zero.ai | bash irm https://ps.agent-zero.ai | iex ``` -### Docker already installed? Run this directly +### Headless / scripted + +For servers and automation, run the installer in Quick Start mode so it 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 @@ -54,10 +94,27 @@ docker run -p 80:80 -v a0_usr:/a0/usr agent0ai/agent-zero Open the Web UI, configure your LLM provider, and start with a concrete task. For the full setup and onboarding experience, see the [Installation guide](./docs/setup/installation.md). -## A Real Linux Desktop in the Canvas +
-Agent Zero driving Blender in its built-in XFCE desktop -
+## Troubleshooting + +- **Docker is not running:** start Docker Desktop or your Docker service, then reopen the Launcher or rerun the install command. +- **Port 80 is already in use:** use the Launcher to pick another port, or run Docker directly with `-p 5080:80` and open `http://localhost:5080`. +- **Installing on a server:** use the A0 Install Quick Start command with `--quick-start --name agent-zero --port 5080`. +- **Still blocked:** see the [Troubleshooting guide](./docs/guides/troubleshooting.md). + +# Try These First + +- **Annotate a design you like:** "Open this template site in the Browser. I'll annotate the hero section - re-implement it in my project's React + Tailwind stack." +- **Cowork on a spreadsheet:** "Create an editable ODS budget model with assumptions and monthly projections." +- **Drive a desktop app:** "Use the Linux Desktop to open Blender and create a simple 3D logo for me." +- **Review a web UI:** "Open my local app in the Browser. I will annotate the page with comments; then implement the requested UI fixes." +- **Create a specialist:** "Create an Agent Profile for financial analysis with cautious reasoning, clear assumptions, and spreadsheet-first deliverables." +- **Recover a workspace:** "Show me recent Time Travel snapshots and explain what changed before I revert anything." + +# Deep Dives + +## A Real Linux Desktop in the Canvas Agent Zero opens its own Linux desktop inside the right-side Canvas. Not a remote VM, not a shared clipboard, but a real XFCE desktop session running in the container. @@ -81,7 +138,7 @@ Annotate mode turns any webpage into an interactive directive surface. Click an - **Lift it** - see a card, hero, or component on someone else's site that you like? Capture it and have the agent re-implement it in your own project's stack. - **Comment it** - leave actionable notes pinned to elements during a UI review; the agent reads the comments and ships the fixes. -The Docker browser is the default live Browser surface. Browser history keeps screenshots of important steps, so older chats can still show what the agent saw. The Browser also supports Chrome extensions inside the Docker browser, and **Bring Your Own Browser** through the A0 CLI Connector lets the agent drive Chrome/Edge/Chromium on your own machine. +The Docker browser is the default live Browser surface. Browser history keeps screenshots of important steps, so older chats can still show what the agent saw. The Browser also supports Chrome extensions inside the Docker browser, and **Bring Your Own Browser** through the A0 CLI Connector lets the agent drive Chrome, Edge, Brave, Opera, Vivaldi, or Chromium on your own machine. See the [Browser guide](./docs/guides/browser.md) for screenshots, settings, host-browser setup, and troubleshooting. @@ -186,31 +243,9 @@ Almost nothing is hidden. Prompts live in `prompts/`, tools live in `tools/` or Agent Zero supports plugins, MCP, A2A, custom tools, custom prompts, project-scoped configuration, environment-based deployment settings, and a Web UI designed to keep the agent's work readable in real time. -## Try These First +## Time Travel -- **Annotate a design you like:** "Open this template site in the Browser. I'll annotate the hero section - re-implement it in my project's React + Tailwind stack." -- **Cowork on a spreadsheet:** "Create an editable ODS budget model with assumptions and monthly projections." -- **Drive a desktop app:** "Use the Linux Desktop to open Blender and create a simple 3D logo for me." -- **Review a web UI:** "Open my local app in the Browser. I will annotate the page with comments; then implement the requested UI fixes." -- **Create a specialist:** "Create an Agent Profile for financial analysis with cautious reasoning, clear assumptions, and spreadsheet-first deliverables." -- **Recover a workspace:** "Show me recent Time Travel snapshots and explain what changed before I revert anything." - -## Agent Zero and Space Agent - -Agent Zero is the open framework and Linux-powered agent workbench. - -[Space Agent](https://github.com/agent0ai/space-agent) is our newer product direction for the agent-shaped workspace: a Space the agent can reshape from inside your browser, with live demos, a desktop app, and a path to running a real server for yourself or your team. - -

- Watch Space Agent on YouTube -

- -If you want the raw power and deep customizability of an agent with a full Linux system, start here with Agent Zero. If you want the polished Space experience for easier personal, team, desktop, or self-hosted use, explore [Space Agent](https://github.com/agent0ai/space-agent). - - -## Time Travel (powered by Space Agent) - -Time Travel gives Agent Zero-owned `/a0/usr` workspaces snapshot history, diff inspection, travel, and revert. It is designed for recoverable agent work: see what changed, compare files, inspect a past state, and roll back when needed. Try it in Space Agent as well (link above). +Time Travel gives Agent Zero-owned `/a0/usr` workspaces snapshot history, diff inspection, travel, and revert. It is designed for recoverable agent work: see what changed, compare files, inspect a past state, and roll back when needed. Time Travel @@ -228,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 | @@ -274,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/agent.py b/agent.py index 149c900e3..f8c902da7 100644 --- a/agent.py +++ b/agent.py @@ -1,4 +1,4 @@ -import asyncio, random, string, threading +import asyncio, json, random, re, string, threading from collections import OrderedDict from dataclasses import dataclass, field @@ -32,6 +32,15 @@ from typing import Callable from helpers.localization import Localization from helpers import extension from helpers.errors import RepairableException, InterventionException, HandledException +from helpers.llm_result import ( + LLMResult, + RESPONSE_METADATA_KEY, + function_call_output_item, + metadata_from_llm_result, + result_from_metadata, +) +from helpers.litellm_transport import ResponsesTransport +from helpers.responses_tools import build_responses_function_tools, original_tool_name class AgentContextType(Enum): USER = "user" @@ -329,6 +338,8 @@ class LoopData: self.system = [] self.user_message: history.Message | None = None self.history_output: list[history.OutputMessage] = [] + self.protocol_temporary: OrderedDict[str, history.MessageContent] = OrderedDict() + self.protocol_persistent: OrderedDict[str, history.MessageContent] = OrderedDict() self.extras_temporary: OrderedDict[str, history.MessageContent] = OrderedDict() self.extras_persistent: OrderedDict[str, history.MessageContent] = OrderedDict() self.last_response = "" @@ -346,6 +357,9 @@ class Agent: DATA_NAME_SUPERIOR = "_superior" DATA_NAME_SUBORDINATE = "_subordinate" DATA_NAME_CTX_WINDOW = "ctx_window" + DATA_NAME_RESPONSES_STATE = "responses_state" + DATA_NAME_RESPONSES_TOOL_NAME_MAP = "responses_tool_name_map" + DATA_NAME_RESPONSES_COMPUTER_SESSION = "responses_computer_session_id" @extension.extensible def __init__( @@ -468,11 +482,12 @@ class Agent: return stop_response # call main LLM - agent_response, _reasoning = await self.call_chat_model( + llm_result = await self.call_chat_model_turn( messages=prompt, response_callback=stream_callback, reasoning_callback=reasoning_callback, ) + agent_response = llm_result.response await self.handle_intervention(agent_response) # Notify extensions to finalize their stream filters @@ -492,7 +507,12 @@ class Agent: ): # if assistant_response is the same as last message in history, let him know # Append the assistant's response to the history log_item = self.loop_data.params_temporary.get("log_item_generating") - self.hist_add_ai_response(agent_response, id=log_item.id if log_item else "") + assistant_message = self.hist_add_ai_response( + agent_response, + id=log_item.id if log_item else "", + llm_result=llm_result, + ) + self._remember_llm_result_state(llm_result, assistant_message) # Append warning message to the history warning_msg = self.read_prompt("fw.msg_repeat.md") wmsg = self.hist_add_warning(message=warning_msg) @@ -504,9 +524,16 @@ class Agent: else: # otherwise proceed with tool # Append the assistant's response to the history log_item = self.loop_data.params_temporary.get("log_item_generating") - self.hist_add_ai_response(agent_response, id=log_item.id if log_item else "") + assistant_message = self.hist_add_ai_response( + agent_response, + id=log_item.id if log_item else "", + llm_result=llm_result, + ) + self._remember_llm_result_state(llm_result, assistant_message) # process tools requested in agent message - tools_result = await self.process_tools(agent_response) + tools_result = await self.process_llm_result_tools( + llm_result + ) if tools_result: # final response of message loop available return tools_result # break the execution if the task is done @@ -555,24 +582,28 @@ class Agent: # concatenate system prompt system_text = "\n\n".join(loop_data.system) - # join extras - extras = history.Message( # type: ignore[abstract] - False, - content=self.read_prompt( - "agent.context.extras.md", - extras=dirty_json.stringify( - {**loop_data.extras_persistent, **loop_data.extras_temporary} - ), - ), - ).output() + # join protocol and extras + protocol = self._build_context_message( + "agent.context.protocol.md", + "protocol", + {**loop_data.protocol_persistent, **loop_data.protocol_temporary}, + include_empty=False, + ) + extras = self._build_context_message( + "agent.context.extras.md", + "extras", + {**loop_data.extras_persistent, **loop_data.extras_temporary}, + include_empty=True, + ) + loop_data.protocol_temporary.clear() loop_data.extras_temporary.clear() - # convert history + extras to LLM format + # convert protocol + history + extras to LLM format history_langchain: list[BaseMessage] = history.output_langchain( - loop_data.history_output + extras + protocol + loop_data.history_output + extras ) - # build full prompt from system prompt, message history and extrS + # build full prompt from system prompt, protocol, message history and extras full_prompt: list[BaseMessage] = [ SystemMessage(content=system_text), *history_langchain, @@ -590,6 +621,24 @@ class Agent: return full_prompt + def _build_context_message( + self, + prompt_file: str, + variable_name: str, + values: dict[str, history.MessageContent], + include_empty: bool, + ) -> list[history.OutputMessage]: + if not include_empty and not values: + return [] + + return history.Message( # type: ignore[abstract] + False, + content=self.read_prompt( + prompt_file, + **{variable_name: dirty_json.stringify(values)}, + ), + ).output() + @extension.extensible async def handle_exception(self, location: str, exception: Exception): if exception: @@ -664,7 +713,12 @@ class Agent: @extension.extensible def hist_add_message( - self, ai: bool, content: history.MessageContent, tokens: int = 0, id: str = "" + self, + ai: bool, + content: history.MessageContent, + tokens: int = 0, + id: str = "", + metadata: dict[str, Any] | None = None, ): self.last_message = Localization.get().now() # Allow extensions to process content before adding to history @@ -673,7 +727,11 @@ class Agent: "hist_add_before", self, content_data=content_data, ai=ai ) return self.history.add_message( - ai=ai, content=content_data["content"], tokens=tokens, id=id + ai=ai, + content=content_data["content"], + tokens=tokens, + id=id, + metadata=metadata, ) @extension.extensible @@ -706,10 +764,17 @@ class Agent: return msg @extension.extensible - def hist_add_ai_response(self, message: str, id: str = ""): + def hist_add_ai_response( + self, message: str, id: str = "", llm_result: LLMResult | None = None + ): self.loop_data.last_response = message content = self.parse_prompt("fw.ai_response.md", message=message) - return self.hist_add_message(True, content=content, id=id) + return self.hist_add_message( + True, + content=content, + id=id, + metadata=metadata_from_llm_result(llm_result), + ) @extension.extensible def hist_add_warning(self, message: history.MessageContent, id: str = ""): @@ -719,13 +784,28 @@ class Agent: @extension.extensible def hist_add_tool_result(self, tool_name: str, tool_result: str, **kwargs): msg_id = kwargs.pop("id", "") + responses_item = kwargs.pop("_responses_output_item", None) or kwargs.pop( + "responses_item", None + ) + metadata = ( + { + RESPONSE_METADATA_KEY: { + "input_items": [responses_item], + "output_items": [], + "mode": "responses", + "state": "provider", + } + } + if isinstance(responses_item, dict) + else None + ) data = { "tool_name": tool_name, "tool_result": tool_result, **kwargs, } extension.call_extensions_sync("hist_add_tool_result", self, data=data) - return self.hist_add_message(False, content=data, id=msg_id) + return self.hist_add_message(False, content=data, id=msg_id, metadata=metadata) def concat_messages( self, messages @@ -830,6 +910,164 @@ class Agent: return response, reasoning + @extension.extensible + async def call_chat_model_turn( + self, + messages: list[BaseMessage], + response_callback: Callable[[str, str], Awaitable[str | None]] | None = None, + reasoning_callback: Callable[[str, str], Awaitable[None]] | None = None, + background: bool = False, + explicit_caching: bool = True, + ) -> LLMResult: + model = self.get_chat_model() + model_kwargs = getattr(model, "kwargs", {}) if model else {} + if isinstance(model_kwargs, dict) and model_kwargs.get("responses_delete_on_chat_delete") is False: + self.set_data("responses_delete_on_chat_delete", False) + response_tools, name_map = build_responses_function_tools(self) + self.set_data(Agent.DATA_NAME_RESPONSES_TOOL_NAME_MAP, name_map) + + call_data = { + "model": model, + "messages": messages, + "response_callback": response_callback, + "reasoning_callback": reasoning_callback, + "background": background, + "explicit_caching": explicit_caching, + "a0_responses_function_tools": response_tools, + } + + previous_state = self._responses_state_for_model(model) + if previous_state: + history_counter = int(previous_state.get("history_counter", 0) or 0) + call_data["previous_response_id"] = previous_state.get("response_id", "") + call_data["responses_input_items"] = self._responses_input_items_since( + model, + history_counter, + ) + call_data["responses_local_input_items"] = self._responses_prompt_input_items( + model, + messages, + ) + + await extension.call_extensions_async( + "chat_model_call_before", self, call_data=call_data + ) + + turn_kwargs = { + "a0_responses_function_tools": call_data.get( + "a0_responses_function_tools" + ), + "responses_local_input_items": call_data.get( + "responses_local_input_items" + ), + } + for key in ( + "responses_builtin_tools", + "responses_state", + "previous_response_id", + "responses_input_items", + ): + if call_data.get(key) is not None: + turn_kwargs[key] = call_data.get(key) + + llm_result = await call_data["model"].unified_turn( + messages=call_data["messages"], + reasoning_callback=call_data["reasoning_callback"], + response_callback=call_data["response_callback"], + rate_limiter_callback=( + self.rate_limiter_callback if not call_data["background"] else None + ), + explicit_caching=call_data["explicit_caching"], + **turn_kwargs, + ) + + downgraded = llm_result.capability.get("builtin_tool_downgrades") + if downgraded: + self.context.log.log( + type="info", + heading="Responses capability downgrade", + content=( + "Provider rejected Responses built-in tool(s); omitted: " + + ", ".join(str(item) for item in downgraded) + ), + ) + + await extension.call_extensions_async( + "chat_model_call_after", + self, + call_data=call_data, + response=llm_result.response, + reasoning=llm_result.reasoning, + ) + + return llm_result + + def _responses_state_for_model(self, model: Any) -> dict[str, Any]: + state = self.get_data(Agent.DATA_NAME_RESPONSES_STATE) + if not isinstance(state, dict): + return {} + provider_model_key = str(getattr(model, "model_name", "") or "") + if state.get("provider_model_key") != provider_model_key: + return {} + if not state.get("response_id"): + return {} + return state + + def _responses_input_items_since( + self, model: Any, sequence: int + ) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for message in self.history.messages_since(sequence): + items.extend(self._responses_input_items_for_message(model, message)) + return items + + def _responses_input_items_for_message( + self, model: Any, message: history.Message + ) -> list[dict[str, Any]]: + result = result_from_metadata(message.metadata) + if result: + if message.ai and result.output_items: + return [item.to_dict() for item in result.output_items] + if not message.ai and result.input_items: + return [dict(item) for item in result.input_items] + + output = message.output() + langchain_messages = history.output_langchain(output) + if hasattr(model, "_convert_messages"): + converted = model._convert_messages(langchain_messages) + return ResponsesTransport.input_from_messages(converted) + return [] + + def _responses_prompt_input_items( + self, model: Any, messages: list[BaseMessage] + ) -> list[dict[str, Any]]: + if not hasattr(model, "_convert_messages"): + return [] + converted = model._convert_messages(messages) + return ResponsesTransport.input_from_messages(converted) + + def _remember_llm_result_state( + self, llm_result: LLMResult, history_message: history.Message + ) -> None: + if not llm_result.response_id: + return + current = self.get_data(Agent.DATA_NAME_RESPONSES_STATE) + response_ids = [] + if isinstance(current, dict) and isinstance(current.get("response_ids"), list): + response_ids = [str(item) for item in current["response_ids"] if item] + if llm_result.response_id not in response_ids: + response_ids.append(llm_result.response_id) + self.set_data( + Agent.DATA_NAME_RESPONSES_STATE, + { + "response_id": llm_result.response_id, + "previous_response_id": llm_result.previous_response_id, + "provider_model_key": llm_result.provider_model_key, + "history_counter": history_message.sequence, + "response_ids": response_ids, + }, + ) + @extension.extensible async def rate_limiter_callback( self, message: str, key: str, total: int, limit: int @@ -863,6 +1101,310 @@ class Agent: while self.context.paused: await asyncio.sleep(0.1) + async def process_llm_result_tools(self, llm_result: LLMResult): + await self._log_response_builtin_items(llm_result) + if llm_result.function_calls: + for function_call in llm_result.function_calls: + name_map = self.get_data(Agent.DATA_NAME_RESPONSES_TOOL_NAME_MAP) + tool_name = original_tool_name(function_call.name, name_map) + response_item_factory = lambda response, call=function_call: function_call_output_item( + call.call_id, + response.message, + ) + result = await self._execute_tool_request( + tool_name=tool_name, + tool_args=function_call.arguments, + message=llm_result.response, + raw_tool_name=tool_name, + responses_item_factory=response_item_factory, + ) + if result: + return result + return None + if llm_result.builtin_items and not llm_result.response: + return None + if ( + llm_result.mode == "responses" + and llm_result.response + and extract_tools.json_parse_dirty(llm_result.response) is None + ): + return llm_result.response + return await self.process_tools(llm_result.response) + + async def _execute_tool_request( + self, + tool_name: str, + tool_args: dict, + message: str, + raw_tool_name: str = "", + responses_item_factory: Callable[[Any], dict[str, Any]] | None = None, + ): + raw_tool_name = raw_tool_name or tool_name + tool_method = None + tool = None + + try: + import helpers.mcp_handler as mcp_helper + + mcp_tool_candidate = mcp_helper.MCPConfig.get_instance().get_tool( + self, tool_name + ) + if mcp_tool_candidate: + tool = mcp_tool_candidate + except ImportError: + PrintStyle( + background_color="black", font_color="yellow", padding=True + ).print("MCP helper module not found. Skipping MCP tool lookup.") + except Exception as e: + PrintStyle(background_color="black", font_color="red", padding=True).print( + f"Failed to get MCP tool '{tool_name}': {e}" + ) + + if not tool: + tool = self.get_tool( + name=tool_name, + method=tool_method, + args=tool_args, + message=message, + loop_data=self.loop_data, + ) + + if not tool: + error_detail = ( + f"Tool '{raw_tool_name}' not found or could not be initialized." + ) + wmsg = self.hist_add_warning(error_detail) + PrintStyle(font_color="red", padding=True).print(error_detail) + self.context.log.log( + type="warning", + content=f"{self.agent_name}: {error_detail}", + id=wmsg.id, + ) + return None + + self.loop_data.current_tool = tool # type: ignore + try: + await self.handle_intervention() + + await tool.before_execution(**tool_args) + await self.handle_intervention() + + await extension.call_extensions_async( + "tool_execute_before", + self, + tool_args=tool_args or {}, + tool_name=tool_name, + ) + + response = await tool.execute(**tool_args) + await self.handle_intervention() + + await extension.call_extensions_async( + "tool_execute_after", + self, + response=response, + tool_name=tool_name, + ) + + if responses_item_factory: + response.additional = { + **(response.additional or {}), + "_responses_output_item": responses_item_factory(response), + } + + await tool.after_execution(response) + await self.handle_intervention() + + if response.break_loop: + self._clear_responses_pending_state() + return response.message + finally: + self.loop_data.current_tool = None + return None + + async def _log_response_builtin_items(self, llm_result: LLMResult) -> None: + for item in llm_result.builtin_items: + if item.type == "computer_call": + await self._handle_responses_computer_call(item.data) + continue + if item.type == "mcp_approval_request": + self._handle_responses_mcp_approval_request(item.data) + continue + self.context.log.log( + type="info", + heading=f"Responses tool item: {item.type}", + content=json.dumps(item.data, ensure_ascii=False, default=str), + ) + + async def _handle_responses_computer_call(self, item: dict[str, Any]) -> None: + safety_checks = item.get("pending_safety_checks") or item.get("safety_checks") + if safety_checks: + message = ( + "Responses computer_call requested safety-check acknowledgement. " + "Agent Zero requires explicit user acknowledgement before executing it." + ) + output_item = { + "type": "computer_call_output", + "call_id": str(item.get("call_id") or item.get("id") or ""), + "output": {"type": "input_text", "text": message}, + } + self.hist_add_tool_result( + "computer_call", + message, + responses_item=output_item, + ) + self.context.log.log(type="warning", content=message) + return + + args = self._computer_call_args(item) + if not args: + message = "Responses computer_call action is unsupported by Agent Zero." + output_item = { + "type": "computer_call_output", + "call_id": str(item.get("call_id") or item.get("id") or ""), + "output": {"type": "input_text", "text": message}, + } + self.hist_add_tool_result( + "computer_call", + message, + responses_item=output_item, + ) + self.context.log.log(type="warning", content=message) + return + + if args.get("action") != "start_session" and not args.get("session_id"): + session_id = str( + self.get_data(Agent.DATA_NAME_RESPONSES_COMPUTER_SESSION) or "" + ) + if session_id: + args["session_id"] = session_id + + response_item_factory = lambda response: self._computer_call_output_item( + item, + response, + ) + result = await self._execute_tool_request( + tool_name="computer_use_remote", + tool_args=args, + message=json.dumps(item, ensure_ascii=False, default=str), + raw_tool_name="computer_call", + responses_item_factory=response_item_factory, + ) + _ = result + + def _handle_responses_mcp_approval_request(self, item: dict[str, Any]) -> None: + request_id = str( + item.get("approval_request_id") or item.get("id") or item.get("call_id") or "" + ) + message = ( + "Responses MCP approval request received. Agent Zero denied it because " + "provider-hosted MCP approval requires explicit user approval." + ) + output_item = { + "type": "mcp_approval_response", + "approval_request_id": request_id, + "approve": False, + } + self.hist_add_tool_result( + "mcp_approval_request", + message, + responses_item=output_item, + ) + self.context.log.log( + type="warning", + heading="Responses MCP approval required", + content=message, + ) + + def _computer_call_args(self, item: dict[str, Any]) -> dict[str, Any]: + action = item.get("action") + action_data = dict(action) if isinstance(action, dict) else {} + action_type = str( + action_data.get("type") + or action_data.get("action") + or item.get("action_type") + or "" + ).strip().lower() + args: dict[str, Any] = {} + + if action_type in {"screenshot", "capture"}: + args["action"] = "capture" + elif action_type in {"move", "mousemove"}: + args.update({"action": "move", "x": action_data.get("x"), "y": action_data.get("y")}) + elif action_type in {"click", "double_click"}: + args.update( + { + "action": "click", + "x": action_data.get("x"), + "y": action_data.get("y"), + "button": action_data.get("button", "left"), + "count": 2 if action_type == "double_click" else action_data.get("count", 1), + } + ) + elif action_type == "scroll": + args.update( + { + "action": "scroll", + "dx": action_data.get("dx", action_data.get("scroll_x", 0)), + "dy": action_data.get("dy", action_data.get("scroll_y", 0)), + } + ) + elif action_type in {"keypress", "key"}: + args.update( + { + "action": "key", + "keys": action_data.get("keys") or action_data.get("key"), + } + ) + elif action_type in {"type", "input_text"}: + args.update({"action": "type", "text": action_data.get("text", "")}) + else: + return {} + + session_id = item.get("session_id") or action_data.get("session_id") + if session_id: + args["session_id"] = session_id + return args + + def _computer_call_output_item( + self, source_item: dict[str, Any], response: Any + ) -> dict[str, Any]: + output: dict[str, Any] = { + "type": "input_text", + "text": str(getattr(response, "message", "") or ""), + } + additional = getattr(response, "additional", None) + raw_content = additional.get("raw_content") if isinstance(additional, dict) else None + if isinstance(raw_content, list): + for content in raw_content: + if not isinstance(content, dict): + continue + if content.get("type") != "image_url": + continue + image_url = content.get("image_url") + url = image_url.get("url") if isinstance(image_url, dict) else image_url + if url: + output = {"type": "input_image", "image_url": url} + break + + session_id_match = re_search_session_id(str(getattr(response, "message", "") or "")) + if session_id_match: + self.set_data(Agent.DATA_NAME_RESPONSES_COMPUTER_SESSION, session_id_match) + + return { + "type": "computer_call_output", + "call_id": str(source_item.get("call_id") or source_item.get("id") or ""), + "output": output, + } + + def _clear_responses_pending_state(self) -> None: + state = self.get_data(Agent.DATA_NAME_RESPONSES_STATE) + if isinstance(state, dict): + state = dict(state) + state.pop("response_id", None) + state.pop("previous_response_id", None) + self.set_data(Agent.DATA_NAME_RESPONSES_STATE, state) + @extension.extensible async def process_tools(self, msg: str): # search for tool usage requests in agent message @@ -1037,3 +1579,8 @@ class Agent: loop_data=loop_data, **kwargs, ) + + +def re_search_session_id(text: str) -> str: + match = re.search(r"session_id=([A-Za-z0-9_.:-]+)", text or "") + return match.group(1) if match else "" diff --git a/agents/AGENTS.md b/agents/AGENTS.md index cce3cbc96..ae6bda3c5 100644 --- a/agents/AGENTS.md +++ b/agents/AGENTS.md @@ -31,4 +31,14 @@ ## Child DOX Index -No child DOX files. +Direct child DOX files: + +| Child | Scope | +| --- | --- | +| [_example/AGENTS.md](_example/AGENTS.md) | Reference profile demonstrating profile-local prompts, tools, and extensions. | +| [agent0/AGENTS.md](agent0/AGENTS.md) | Main user-facing Agent Zero profile metadata. | +| [default/AGENTS.md](default/AGENTS.md) | Base profile metadata and inherited prompt specifics. | +| [developer/AGENTS.md](developer/AGENTS.md) | Software development specialist profile. | +| [hacker/AGENTS.md](hacker/AGENTS.md) | Cyber security and penetration testing specialist profile. | +| [researcher/AGENTS.md](researcher/AGENTS.md) | Research, data analysis, and reporting specialist profile. | +| [tiny-local/AGENTS.md](tiny-local/AGENTS.md) | Small/local model profile with an action-first communication prompt. | diff --git a/agents/_example/AGENTS.md b/agents/_example/AGENTS.md new file mode 100644 index 000000000..555428ee6 --- /dev/null +++ b/agents/_example/AGENTS.md @@ -0,0 +1,33 @@ +# Example Agent Profile DOX + +## Purpose + +- Own the reference profile used to demonstrate bundled profile layout. +- Show how profile-local prompts, tools, and extensions fit beside `agent.yaml`. + +## Ownership + +- `agent.yaml` owns the example profile metadata. +- `prompts/` owns prompt override examples. +- `tools/` owns profile-local tool examples. +- `extensions/` owns profile-local lifecycle extension examples. + +## Local Contracts + +- Keep this profile generic, minimal, and safe to copy into user or plugin profile work. +- Do not add product behavior here that should live in a real bundled profile. +- Profile-local tools and extensions must follow the same contracts as root tools and extensions. + +## Work Guidance + +- Prefer simple examples that illustrate structure over complex behavior. +- Update related skill guidance when the example profile layout changes. + +## Verification + +- Manually inspect YAML and prompt filenames after edits. +- Run profile-loading tests when changing discovery or profile schema assumptions. + +## Child DOX Index + +No child DOX files. diff --git a/agents/agent0/AGENTS.md b/agents/agent0/AGENTS.md new file mode 100644 index 000000000..65528190a --- /dev/null +++ b/agents/agent0/AGENTS.md @@ -0,0 +1,31 @@ +# Agent 0 Profile DOX + +## Purpose + +- Own the main user-facing Agent Zero profile metadata. +- Keep the primary assistant profile discoverable and distinct from subordinate specialist profiles. + +## Ownership + +- `agent.yaml` owns the profile title, description, and delegation context. +- Prompt behavior is inherited from the default profile unless a local prompt override is added. + +## Local Contracts + +- Keep `Agent 0` suitable as the direct conversation agent for the system. +- Do not add narrow specialist behavior that belongs in `developer/`, `researcher/`, `hacker/`, or a custom user profile. +- Do not store user-specific preferences, provider settings, or secrets in this profile. + +## Work Guidance + +- Keep metadata concise because it appears in profile selection and delegation contexts. +- Coordinate substantial behavior changes with default prompts and WebUI profile selection. + +## Verification + +- Manually inspect `agent.yaml` for valid YAML after edits. +- Run profile-loading tests when changing schema or discovery behavior. + +## Child DOX Index + +No child DOX files. diff --git a/agents/default/AGENTS.md b/agents/default/AGENTS.md new file mode 100644 index 000000000..130d84cc1 --- /dev/null +++ b/agents/default/AGENTS.md @@ -0,0 +1,32 @@ +# Default Agent Profile DOX + +## Purpose + +- Own base profile metadata and default prompt specifics inherited by specialized profiles. +- Provide the shared behavior layer for bundled and custom profiles. + +## Ownership + +- `agent.yaml` owns default profile metadata. +- `agent.system.main.specifics.md` owns default profile-specific system prompt content. +- Additional prompt overrides under this directory become shared defaults unless a child profile overrides them. + +## Local Contracts + +- Keep default behavior broad, framework-compatible, and safe for inheritance. +- Avoid role-specific instructions that belong in specialist profiles. +- Prompt filenames must match the framework prompt override names they target. + +## Work Guidance + +- Prefer small, explicit prompt changes with clear inheritance impact. +- Check bundled specialist profiles after changing default behavior. + +## Verification + +- Manually inspect YAML and prompt rendering assumptions after edits. +- Run prompt/profile tests when changing inherited prompt behavior. + +## Child DOX Index + +No child DOX files. diff --git a/agents/developer/AGENTS.md b/agents/developer/AGENTS.md new file mode 100644 index 000000000..b2d189fff --- /dev/null +++ b/agents/developer/AGENTS.md @@ -0,0 +1,32 @@ +# Developer Agent Profile DOX + +## Purpose + +- Own the bundled software development specialist profile. +- Keep development, debugging, refactoring, and architecture behavior separate from general agent defaults. + +## Ownership + +- `agent.yaml` owns title, description, and delegation context for software development work. +- `prompts/` owns developer-specific prompt overrides when present. +- `extensions/` owns developer-specific lifecycle hooks when present. + +## Local Contracts + +- Keep this profile focused on software engineering tasks. +- Do not hardcode repository-local credentials, paths, or project-specific conventions. +- Prompt overrides must preserve the framework tool-call and response contracts. + +## Work Guidance + +- Align developer behavior with the root engineering and tool contracts. +- Prefer profile prompt edits over core prompt edits when the behavior is specific to development tasks. + +## Verification + +- Manually inspect `agent.yaml` for valid YAML after edits. +- Run prompt/profile tests when changing profile loading or developer prompt behavior. + +## Child DOX Index + +No child DOX files. diff --git a/agents/hacker/AGENTS.md b/agents/hacker/AGENTS.md new file mode 100644 index 000000000..9daeaed00 --- /dev/null +++ b/agents/hacker/AGENTS.md @@ -0,0 +1,31 @@ +# Hacker Agent Profile DOX + +## Purpose + +- Own the bundled cyber security and penetration testing specialist profile. +- Keep security-audit behavior scoped to this profile instead of default agent behavior. + +## Ownership + +- `agent.yaml` owns title, description, and delegation context for security work. +- `prompts/` owns security-specific prompt overrides when present. + +## Local Contracts + +- Keep the profile focused on authorized security analysis, vulnerability research, and defensive audit tasks. +- Do not add secrets, target-specific credentials, or local environment assumptions. +- Preserve the framework tool-call contract and safety expectations. + +## Work Guidance + +- Keep security instructions operational and bounded to legitimate testing contexts. +- Coordinate broad safety changes with core prompts and relevant tests. + +## Verification + +- Manually inspect `agent.yaml` for valid YAML after edits. +- Run prompt/profile tests when changing profile discovery or security prompt behavior. + +## Child DOX Index + +No child DOX files. diff --git a/agents/researcher/AGENTS.md b/agents/researcher/AGENTS.md new file mode 100644 index 000000000..dd7e41def --- /dev/null +++ b/agents/researcher/AGENTS.md @@ -0,0 +1,31 @@ +# Researcher Agent Profile DOX + +## Purpose + +- Own the bundled research, data analysis, and reporting specialist profile. +- Keep evidence-gathering and report-oriented behavior separate from general defaults. + +## Ownership + +- `agent.yaml` owns title, description, and delegation context for research work. +- `prompts/` owns researcher-specific prompt overrides when present. + +## Local Contracts + +- Keep this profile focused on information gathering, analysis, synthesis, and reporting. +- Do not bake in project-specific sources, credentials, or local paths. +- Preserve the framework tool-call and response contracts. + +## Work Guidance + +- Prefer prompt changes that improve citation, evidence handling, and analysis quality for research tasks. +- Coordinate broad research behavior changes with document or browser plugin contracts when relevant. + +## Verification + +- Manually inspect `agent.yaml` for valid YAML after edits. +- Run prompt/profile tests when changing discovery or researcher prompt behavior. + +## Child DOX Index + +No child DOX files. diff --git a/agents/tiny-local/AGENTS.md b/agents/tiny-local/AGENTS.md new file mode 100644 index 000000000..86715f4dc --- /dev/null +++ b/agents/tiny-local/AGENTS.md @@ -0,0 +1,38 @@ +# Tiny Local Agent Profile DOX + +## Purpose + +- Own the bundled Tiny Local profile for small/local chat models. +- Keep local-model behavior prompt-only and isolated from core framework execution. + +## Ownership + +- `agent.yaml` owns profile metadata for discovery and profile switching. +- `prompts/agent.system.main.communication.md` owns the local-model communication contract. +- `prompts/agent.system.main.solving.md` owns the local-model problem-solving contract and suppresses inherited visible reasoning requirements. +- `prompts/fw.msg_repeat.md` owns Tiny Local's profile-specific recovery instructions when the framework rejects a duplicate assistant message. +- `prompts/agent.system.tools.md` owns the Tiny Local tools wrapper and final output-shape reminder after tool listing. +- `prompts/agent.system.tool.*.md` files own Tiny Local-specific tool examples that avoid inherited reasoning fields and repeated writes. + +## Local Contracts + +- Preserve the normal Agent Zero tool-call shape: `tool_name` plus `tool_args`. +- Do not add parser repair, duplicate suppression runtime, model transport, or text-editor runtime behavior here. +- Duplicate-message handling may be tightened through profile prompts only. +- Keep prompt text short enough for small local models to follow. +- Treat continuation requests such as `proceed` or `continue` as commands to execute the next unfinished step, not as prompts for another status response. +- Do not include user-specific provider names, API keys, local paths, or secrets. + +## Work Guidance + +- Prefer prompt wording changes over new files when tightening this profile, except when replacing inherited tool examples for local-model compliance. +- Keep this profile suitable for Ollama, LM Studio, Qwen, and comparable local models. + +## Verification + +- Render the `tiny-local` system prompt after communication prompt changes. +- Run `pytest tests/test_default_prompt_budget.py` for prompt and profile regressions. + +## Child DOX Index + +No child DOX files. diff --git a/agents/tiny-local/agent.yaml b/agents/tiny-local/agent.yaml new file mode 100644 index 000000000..413dcbfbf --- /dev/null +++ b/agents/tiny-local/agent.yaml @@ -0,0 +1,3 @@ +title: Tiny Local +description: Action-first profile for small local models that need a minimal tool-call contract. +context: Use this agent when running small local chat models through Ollama, LM Studio, or similar providers and the model tends to explain actions instead of calling tools. diff --git a/agents/tiny-local/prompts/agent.system.main.communication.md b/agents/tiny-local/prompts/agent.system.main.communication.md new file mode 100644 index 000000000..65f79edef --- /dev/null +++ b/agents/tiny-local/prompts/agent.system.main.communication.md @@ -0,0 +1,31 @@ +## Communication + +You are Agent Zero. Act on the user's behalf. + +When the user asks you to do something, do it directly. Do not explain how the user could do it themselves. + +Your visible assistant message must be exactly one valid JSON object. + +Use exactly these top-level fields: `"tool_name"` and `"tool_args"`. + +Do not include markdown fences, prose before the JSON, prose after the JSON, hidden reasoning, analysis, thoughts, or headlines. + +Choose a tool from the tools listed in this system prompt. Do not invent tool names, action names, or generic names such as `read`, `write`, `terminal`, or `multi`. + +For a final user-facing answer, use the `response` tool. + +Use `response` only when the work is complete, blocked, or the user is only acknowledging completed work. + +If the user says "proceed", "continue", "go ahead", "do it", "excellent proceed", or similar after you named a next step or there is unfinished work, do not answer with a promise or status update. Call the next appropriate tool. + +Final-answer shape: + +`{"tool_name":"response","tool_args":{"text":"Answer briefly."}}` + +For work that requires a command, file action, browser action, or any other available tool, call the appropriate tool immediately. Do not explain what command the user could run manually. + +If the framework warns that your prior message was malformed, repeated, or reasoning-only, output a corrected JSON tool request immediately without explaining the warning. + +When the warning says you sent the same message again, do not resend the same JSON. Change the tool, action, arguments, or final answer so the next message is meaningfully different. + +{{ include "agent.system.main.communication_additions.md" }} diff --git a/agents/tiny-local/prompts/agent.system.main.solving.md b/agents/tiny-local/prompts/agent.system.main.solving.md new file mode 100644 index 000000000..2e410be4a --- /dev/null +++ b/agents/tiny-local/prompts/agent.system.main.solving.md @@ -0,0 +1,18 @@ +## Problem Solving + +Act directly and keep hidden reasoning out of the visible JSON. + +For simple questions, answer with the `response` tool. + +Continuation words such as "proceed", "continue", "go ahead", "do it", and "excellent proceed" mean execute the next unfinished step. Do not respond by saying you will begin, continue, start, proceed, or investigate. Use a real tool call unless the task is already complete or blocked. + +For tasks that need shell commands, files, browser actions, or other capabilities: +- choose the appropriate listed tool immediately +- keep one tool call per turn unless the `parallel` tool is listed and truly useful +- inspect outputs before deciding the next tool call +- never claim success from timeout output or a still-running command +- after a successful tool result, do not repeat the same exact tool call +- after a repeated-message warning, do not repeat the same status response or exact tool request; choose the next different executable action or report a blocker +- when finished, use the `response` tool with a brief result + +Do not include `thoughts`, `headline`, analysis, plans, or prose outside the JSON object. diff --git a/agents/tiny-local/prompts/agent.system.tool.code_exe.md b/agents/tiny-local/prompts/agent.system.tool.code_exe.md new file mode 100644 index 000000000..a42a3314f --- /dev/null +++ b/agents/tiny-local/prompts/agent.system.tool.code_exe.md @@ -0,0 +1,24 @@ +### code_execution_tool +Run terminal, Python, or Node.js commands. + +Arguments in `tool_args`: +- `runtime`: `terminal`, `python`, `nodejs`, or `output` +- `code`: command or script code +- `session`: terminal session id; default `0` +- `reset`: kill a session before running; `true` or `false` + +Rules: +- Put the command or script in `code`. +- Use `runtime=output` to poll running work. +- Use `input` for interactive terminal prompts. +- If a session is stuck, call this tool again with the same `session` and `reset=true`. +- Do not claim success from timeout output or a still-running command. +- When counting files, prefer `find` over `ls` so hidden files and type filters are handled. + +Examples: + +`{"tool_name":"code_execution_tool","tool_args":{"runtime":"terminal","session":0,"reset":false,"code":"ls -1 /tmp | wc -l"}}` + +`{"tool_name":"code_execution_tool","tool_args":{"runtime":"python","session":0,"reset":false,"code":"import os\nprint(os.getcwd())"}}` + +`{"tool_name":"code_execution_tool","tool_args":{"runtime":"output","session":0}}` diff --git a/agents/tiny-local/prompts/agent.system.tool.response.md b/agents/tiny-local/prompts/agent.system.tool.response.md new file mode 100644 index 000000000..de0709179 --- /dev/null +++ b/agents/tiny-local/prompts/agent.system.tool.response.md @@ -0,0 +1,13 @@ +### response +Final answer to the user. + +Use this tool only when the task is done, blocked, or no tool is needed. + +Do not use this tool for "proceed", "continue", "go ahead", or similar continuation requests when there is an unfinished next step. Call a real tool instead. + +Arguments in `tool_args`: +- `text`: concise final answer text + +Example: + +`{"tool_name":"response","tool_args":{"text":"There are 24 files in /tmp."}}` diff --git a/agents/tiny-local/prompts/agent.system.tool.text_editor.md b/agents/tiny-local/prompts/agent.system.tool.text_editor.md new file mode 100644 index 000000000..fdf7ad141 --- /dev/null +++ b/agents/tiny-local/prompts/agent.system.tool.text_editor.md @@ -0,0 +1,26 @@ +### text_editor +Read, write, or patch Markdown and plain text files. + +Actions in `tool_args.action`: +- `read`: read a file +- `write`: create or overwrite a file +- `patch`: edit an existing file + +Common arguments: +- `path`: absolute file path +- `content`: full file content for `write` +- `open_in_canvas`: set `true` when the user explicitly asks to open a Markdown file in the Canvas or Editor + +Rules: +- Use this tool for `.md` and plain text files. +- Use `write` to create a new Markdown file. +- If the user asks to open the file in the Canvas or Editor, include `"open_in_canvas": true` in the same `write` or `patch` call. +- After a successful write or patch result, do not repeat the same tool call. Use the `response` tool unless a different action is needed. + +Examples: + +`{"tool_name":"text_editor","tool_args":{"action":"write","path":"/a0/usr/workdir/TODO.md","content":"# TODO\n- [ ] First item\n","open_in_canvas":true}}` + +`{"tool_name":"text_editor","tool_args":{"action":"read","path":"/a0/usr/workdir/TODO.md"}}` + +`{"tool_name":"text_editor","tool_args":{"action":"patch","path":"/a0/usr/workdir/TODO.md","old_text":"- [ ] First item","new_text":"- [x] First item"}}` diff --git a/agents/tiny-local/prompts/agent.system.tools.md b/agents/tiny-local/prompts/agent.system.tools.md new file mode 100644 index 000000000..7c50f33b0 --- /dev/null +++ b/agents/tiny-local/prompts/agent.system.tools.md @@ -0,0 +1,17 @@ +## Available Tools + +Use only the tools listed below. Match tool names exactly. + +Every tool request must be exactly one JSON object with only these top-level fields: +- `tool_name` +- `tool_args` + +Action names are not tool names. Do not invent top-level `multi`, `read`, `write`, `terminal`, or generic batch tools. + +{{tools}} + +## Tiny Local Output Rule + +Some inherited tool examples may show `thoughts` or `headline`. Ignore that shape for this profile. + +Do not include `thoughts`, `headline`, analysis, markdown fences, or prose outside the JSON object. diff --git a/agents/tiny-local/prompts/fw.msg_repeat.md b/agents/tiny-local/prompts/fw.msg_repeat.md new file mode 100644 index 000000000..10602a558 --- /dev/null +++ b/agents/tiny-local/prompts/fw.msg_repeat.md @@ -0,0 +1,13 @@ +You have sent the same message again. You have to do something else. + +Your repeated JSON was recorded, but it did not execute another tool. Do not send the same JSON object again. + +Choose one different action now: +- If work is unfinished, call a real tool for the next unfinished step. +- If your previous JSON used `response` while work remains, replace it with the next real tool call. +- If a file write or patch already succeeded, read that file or answer with the observed result. +- If a command already ran, inspect its output or run a different next command. +- If the user only said "proceed" or "continue", continue with the next real tool call. +- If no different action is possible, use `response` with a brief blocker. + +Output exactly one JSON object with `tool_name` and `tool_args`. No prose or markdown. diff --git a/api/AGENTS.md b/api/AGENTS.md index 7e471b399..02344d435 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -19,17 +19,23 @@ - Keep CSRF and authentication protections intact for browser-facing state-changing endpoints. - WebSocket handlers must derive from `helpers.ws.WsHandler` and validate event data before using it. - Do not return secrets, raw environment values, private files, or unfiltered exception details to clients. +- This directory is a file-documented DOX profile: every direct `*.py` endpoint or WebSocket 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 endpoint purpose, request/response concepts, auth/CSRF/API-key/loopback assumptions, side effects, important helper dependencies, and verification guidance. +- When a Python endpoint 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 endpoint deletion or rename. ## Work Guidance - Use helpers for shared behavior instead of duplicating persistence, auth, file, project, plugin, or notification logic in endpoints. - Keep request and response payloads stable; update frontend callers and tests together when payloads change. - Prefer `Response` for files, redirects, status codes, and plain-text errors; return dictionaries for JSON success payloads. +- During the DOX pass, verify that every direct `*.py` file has a matching `*.py.dox.md` and that changed endpoint behavior is described there. ## Verification - Run targeted `pytest tests/test_*api*.py`, endpoint-specific tests, or WebSocket tests after changing handler behavior. - For auth, CSRF, upload/download, tunnel, or file endpoints, run the nearest security regression tests. +- Check file-level documentation coverage with a script or shell loop that verifies each `api/*.py` has a matching `api/*.py.dox.md`. ## Child DOX Index diff --git a/api/agent_profile_set.py b/api/agent_profile_set.py index 331e21fd0..bec0b2ef1 100644 --- a/api/agent_profile_set.py +++ b/api/agent_profile_set.py @@ -1,4 +1,4 @@ -from agent import Agent, AgentContext +from agent import AgentContext from helpers import subagents from helpers.api import ApiHandler, Request, Response from helpers.persist_chat import save_tmp_chat @@ -39,11 +39,7 @@ class SetAgentProfile(ApiHandler): config = initialize_agent(override_settings={"agent_profile": profile}) context.config = config - - agent = context.agent0 - while agent: - agent.config = config - agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE) + context.agent0.config = config save_tmp_chat(context) mark_dirty_for_context(context.id, reason="agent_profile_change") diff --git a/api/agent_profile_set.py.dox.md b/api/agent_profile_set.py.dox.md new file mode 100644 index 000000000..8e891085d --- /dev/null +++ b/api/agent_profile_set.py.dox.md @@ -0,0 +1,48 @@ +# agent_profile_set.py DOX + +## Purpose + +- Own the `agent_profile_set.py` API endpoint. +- This module sets the active agent profile for a chat context and returns profile label metadata. +- Keep this file-level DOX profile synchronized with `agent_profile_set.py` because this directory is intentionally flat. + +## Ownership + +- `agent_profile_set.py` owns the runtime implementation. +- `agent_profile_set.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SetAgentProfile` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` +- Top-level functions: +- `_agent_profile_labels() -> dict[str, str]` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SetAgentProfile` is an `ApiHandler`. +- `SetAgentProfile` defines `process(...)`. +- Observed side-effect areas: filesystem writes, settings/state persistence. +- Switching a chat profile updates the context and top-level agent profile only; existing subordinate agents keep their own profile configs. +- Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.persist_chat`, `helpers.state_monitor_integration`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `str.strip`, `context.is_running`, `_agent_profile_labels`, `initialize_agent`, `context.agent0.config`, `save_tmp_chat`, `mark_dirty_for_context`, `subagents.get_all_agents_list`, `Response`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_subagent_profiles.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/agents.py.dox.md b/api/agents.py.dox.md new file mode 100644 index 000000000..e7f6895ac --- /dev/null +++ b/api/agents.py.dox.md @@ -0,0 +1,48 @@ +# agents.py DOX + +## Purpose + +- Own the `agents.py` API endpoint. +- This module lists available agent profiles for selection and delegation UI flows. +- Keep this file-level DOX profile synchronized with `agents.py` because this directory is intentionally flat. + +## Ownership + +- `agents.py` owns the runtime implementation. +- `agents.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Agents` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Agents` is an `ApiHandler`. +- `Agents` defines `process(...)`. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `subagents.get_all_agents_list`, `Exception`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_default_prompt_budget.py` + - `tests/test_office_document_store.py` + - `tests/test_projects.py` + - `tests/test_skills_runtime.py` + - `tests/test_time_travel.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/api_files_get.py.dox.md b/api/api_files_get.py.dox.md new file mode 100644 index 000000000..c14bf9f13 --- /dev/null +++ b/api/api_files_get.py.dox.md @@ -0,0 +1,52 @@ +# api_files_get.py DOX + +## Purpose + +- Own the `api_files_get.py` API endpoint. +- This module returns downloadable or inspectable files exposed through the external API surface. +- Keep this file-level DOX profile synchronized with `api_files_get.py` because this directory is intentionally flat. + +## Ownership + +- `api_files_get.py` owns the runtime implementation. +- `api_files_get.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ApiFilesGet` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_csrf(cls) -> bool` + - `requires_api_key(cls) -> bool` + - `get_methods(cls) -> list[str]` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `ApiFilesGet` is an `ApiHandler`. +- `ApiFilesGet` defines `process(...)`. +- `ApiFilesGet` defines `get_methods(...)`. +- `ApiFilesGet` defines `requires_auth(...)`. +- `ApiFilesGet` defines `requires_csrf(...)`. +- `ApiFilesGet` defines `requires_api_key(...)`. +- Observed side-effect areas: filesystem reads, filesystem writes, settings/state persistence. +- Imported dependency areas include: `base64`, `helpers`, `helpers.api`, `helpers.print_style`, `json`, `os`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `Response`, `PrintStyle.error`, `path.startswith`, `PrintStyle`, `json.dumps`, `path.replace`, `files.get_abs_path`, `os.path.basename`, `os.path.exists`, `PrintStyle.warning`, `f.read`, `base64.b64encode.decode`, `base64.b64encode`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/api_log_get.py.dox.md b/api/api_log_get.py.dox.md new file mode 100644 index 000000000..043a9c9cf --- /dev/null +++ b/api/api_log_get.py.dox.md @@ -0,0 +1,52 @@ +# api_log_get.py DOX + +## Purpose + +- Own the `api_log_get.py` API endpoint. +- This module returns API/chat log data for external API clients. +- Keep this file-level DOX profile synchronized with `api_log_get.py` because this directory is intentionally flat. + +## Ownership + +- `api_log_get.py` owns the runtime implementation. +- `api_log_get.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ApiLogGet` (`ApiHandler`) + - `get_methods(cls) -> list[str]` + - `requires_auth(cls) -> bool` + - `requires_csrf(cls) -> bool` + - `requires_api_key(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `ApiLogGet` is an `ApiHandler`. +- `ApiLogGet` defines `process(...)`. +- `ApiLogGet` defines `get_methods(...)`. +- `ApiLogGet` defines `requires_auth(...)`. +- `ApiLogGet` defines `requires_csrf(...)`. +- `ApiLogGet` defines `requires_api_key(...)`. +- Observed side-effect areas: settings/state persistence, secret handling. +- Imported dependency areas include: `agent`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `AgentContext.use`, `Response`, `context.log.output`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/api_message.py.dox.md b/api/api_message.py.dox.md new file mode 100644 index 000000000..646939cad --- /dev/null +++ b/api/api_message.py.dox.md @@ -0,0 +1,51 @@ +# api_message.py DOX + +## Purpose + +- Own the `api_message.py` API endpoint. +- This module accepts external API messages and dispatches them into Agent Zero chat processing. +- Keep this file-level DOX profile synchronized with `api_message.py` because this directory is intentionally flat. + +## Ownership + +- `api_message.py` owns the runtime implementation. +- `api_message.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ApiMessage` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_csrf(cls) -> bool` + - `requires_api_key(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `ApiMessage` is an `ApiHandler`. +- `ApiMessage` defines `process(...)`. +- `ApiMessage` defines `requires_auth(...)`. +- `ApiMessage` defines `requires_csrf(...)`. +- `ApiMessage` defines `requires_api_key(...)`. +- Observed side-effect areas: filesystem reads, filesystem writes, settings/state persistence, secret handling, scheduler state. +- Imported dependency areas include: `agent`, `base64`, `datetime`, `helpers`, `helpers.api`, `helpers.print_style`, `helpers.projects`, `helpers.security`, `initialize`, `os`, `uuid`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `context.set_data`, `datetime.now`, `Response`, `files.get_abs_path`, `os.makedirs`, `AgentContext.use`, `context.get_data`, `initialize_agent`, `AgentContext`, `context.log.log`, `context.communicate`, `ValueError`, `uuid.uuid4`, `UserMessage`, `task.result`, `PrintStyle.error`, `safe_filename`, `base64.b64decode`, `os.path.join`, `activate_project`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_api_chat_lifetime.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/api_reset_chat.py.dox.md b/api/api_reset_chat.py.dox.md new file mode 100644 index 000000000..5120f46cd --- /dev/null +++ b/api/api_reset_chat.py.dox.md @@ -0,0 +1,52 @@ +# api_reset_chat.py DOX + +## Purpose + +- Own the `api_reset_chat.py` API endpoint. +- This module resets an API-created chat context. +- Keep this file-level DOX profile synchronized with `api_reset_chat.py` because this directory is intentionally flat. + +## Ownership + +- `api_reset_chat.py` owns the runtime implementation. +- `api_reset_chat.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ApiResetChat` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_csrf(cls) -> bool` + - `requires_api_key(cls) -> bool` + - `get_methods(cls) -> list[str]` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `ApiResetChat` is an `ApiHandler`. +- `ApiResetChat` defines `process(...)`. +- `ApiResetChat` defines `get_methods(...)`. +- `ApiResetChat` defines `requires_auth(...)`. +- `ApiResetChat` defines `requires_csrf(...)`. +- `ApiResetChat` defines `requires_api_key(...)`. +- Observed side-effect areas: filesystem writes, filesystem deletion, settings/state persistence. +- Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.print_style`, `json`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `AgentContext.use`, `context.reset`, `persist_chat.save_tmp_chat`, `persist_chat.remove_msg_files`, `Response`, `PrintStyle.error`, `PrintStyle`, `json.dumps`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/api_terminate_chat.py.dox.md b/api/api_terminate_chat.py.dox.md new file mode 100644 index 000000000..541a6d007 --- /dev/null +++ b/api/api_terminate_chat.py.dox.md @@ -0,0 +1,52 @@ +# api_terminate_chat.py DOX + +## Purpose + +- Own the `api_terminate_chat.py` API endpoint. +- This module terminates an API-created chat context. +- Keep this file-level DOX profile synchronized with `api_terminate_chat.py` because this directory is intentionally flat. + +## Ownership + +- `api_terminate_chat.py` owns the runtime implementation. +- `api_terminate_chat.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ApiTerminateChat` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_csrf(cls) -> bool` + - `requires_api_key(cls) -> bool` + - `get_methods(cls) -> list[str]` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `ApiTerminateChat` is an `ApiHandler`. +- `ApiTerminateChat` defines `process(...)`. +- `ApiTerminateChat` defines `get_methods(...)`. +- `ApiTerminateChat` defines `requires_auth(...)`. +- `ApiTerminateChat` defines `requires_csrf(...)`. +- `ApiTerminateChat` defines `requires_api_key(...)`. +- Observed side-effect areas: filesystem writes, filesystem deletion, settings/state persistence. +- Imported dependency areas include: `agent`, `helpers.api`, `helpers.persist_chat`, `helpers.print_style`, `json`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `AgentContext.use`, `AgentContext.remove`, `remove_chat`, `Response`, `PrintStyle.error`, `PrintStyle`, `json.dumps`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/backup_create.py.dox.md b/api/backup_create.py.dox.md new file mode 100644 index 000000000..be6076326 --- /dev/null +++ b/api/backup_create.py.dox.md @@ -0,0 +1,49 @@ +# backup_create.py DOX + +## Purpose + +- Own the `backup_create.py` API endpoint. +- This module handles backup create requests. +- Keep this file-level DOX profile synchronized with `backup_create.py` because this directory is intentionally flat. + +## Ownership + +- `backup_create.py` owns the runtime implementation. +- `backup_create.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `BackupCreate` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_loopback(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `BackupCreate` is an `ApiHandler`. +- `BackupCreate` defines `process(...)`. +- `BackupCreate` defines `requires_auth(...)`. +- `BackupCreate` defines `requires_loopback(...)`. +- Observed side-effect areas: filesystem writes. +- Imported dependency areas include: `helpers.api`, `helpers.backup`, `helpers.persist_chat`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `save_tmp_chats`, `BackupService`, `send_file`, `backup_service.create_backup`, `line.strip`, `line.startswith`, `patterns_string.split`, `line.strip.startswith`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_download_toast_regressions.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/backup_get_defaults.py.dox.md b/api/backup_get_defaults.py.dox.md new file mode 100644 index 000000000..8f2c1966d --- /dev/null +++ b/api/backup_get_defaults.py.dox.md @@ -0,0 +1,47 @@ +# backup_get_defaults.py DOX + +## Purpose + +- Own the `backup_get_defaults.py` API endpoint. +- This module handles backup get defaults requests. +- Keep this file-level DOX profile synchronized with `backup_get_defaults.py` because this directory is intentionally flat. + +## Ownership + +- `backup_get_defaults.py` owns the runtime implementation. +- `backup_get_defaults.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `BackupGetDefaults` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_loopback(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `BackupGetDefaults` is an `ApiHandler`. +- `BackupGetDefaults` defines `process(...)`. +- `BackupGetDefaults` defines `requires_auth(...)`. +- `BackupGetDefaults` defines `requires_loopback(...)`. +- Imported dependency areas include: `helpers.api`, `helpers.backup`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `BackupService`, `backup_service.get_default_backup_metadata`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/backup_inspect.py.dox.md b/api/backup_inspect.py.dox.md new file mode 100644 index 000000000..363dd63dc --- /dev/null +++ b/api/backup_inspect.py.dox.md @@ -0,0 +1,47 @@ +# backup_inspect.py DOX + +## Purpose + +- Own the `backup_inspect.py` API endpoint. +- This module handles backup inspect requests. +- Keep this file-level DOX profile synchronized with `backup_inspect.py` because this directory is intentionally flat. + +## Ownership + +- `backup_inspect.py` owns the runtime implementation. +- `backup_inspect.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `BackupInspect` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_loopback(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `BackupInspect` is an `ApiHandler`. +- `BackupInspect` defines `process(...)`. +- `BackupInspect` defines `requires_auth(...)`. +- `BackupInspect` defines `requires_loopback(...)`. +- Imported dependency areas include: `helpers.api`, `helpers.backup`, `werkzeug.datastructures`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `BackupService`, `backup_service.inspect_backup`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/backup_preview_grouped.py.dox.md b/api/backup_preview_grouped.py.dox.md new file mode 100644 index 000000000..24e075981 --- /dev/null +++ b/api/backup_preview_grouped.py.dox.md @@ -0,0 +1,47 @@ +# backup_preview_grouped.py DOX + +## Purpose + +- Own the `backup_preview_grouped.py` API endpoint. +- This module handles backup preview grouped requests. +- Keep this file-level DOX profile synchronized with `backup_preview_grouped.py` because this directory is intentionally flat. + +## Ownership + +- `backup_preview_grouped.py` owns the runtime implementation. +- `backup_preview_grouped.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `BackupPreviewGrouped` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_loopback(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `BackupPreviewGrouped` is an `ApiHandler`. +- `BackupPreviewGrouped` defines `process(...)`. +- `BackupPreviewGrouped` defines `requires_auth(...)`. +- `BackupPreviewGrouped` defines `requires_loopback(...)`. +- Imported dependency areas include: `helpers.api`, `helpers.backup`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `BackupService`, `search_filter.strip`, `backup_service.test_patterns`, `search_filter.lower`, `path.strip.split`, `line.strip`, `line.startswith`, `groups.add`, `patterns_string.split`, `path.strip`, `join`, `f.lower`, `line.strip.startswith`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/backup_restore.py.dox.md b/api/backup_restore.py.dox.md new file mode 100644 index 000000000..78e74737d --- /dev/null +++ b/api/backup_restore.py.dox.md @@ -0,0 +1,49 @@ +# backup_restore.py DOX + +## Purpose + +- Own the `backup_restore.py` API endpoint. +- This module handles backup restore requests. +- Keep this file-level DOX profile synchronized with `backup_restore.py` because this directory is intentionally flat. + +## Ownership + +- `backup_restore.py` owns the runtime implementation. +- `backup_restore.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `BackupRestore` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_loopback(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `BackupRestore` is an `ApiHandler`. +- `BackupRestore` defines `process(...)`. +- `BackupRestore` defines `requires_auth(...)`. +- `BackupRestore` defines `requires_loopback(...)`. +- Observed side-effect areas: filesystem writes, filesystem deletion, settings/state persistence. +- Imported dependency areas include: `helpers.api`, `helpers.backup`, `helpers.persist_chat`, `json`, `werkzeug.datastructures`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `request.form.get.lower`, `json.loads`, `BackupService`, `load_tmp_chats`, `backup_service.restore_backup`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_self_update_tag_filter.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/backup_restore_preview.py.dox.md b/api/backup_restore_preview.py.dox.md new file mode 100644 index 000000000..572473a0a --- /dev/null +++ b/api/backup_restore_preview.py.dox.md @@ -0,0 +1,48 @@ +# backup_restore_preview.py DOX + +## Purpose + +- Own the `backup_restore_preview.py` API endpoint. +- This module handles backup restore preview requests. +- Keep this file-level DOX profile synchronized with `backup_restore_preview.py` because this directory is intentionally flat. + +## Ownership + +- `backup_restore_preview.py` owns the runtime implementation. +- `backup_restore_preview.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `BackupRestorePreview` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_loopback(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `BackupRestorePreview` is an `ApiHandler`. +- `BackupRestorePreview` defines `process(...)`. +- `BackupRestorePreview` defines `requires_auth(...)`. +- `BackupRestorePreview` defines `requires_loopback(...)`. +- Observed side-effect areas: filesystem deletion, settings/state persistence. +- Imported dependency areas include: `helpers.api`, `helpers.backup`, `json`, `werkzeug.datastructures`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `request.form.get.lower`, `json.loads`, `BackupService`, `backup_service.preview_restore`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/backup_test.py b/api/backup_test.py index b5b43eac8..c7a748ebd 100644 --- a/api/backup_test.py +++ b/api/backup_test.py @@ -47,12 +47,13 @@ class BackupTest(ApiHandler): backup_service = BackupService() matched_files = await backup_service.test_patterns(metadata, max_files=max_files) + truncated = max_files is not None and len(matched_files) >= max_files return { "success": True, "files": matched_files, "total_count": len(matched_files), - "truncated": len(matched_files) >= max_files + "truncated": truncated } except Exception as e: diff --git a/api/backup_test.py.dox.md b/api/backup_test.py.dox.md new file mode 100644 index 000000000..a8e06cc6b --- /dev/null +++ b/api/backup_test.py.dox.md @@ -0,0 +1,48 @@ +# backup_test.py DOX + +## Purpose + +- Own the `backup_test.py` API endpoint. +- This module handles backup test requests. +- Keep this file-level DOX profile synchronized with `backup_test.py` because this directory is intentionally flat. + +## Ownership + +- `backup_test.py` owns the runtime implementation. +- `backup_test.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `BackupTest` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_loopback(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `BackupTest` is an `ApiHandler`. +- `BackupTest` defines `process(...)`. +- `BackupTest` defines `requires_auth(...)`. +- `BackupTest` defines `requires_loopback(...)`. +- Imported dependency areas include: `helpers.api`, `helpers.backup`. +- The `truncated` response flag is true only when a finite `max_files` limit is supplied and the result reaches that limit. + +## Key Concepts + +- Important called helpers/classes observed in the source: `BackupService`, `backup_service.test_patterns`, `line.strip`, `line.startswith`, `patterns_string.split`, `line.strip.startswith`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/banners.py.dox.md b/api/banners.py.dox.md new file mode 100644 index 000000000..9d007e7d5 --- /dev/null +++ b/api/banners.py.dox.md @@ -0,0 +1,46 @@ +# banners.py DOX + +## Purpose + +- Own the `banners.py` API endpoint. +- This module collects alert banners and discovery cards from backend extensions. +- Keep this file-level DOX profile synchronized with `banners.py` because this directory is intentionally flat. + +## Ownership + +- `banners.py` owns the runtime implementation. +- `banners.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `GetBanners` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `GetBanners` is an `ApiHandler`. +- `GetBanners` defines `process(...)`. +- Imported dependency areas include: `helpers.api`, `helpers.extension`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `call_extensions_async`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_model_config_api_keys.py` + - `tests/test_oauth_static.py` + - `tests/test_webui_extension_surfaces.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/cache_reset.py.dox.md b/api/cache_reset.py.dox.md new file mode 100644 index 000000000..a7164d5d5 --- /dev/null +++ b/api/cache_reset.py.dox.md @@ -0,0 +1,53 @@ +# cache_reset.py DOX + +## Purpose + +- Own the `cache_reset.py` API endpoint. +- This module handles cache reset API requests. +- Keep this file-level DOX profile synchronized with `cache_reset.py` because this directory is intentionally flat. + +## Ownership + +- `cache_reset.py` owns the runtime implementation. +- `cache_reset.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `CacheReset` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_csrf(cls) -> bool` + - `requires_api_key(cls) -> bool` + - `requires_loopback(cls) -> bool` + - `get_methods(cls) -> list[str]` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `CacheReset` is an `ApiHandler`. +- `CacheReset` defines `process(...)`. +- `CacheReset` defines `get_methods(...)`. +- `CacheReset` defines `requires_auth(...)`. +- `CacheReset` defines `requires_csrf(...)`. +- `CacheReset` defines `requires_api_key(...)`. +- `CacheReset` defines `requires_loopback(...)`. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `cache.clear_all`, `cache.clear`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/chat_create.py.dox.md b/api/chat_create.py.dox.md new file mode 100644 index 000000000..3546f4389 --- /dev/null +++ b/api/chat_create.py.dox.md @@ -0,0 +1,45 @@ +# chat_create.py DOX + +## Purpose + +- Own the `chat_create.py` API endpoint. +- This module handles chat create requests. +- Keep this file-level DOX profile synchronized with `chat_create.py` because this directory is intentionally flat. + +## Ownership + +- `chat_create.py` owns the runtime implementation. +- `chat_create.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `CreateChat` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `CreateChat` is an `ApiHandler`. +- `CreateChat` defines `process(...)`. +- Observed side-effect areas: filesystem writes, model calls, plugin state, settings/state persistence. +- Imported dependency areas include: `agent`, `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.use_context`, `mark_dirty_all`, `guids.generate_id`, `current_context.get_data`, `current_context.get_output_data`, `new_context.set_data`, `new_context.set_output_data`, `is_chat_override_allowed`, `settings.get_settings`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_browser_agent_regressions.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/chat_export.py.dox.md b/api/chat_export.py.dox.md new file mode 100644 index 000000000..03b5a0c91 --- /dev/null +++ b/api/chat_export.py.dox.md @@ -0,0 +1,44 @@ +# chat_export.py DOX + +## Purpose + +- Own the `chat_export.py` API endpoint. +- This module handles chat export requests. +- Keep this file-level DOX profile synchronized with `chat_export.py` because this directory is intentionally flat. + +## Ownership + +- `chat_export.py` owns the runtime implementation. +- `chat_export.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ExportChat` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `ExportChat` is an `ApiHandler`. +- `ExportChat` defines `process(...)`. +- Observed side-effect areas: filesystem writes. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.use_context`, `persist_chat.export_json_chat`, `Exception`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/chat_files_path_get.py.dox.md b/api/chat_files_path_get.py.dox.md new file mode 100644 index 000000000..63f0a2b6e --- /dev/null +++ b/api/chat_files_path_get.py.dox.md @@ -0,0 +1,44 @@ +# chat_files_path_get.py DOX + +## Purpose + +- Own the `chat_files_path_get.py` API endpoint. +- This module handles chat files path get requests. +- Keep this file-level DOX profile synchronized with `chat_files_path_get.py` because this directory is intentionally flat. + +## Ownership + +- `chat_files_path_get.py` owns the runtime implementation. +- `chat_files_path_get.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `GetChatFilesPath` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `GetChatFilesPath` is an `ApiHandler`. +- `GetChatFilesPath` defines `process(...)`. +- Observed side-effect areas: settings/state persistence. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.use_context`, `projects.get_context_project_name`, `Exception`, `files.normalize_a0_path`, `projects.get_project_folder`, `settings.get_settings`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/chat_load.py.dox.md b/api/chat_load.py.dox.md new file mode 100644 index 000000000..4718a9fc6 --- /dev/null +++ b/api/chat_load.py.dox.md @@ -0,0 +1,44 @@ +# chat_load.py DOX + +## Purpose + +- Own the `chat_load.py` API endpoint. +- This module handles chat load requests. +- Keep this file-level DOX profile synchronized with `chat_load.py` because this directory is intentionally flat. + +## Ownership + +- `chat_load.py` owns the runtime implementation. +- `chat_load.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `LoadChats` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `LoadChats` is an `ApiHandler`. +- `LoadChats` defines `process(...)`. +- Observed side-effect areas: filesystem writes. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `persist_chat.load_json_chats`, `Exception`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/chat_remove.py.dox.md b/api/chat_remove.py.dox.md new file mode 100644 index 000000000..ac1ffc0f8 --- /dev/null +++ b/api/chat_remove.py.dox.md @@ -0,0 +1,44 @@ +# chat_remove.py DOX + +## Purpose + +- Own the `chat_remove.py` API endpoint. +- This module handles chat remove requests. +- Keep this file-level DOX profile synchronized with `chat_remove.py` because this directory is intentionally flat. + +## Ownership + +- `chat_remove.py` owns the runtime implementation. +- `chat_remove.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `RemoveChat` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `RemoveChat` is an `ApiHandler`. +- `RemoveChat` defines `process(...)`. +- Observed side-effect areas: filesystem writes, filesystem deletion, settings/state persistence, scheduler state. +- Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.task_scheduler`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `scheduler.cancel_tasks_by_context`, `AgentContext.use`, `AgentContext.remove`, `persist_chat.remove_chat`, `scheduler.get_tasks_by_context_id`, `mark_dirty_all`, `context.reset`, `scheduler.reload`, `scheduler.remove_task_by_uuid`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/chat_reset.py.dox.md b/api/chat_reset.py.dox.md new file mode 100644 index 000000000..39a886a53 --- /dev/null +++ b/api/chat_reset.py.dox.md @@ -0,0 +1,44 @@ +# chat_reset.py DOX + +## Purpose + +- Own the `chat_reset.py` API endpoint. +- This module handles chat reset requests. +- Keep this file-level DOX profile synchronized with `chat_reset.py` because this directory is intentionally flat. + +## Ownership + +- `chat_reset.py` owns the runtime implementation. +- `chat_reset.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Reset` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Reset` is an `ApiHandler`. +- `Reset` defines `process(...)`. +- Observed side-effect areas: filesystem writes, filesystem deletion, settings/state persistence, scheduler state. +- Imported dependency areas include: `helpers`, `helpers.api`, `helpers.task_scheduler`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `TaskScheduler.get.cancel_tasks_by_context`, `self.use_context`, `context.reset`, `persist_chat.save_tmp_chat`, `persist_chat.remove_msg_files`, `mark_dirty_all`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/csrf_token.py.dox.md b/api/csrf_token.py.dox.md new file mode 100644 index 000000000..3570d2471 --- /dev/null +++ b/api/csrf_token.py.dox.md @@ -0,0 +1,57 @@ +# csrf_token.py DOX + +## Purpose + +- Own the `csrf_token.py` API endpoint. +- This module issues or refreshes CSRF tokens for browser API clients. +- Keep this file-level DOX profile synchronized with `csrf_token.py` because this directory is intentionally flat. + +## Ownership + +- `csrf_token.py` owns the runtime implementation. +- `csrf_token.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `GetCsrfToken` (`ApiHandler`) + - `get_methods(cls) -> list[str]` + - `requires_csrf(cls) -> bool` + - `async process(self, input: Input, request: Request) -> Output` + - `async check_allowed_origin(self, request: Request)` + - `async is_allowed_origin(self, request: Request)` + - `get_origin_from_request(self, request: Request)` + - `async get_allowed_origins(self) -> list[str]` + - `get_default_allowed_origins(self) -> list[str]` +- Notable constants/configuration names: `ALLOWED_ORIGINS_KEY`. + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `GetCsrfToken` is an `ApiHandler`. +- `GetCsrfToken` defines `process(...)`. +- `GetCsrfToken` defines `get_methods(...)`. +- `GetCsrfToken` defines `requires_csrf(...)`. +- Observed side-effect areas: filesystem writes, network calls, secret handling, tunnel state. +- Imported dependency areas include: `fnmatch`, `helpers`, `helpers.api`, `secrets`, `urllib.parse`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `login.is_login_required`, `self.initialize_allowed_origins`, `self.get_origin_from_request`, `urlparse`, `dotenv.get_dotenv_value`, `self.get_default_allowed_origins`, `dotenv.save_dotenv_value`, `self.check_allowed_origin`, `secrets.token_urlsafe`, `runtime.get_runtime_id`, `self.is_allowed_origin`, `self.get_allowed_origins`, `origin.strip`, `join`, `fnmatch.fnmatch`, `split`, `tunnel_api_process`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_http_auth_csrf.py` + - `tests/test_self_update_tag_filter.py` + - `tests/test_ws_security.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/ctx_window_get.py.dox.md b/api/ctx_window_get.py.dox.md new file mode 100644 index 000000000..2de01bf37 --- /dev/null +++ b/api/ctx_window_get.py.dox.md @@ -0,0 +1,44 @@ +# ctx_window_get.py DOX + +## Purpose + +- Own the `ctx_window_get.py` API endpoint. +- This module handles ctx window get API requests. +- Keep this file-level DOX profile synchronized with `ctx_window_get.py` because this directory is intentionally flat. + +## Ownership + +- `ctx_window_get.py` owns the runtime implementation. +- `ctx_window_get.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `GetCtxWindow` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `GetCtxWindow` is an `ApiHandler`. +- `GetCtxWindow` defines `process(...)`. +- Observed side-effect areas: secret handling. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.use_context`, `agent.get_data`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/delete_work_dir_file.py.dox.md b/api/delete_work_dir_file.py.dox.md new file mode 100644 index 000000000..536b70fcb --- /dev/null +++ b/api/delete_work_dir_file.py.dox.md @@ -0,0 +1,46 @@ +# delete_work_dir_file.py DOX + +## Purpose + +- Own the `delete_work_dir_file.py` API endpoint. +- This module handles workdir file operations for delete work dir file. +- Keep this file-level DOX profile synchronized with `delete_work_dir_file.py` because this directory is intentionally flat. + +## Ownership + +- `delete_work_dir_file.py` owns the runtime implementation. +- `delete_work_dir_file.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `DeleteWorkDirFile` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` +- Top-level functions: +- `async delete_file(file_path: str)` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `DeleteWorkDirFile` is an `ApiHandler`. +- `DeleteWorkDirFile` defines `process(...)`. +- Observed side-effect areas: filesystem deletion. +- Imported dependency areas include: `api`, `helpers`, `helpers.api`, `helpers.file_browser`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `FileBrowser`, `browser.delete_file`, `file_path.startswith`, `runtime.call_development_function`, `extension.call_extensions_async`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/delete_work_dir_files.py.dox.md b/api/delete_work_dir_files.py.dox.md new file mode 100644 index 000000000..2c68d3ca2 --- /dev/null +++ b/api/delete_work_dir_files.py.dox.md @@ -0,0 +1,47 @@ +# delete_work_dir_files.py DOX + +## Purpose + +- Own the `delete_work_dir_files.py` API endpoint. +- This module handles workdir file operations for delete work dir files. +- Keep this file-level DOX profile synchronized with `delete_work_dir_files.py` because this directory is intentionally flat. + +## Ownership + +- `delete_work_dir_files.py` owns the runtime implementation. +- `delete_work_dir_files.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `DeleteWorkDirFiles` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` +- Top-level functions: +- `async delete_files(paths: list[str]) -> dict` +- `collapse_nested_paths(paths: list[str]) -> list[str]` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `DeleteWorkDirFiles` is an `ApiHandler`. +- `DeleteWorkDirFiles` defines `process(...)`. +- Observed side-effect areas: filesystem deletion. +- Imported dependency areas include: `api`, `api.download_work_dir_files`, `helpers`, `helpers.api`, `helpers.file_browser`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `FileBrowser`, `collapse_nested_paths`, `browser.delete_file`, `normalize_paths`, `runtime.call_development_function`, `path.strip`, `extension.call_extensions_async`, `item.count`, `clean_path.startswith`, `parent.rstrip`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/download_work_dir_file.py.dox.md b/api/download_work_dir_file.py.dox.md new file mode 100644 index 000000000..87085765a --- /dev/null +++ b/api/download_work_dir_file.py.dox.md @@ -0,0 +1,53 @@ +# download_work_dir_file.py DOX + +## Purpose + +- Own the `download_work_dir_file.py` API endpoint. +- This module handles workdir file operations for download work dir file. +- Keep this file-level DOX profile synchronized with `download_work_dir_file.py` because this directory is intentionally flat. + +## Ownership + +- `download_work_dir_file.py` owns the runtime implementation. +- `download_work_dir_file.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `DownloadFile` (`ApiHandler`) + - `get_methods(cls)` + - `async process(self, input: Input, request: Request) -> Output` +- Top-level functions: +- `stream_file_download(file_source, download_name, chunk_size=...)`: Create a streaming response for file downloads that shows progress in browser. +- `make_disposition(download_name: str) -> str` +- `resolve_download_path(path: str) -> str`: Resolve a requested download path and keep it within the runtime base dir. +- `async fetch_file(path)` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `DownloadFile` is an `ApiHandler`. +- `DownloadFile` defines `process(...)`. +- `DownloadFile` defines `get_methods(...)`. +- Observed side-effect areas: filesystem reads, network calls. +- Imported dependency areas include: `api`, `base64`, `flask`, `helpers`, `helpers.api`, `io`, `mimetypes`, `os`, `pathlib`, `urllib.parse`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `mimetypes.guess_type`, `Response`, `quote`, `Path.resolve`, `Path`, `candidate.is_absolute`, `os.path.getsize`, `generate`, `download_name.encode.decode`, `candidate.resolve`, `resolve`, `resolved.relative_to`, `Exception`, `file.read`, `base64.b64encode.decode`, `file_source.tell`, `file_source.seek`, `ValueError`, `file_path.startswith`, `runtime.call_development_function`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_download_toast_regressions.py` + - `tests/test_office_canvas_setup.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/download_work_dir_files.py.dox.md b/api/download_work_dir_files.py.dox.md new file mode 100644 index 000000000..478669fa8 --- /dev/null +++ b/api/download_work_dir_files.py.dox.md @@ -0,0 +1,54 @@ +# download_work_dir_files.py DOX + +## Purpose + +- Own the `download_work_dir_files.py` API endpoint. +- This module handles workdir file operations for download work dir files. +- Keep this file-level DOX profile synchronized with `download_work_dir_files.py` because this directory is intentionally flat. + +## Ownership + +- `download_work_dir_files.py` owns the runtime implementation. +- `download_work_dir_files.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `DownloadFiles` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` +- Top-level functions: +- `normalize_paths(paths) -> list[str]` +- `selected_archive_name(count: int) -> str` +- `create_selected_zip(paths: list[str], current_path: str=...) -> str` +- `resolve_download_path(path: str, base_dir: Path) -> Path` +- `collapse_nested_paths(paths: list[Path]) -> list[Path]` +- `archive_root_name(source_path: Path, current_dir: Path | None, base_dir: Path) -> str` +- `unique_archive_name(name: str, used_names: set[str]) -> str` +- `write_zip_entry(zip_file: zipfile.ZipFile, source_path: Path, arc_root: str) -> None` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `DownloadFiles` is an `ApiHandler`. +- `DownloadFiles` defines `process(...)`. +- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion. +- Imported dependency areas include: `api.download_work_dir_file`, `base64`, `flask`, `helpers`, `helpers.api`, `helpers.localization`, `io`, `os`, `pathlib`, `tempfile`, `zipfile`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `Localization.get.now.strftime`, `Path.resolve`, `normalize_paths`, `collapse_nested_paths`, `Path`, `os.path.splitext`, `source_path.is_dir`, `zip_file.write`, `selected_archive_name`, `runtime.is_development`, `stream_file_download`, `ValueError`, `raw_path.strip`, `resolve_download_path`, `current_dir.is_file`, `resolved.exists`, `FileNotFoundError`, `tempfile.NamedTemporaryFile`, `zipfile.ZipFile`, `candidate.is_absolute`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_download_toast_regressions.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/edit_work_dir_file.py.dox.md b/api/edit_work_dir_file.py.dox.md new file mode 100644 index 000000000..e5615517b --- /dev/null +++ b/api/edit_work_dir_file.py.dox.md @@ -0,0 +1,50 @@ +# edit_work_dir_file.py DOX + +## Purpose + +- Own the `edit_work_dir_file.py` API endpoint. +- This module handles workdir file operations for edit work dir file. +- Keep this file-level DOX profile synchronized with `edit_work_dir_file.py` because this directory is intentionally flat. + +## Ownership + +- `edit_work_dir_file.py` owns the runtime implementation. +- `edit_work_dir_file.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `EditWorkDirFile` (`ApiHandler`) + - `get_methods(cls)` + - `async process(self, input: Input, request: Request) -> Output` +- Top-level functions: +- `async load_file(file_path: str) -> dict` +- `save_file(file_path: str, content: str) -> bool` +- Notable constants/configuration names: `MAX_EDIT_FILE_SIZE`, `BINARY_SAMPLE_SIZE`. + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `EditWorkDirFile` is an `ApiHandler`. +- `EditWorkDirFile` defines `process(...)`. +- `EditWorkDirFile` defines `get_methods(...)`. +- Observed side-effect areas: filesystem reads, filesystem writes. +- Imported dependency areas include: `helpers`, `helpers.api`, `helpers.file_browser`, `mimetypes`, `os`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `FileBrowser`, `browser.get_full_path`, `os.path.isdir`, `os.path.getsize`, `files.is_probably_binary_file`, `mimetypes.guess_type`, `browser.save_text_file`, `error_str.strip`, `Exception`, `os.path.basename`, `error_str.split`, `file.read`, `line.split.strip`, `file_path.startswith`, `content.encode`, `runtime.call_development_function`, `extension.call_extensions_async`, `self._extract_error_message`, `line.split`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/file_info.py.dox.md b/api/file_info.py.dox.md new file mode 100644 index 000000000..e3612589d --- /dev/null +++ b/api/file_info.py.dox.md @@ -0,0 +1,47 @@ +# file_info.py DOX + +## Purpose + +- Own the `file_info.py` API endpoint. +- This module handles file info API requests. +- Keep this file-level DOX profile synchronized with `file_info.py` because this directory is intentionally flat. + +## Ownership + +- `file_info.py` owns the runtime implementation. +- `file_info.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `FileInfoApi` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` +- `FileInfo` (`TypedDict`) +- Top-level functions: +- `async get_file_info(path: str) -> FileInfo` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `FileInfoApi` is an `ApiHandler`. +- `FileInfoApi` defines `process(...)`. +- Observed side-effect areas: filesystem reads. +- Imported dependency areas include: `helpers`, `helpers.api`, `os`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `files.get_abs_path`, `os.path.exists`, `os.path.dirname`, `os.path.basename`, `runtime.call_development_function`, `os.path.isdir`, `os.path.isfile`, `os.path.islink`, `os.path.getsize`, `os.path.getmtime`, `os.path.getctime`, `os.path.splitext`, `os.stat`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/get_work_dir_files.py b/api/get_work_dir_files.py index 7e8e99c7f..68aee4990 100644 --- a/api/get_work_dir_files.py +++ b/api/get_work_dir_files.py @@ -9,7 +9,7 @@ class GetWorkDirFiles(ApiHandler): return ["GET"] async def process(self, input: dict, request: Request) -> dict | Response: - current_path = request.args.get("path", "") + current_path = request.args.get("path", "") or "$WORK_DIR" if current_path == "$WORK_DIR": # if runtime.is_development(): # current_path = "work_dir" diff --git a/api/get_work_dir_files.py.dox.md b/api/get_work_dir_files.py.dox.md new file mode 100644 index 000000000..f5ff3e005 --- /dev/null +++ b/api/get_work_dir_files.py.dox.md @@ -0,0 +1,48 @@ +# get_work_dir_files.py DOX + +## Purpose + +- Own the `get_work_dir_files.py` API endpoint. +- This module handles workdir file operations for get work dir files. +- Keep this file-level DOX profile synchronized with `get_work_dir_files.py` because this directory is intentionally flat. + +## Ownership + +- `get_work_dir_files.py` owns the runtime implementation. +- `get_work_dir_files.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `GetWorkDirFiles` (`ApiHandler`) + - `get_methods(cls)` + - `async process(self, input: dict, request: Request) -> dict | Response` +- Top-level functions: +- `async get_files(path)` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `GetWorkDirFiles` is an `ApiHandler`. +- `GetWorkDirFiles` defines `process(...)`. +- `GetWorkDirFiles` defines `get_methods(...)`. +- Imported dependency areas include: `helpers`, `helpers.api`, `helpers.file_browser`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `FileBrowser`, `browser.get_files`, `runtime.call_development_function`. +- Empty `path` requests and explicit `$WORK_DIR` requests resolve to the default workdir path before `FileBrowser` is called, so the WebUI never receives an empty startup path for the default file browser view. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/health.py.dox.md b/api/health.py.dox.md new file mode 100644 index 000000000..236812f56 --- /dev/null +++ b/api/health.py.dox.md @@ -0,0 +1,52 @@ +# health.py DOX + +## Purpose + +- Own the `health.py` API endpoint. +- This module reports process health for probes and startup checks. +- Keep this file-level DOX profile synchronized with `health.py` because this directory is intentionally flat. + +## Ownership + +- `health.py` owns the runtime implementation. +- `health.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `HealthCheck` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `requires_csrf(cls) -> bool` + - `get_methods(cls) -> list[str]` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `HealthCheck` is an `ApiHandler`. +- `HealthCheck` defines `process(...)`. +- `HealthCheck` defines `get_methods(...)`. +- `HealthCheck` defines `requires_auth(...)`. +- `HealthCheck` defines `requires_csrf(...)`. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `git.get_git_info`, `errors.error_text`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_oauth_providers.py` + - `tests/test_office_document_store.py` + - `tests/test_self_update_tag_filter.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/history_get.py.dox.md b/api/history_get.py.dox.md new file mode 100644 index 000000000..8f2f1ddca --- /dev/null +++ b/api/history_get.py.dox.md @@ -0,0 +1,44 @@ +# history_get.py DOX + +## Purpose + +- Own the `history_get.py` API endpoint. +- This module handles history get API requests. +- Keep this file-level DOX profile synchronized with `history_get.py` because this directory is intentionally flat. + +## Ownership + +- `history_get.py` owns the runtime implementation. +- `history_get.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `GetHistory` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `GetHistory` is an `ApiHandler`. +- `GetHistory` defines `process(...)`. +- Observed side-effect areas: secret handling. +- Imported dependency areas include: `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.use_context`, `agent.history.output_text`, `agent.history.get_tokens`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/image_get.py.dox.md b/api/image_get.py.dox.md new file mode 100644 index 000000000..b9b0da35b --- /dev/null +++ b/api/image_get.py.dox.md @@ -0,0 +1,53 @@ +# image_get.py DOX + +## Purpose + +- Own the `image_get.py` API endpoint. +- This module serves allowed image references and fallback file-type icons. +- Keep this file-level DOX profile synchronized with `image_get.py` because this directory is intentionally flat. + +## Ownership + +- `image_get.py` owns the runtime implementation. +- `image_get.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ImageGet` (`ApiHandler`) + - `get_methods(cls) -> list[str]` + - `async process(self, input: dict, request: Request) -> dict | Response` +- Top-level functions: +- `_resolve_allowed_image_path(path: str) -> str`: Resolve a requested image path and keep it inside Agent Zero's base dir. +- `_set_image_headers(response: Response, filename: str, file_ext: str) -> None` +- `_send_file_type_icon(file_ext, filename=...)`: Return appropriate icon for file type +- `_send_fallback_icon(icon_name)`: Return fallback icon from public directory +- Notable constants/configuration names: `IMAGE_EXTENSIONS`, `SVG_EXTENSIONS`, `SVG_CONTENT_SECURITY_POLICY`. + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `ImageGet` is an `ApiHandler`. +- `ImageGet` defines `process(...)`. +- `ImageGet` defines `get_methods(...)`. +- Observed side-effect areas: filesystem reads, network calls, subprocess/runtime control, settings/state persistence. +- Imported dependency areas include: `base64`, `helpers`, `helpers.api`, `io`, `mimetypes`, `os`, `pathlib`, `urllib.parse`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `runtime.is_development`, `Path.resolve`, `candidate.resolve`, `quote`, `_send_fallback_icon`, `files.get_abs_path`, `send_file`, `os.path.splitext.lower`, `os.path.basename`, `Path`, `candidate.is_absolute`, `resolved.relative_to`, `os.path.exists`, `ValueError`, `_set_image_headers`, `_send_file_type_icon`, `files.fix_dev_path`, `_resolve_allowed_image_path`, `files.exists`, `files.get_base_dir`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_image_get_security.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/load_webui_extensions.py.dox.md b/api/load_webui_extensions.py.dox.md new file mode 100644 index 000000000..c2d41e06a --- /dev/null +++ b/api/load_webui_extensions.py.dox.md @@ -0,0 +1,44 @@ +# load_webui_extensions.py DOX + +## Purpose + +- Own the `load_webui_extensions.py` API endpoint. +- This module returns frontend extension manifests/files for a WebUI extension point. +- Keep this file-level DOX profile synchronized with `load_webui_extensions.py` because this directory is intentionally flat. + +## Ownership + +- `load_webui_extensions.py` owns the runtime implementation. +- `load_webui_extensions.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `LoadWebuiExtensions` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `LoadWebuiExtensions` is an `ApiHandler`. +- `LoadWebuiExtensions` defines `process(...)`. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `extension.get_webui_extensions`, `Response`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_webui_extension_surfaces.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/logout.py.dox.md b/api/logout.py.dox.md new file mode 100644 index 000000000..e125a43e7 --- /dev/null +++ b/api/logout.py.dox.md @@ -0,0 +1,47 @@ +# logout.py DOX + +## Purpose + +- Own the `logout.py` API endpoint. +- This module clears login/session state for the current client. +- Keep this file-level DOX profile synchronized with `logout.py` because this directory is intentionally flat. + +## Ownership + +- `logout.py` owns the runtime implementation. +- `logout.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ApiLogout` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `ApiLogout` is an `ApiHandler`. +- `ApiLogout` defines `process(...)`. +- `ApiLogout` defines `requires_auth(...)`. +- Observed side-effect areas: secret handling. +- Imported dependency areas include: `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `session.clear`, `session.pop`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_office_document_store.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/mcp_server_get_detail.py b/api/mcp_server_get_detail.py index bc5552c4d..25f865a41 100644 --- a/api/mcp_server_get_detail.py +++ b/api/mcp_server_get_detail.py @@ -9,9 +9,11 @@ class McpServerGetDetail(ApiHandler): # try: server_name = input.get("server_name") + project_name = str(input.get("project_name", "") or "").strip() if not server_name: return {"success": False, "error": "Missing server_name"} - detail = MCPConfig.get_instance().get_server_detail(server_name) + config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance() + detail = config.get_server_detail(server_name) return {"success": True, "detail": detail} # except Exception as e: # return {"success": False, "error": str(e)} diff --git a/api/mcp_server_get_detail.py.dox.md b/api/mcp_server_get_detail.py.dox.md new file mode 100644 index 000000000..d71ed2551 --- /dev/null +++ b/api/mcp_server_get_detail.py.dox.md @@ -0,0 +1,45 @@ +# mcp_server_get_detail.py DOX + +## Purpose + +- Own the `mcp_server_get_detail.py` API endpoint. +- This module handles MCP server detail requests for global or project scope. +- Keep this file-level DOX profile synchronized with `mcp_server_get_detail.py` because this directory is intentionally flat. + +## Ownership + +- `mcp_server_get_detail.py` owns the runtime implementation. +- `mcp_server_get_detail.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `McpServerGetDetail` (`ApiHandler`) + - `async process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- The request accepts `server_name` and optional `project_name`; when `project_name` is present, detail resolves through the project-scoped MCP configuration. +- Detail responses include the server tools visible to the manager UI. Tools disabled through a server `disabled_tools` config list remain present in this detail list with a `disabled` flag so the UI can re-enable them. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `McpServerGetDetail` is an `ApiHandler`. +- `McpServerGetDetail` defines `process(...)`. +- Imported dependency areas include: `helpers.api`, `helpers.mcp_handler`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_detail`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/mcp_server_get_log.py b/api/mcp_server_get_log.py index 3305430ea..89b53b58f 100644 --- a/api/mcp_server_get_log.py +++ b/api/mcp_server_get_log.py @@ -9,9 +9,11 @@ class McpServerGetLog(ApiHandler): # try: server_name = input.get("server_name") + project_name = str(input.get("project_name", "") or "").strip() if not server_name: return {"success": False, "error": "Missing server_name"} - log = MCPConfig.get_instance().get_server_log(server_name) + config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance() + log = config.get_server_log(server_name) return {"success": True, "log": log} # except Exception as e: # return {"success": False, "error": str(e)} diff --git a/api/mcp_server_get_log.py.dox.md b/api/mcp_server_get_log.py.dox.md new file mode 100644 index 000000000..0937274a4 --- /dev/null +++ b/api/mcp_server_get_log.py.dox.md @@ -0,0 +1,44 @@ +# mcp_server_get_log.py DOX + +## Purpose + +- Own the `mcp_server_get_log.py` API endpoint. +- This module handles MCP server log requests for global or project scope. +- Keep this file-level DOX profile synchronized with `mcp_server_get_log.py` because this directory is intentionally flat. + +## Ownership + +- `mcp_server_get_log.py` owns the runtime implementation. +- `mcp_server_get_log.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `McpServerGetLog` (`ApiHandler`) + - `async process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- The request accepts `server_name` and optional `project_name`; when `project_name` is present, logs resolve through the project-scoped MCP configuration. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `McpServerGetLog` is an `ApiHandler`. +- `McpServerGetLog` defines `process(...)`. +- Imported dependency areas include: `helpers.api`, `helpers.mcp_handler`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_log`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/mcp_server_scan.py b/api/mcp_server_scan.py new file mode 100644 index 000000000..4639426d3 --- /dev/null +++ b/api/mcp_server_scan.py @@ -0,0 +1,232 @@ +import asyncio +from shutil import which +from typing import Any +from urllib.parse import urlparse + +from helpers.api import ApiHandler, Request, Response +from helpers.mcp_handler import MCPConfig, normalize_name + + +_PROMPT_INJECTION_MARKERS = ( + "ignore previous", + "ignore all previous", + "system prompt", + "developer message", + "hidden instruction", + "exfiltrate", + "leak secret", + "credential", +) + + +class McpServerScan(ApiHandler): + async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response: + server = dict(input.get("server") or {}) + allow_local_execution = bool(input.get("allow_local_execution", False)) + allow_remote_network = bool(input.get("allow_remote_network", False)) + inspect_runtime = input.get("inspect_runtime", True) is not False + + server = self._normalize_server(server) + warnings = self._static_warnings(server) + is_local = not (server.get("url") or server.get("serverUrl")) + has_static_errors = any(warning.get("level") == "error" for warning in warnings) + + runtime_status: list[dict[str, Any]] = [] + runtime_detail: dict[str, Any] = {} + runtime_error = "" + + should_inspect_runtime = ( + inspect_runtime + and not has_static_errors + and ((is_local and allow_local_execution) or (not is_local and allow_remote_network)) + ) + + if should_inspect_runtime: + try: + scan_config = await asyncio.to_thread( + lambda: MCPConfig(servers_list=[server], config_scope="scan") + ) + runtime_status = scan_config.get_servers_status() + runtime_detail = scan_config.get_server_detail(server.get("name", "")) + warnings.extend(self._tool_warnings(runtime_detail.get("tools", []))) + except Exception as exc: + runtime_error = str(exc) + warnings.append( + { + "level": "error", + "title": "Runtime inspection failed", + "message": runtime_error, + } + ) + elif is_local and inspect_runtime: + warnings.append( + { + "level": "warning", + "title": "Local command not executed", + "message": "Local stdio MCP inspection requires explicit trust because it runs the configured command.", + } + ) + elif not is_local and inspect_runtime and has_static_errors: + warnings.append( + { + "level": "info", + "title": "Runtime inspection skipped", + "message": "Fix static scan errors before attempting runtime MCP inspection.", + } + ) + elif not is_local and inspect_runtime: + warnings.append( + { + "level": "info", + "title": "Remote runtime inspection skipped", + "message": "Enable trusted remote inspection to contact the MCP URL and list exposed tools.", + } + ) + + return { + "success": True, + "server": self._redact_server(server), + "risk_level": self._risk_level(warnings), + "warnings": warnings, + "status": runtime_status, + "detail": runtime_detail, + "runtime_error": runtime_error, + } + + def _normalize_server(self, server: dict[str, Any]) -> dict[str, Any]: + name = str(server.get("name") or "").strip() + url = str(server.get("url") or server.get("serverUrl") or "").strip() + command = str(server.get("command") or "").strip() + + if not name: + name = self._derive_name(url, command) + server["name"] = normalize_name(name or "mcp_server") + + if url: + server["url"] = url + server.setdefault("type", "streamable-http") + elif command: + server["command"] = command + server["type"] = "stdio" + + return server + + def _derive_name(self, url: str, command: str) -> str: + if url: + parsed = urlparse(url) + parts = [part for part in parsed.path.split("/") if part] + return parts[-1] if parts else parsed.hostname or "remote_mcp" + if command: + return command.rsplit("/", 1)[-1] + return "mcp_server" + + def _static_warnings(self, server: dict[str, Any]) -> list[dict[str, str]]: + warnings: list[dict[str, str]] = [] + url = str(server.get("url") or "").strip() + command = str(server.get("command") or "").strip() + + if url: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"}: + warnings.append( + { + "level": "error", + "title": "Unsupported URL scheme", + "message": "Remote MCP URLs should use http or https.", + } + ) + elif parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}: + warnings.append( + { + "level": "warning", + "title": "Unencrypted remote URL", + "message": "Prefer HTTPS for remote MCP servers outside localhost.", + } + ) + if not parsed.netloc: + warnings.append( + { + "level": "error", + "title": "Invalid remote URL", + "message": "The remote MCP URL is missing a host.", + } + ) + elif command: + if which(command) is None: + warnings.append( + { + "level": "warning", + "title": "Command not found", + "message": f"'{command}' is not currently available on PATH.", + } + ) + if command in {"bash", "sh", "zsh", "fish", "python", "python3", "node"}: + warnings.append( + { + "level": "warning", + "title": "General-purpose interpreter", + "message": "Review the command and arguments carefully before running this local MCP server.", + } + ) + else: + warnings.append( + { + "level": "error", + "title": "Missing connection target", + "message": "Provide either a remote URL or a local command.", + } + ) + + if isinstance(server.get("headers"), dict) and server["headers"]: + warnings.append( + { + "level": "info", + "title": "Headers configured", + "message": "Header values are redacted in scan output. Keep tokens in trusted settings only.", + } + ) + + if isinstance(server.get("env"), dict) and server["env"]: + warnings.append( + { + "level": "info", + "title": "Environment configured", + "message": "Environment values are redacted in scan output. Avoid hardcoding secrets in MCP configs.", + } + ) + + return warnings + + def _tool_warnings(self, tools: Any) -> list[dict[str, str]]: + warnings: list[dict[str, str]] = [] + if not isinstance(tools, list): + return warnings + + for tool in tools: + if not isinstance(tool, dict): + continue + haystack = f"{tool.get('name', '')}\n{tool.get('description', '')}".lower() + if any(marker in haystack for marker in _PROMPT_INJECTION_MARKERS): + warnings.append( + { + "level": "warning", + "title": "Suspicious tool description", + "message": f"Review tool '{tool.get('name', 'unknown')}' for prompt-injection style language.", + } + ) + return warnings + + def _redact_server(self, server: dict[str, Any]) -> dict[str, Any]: + redacted = dict(server) + for key in ("headers", "env"): + if isinstance(redacted.get(key), dict): + redacted[key] = {name: "***" for name in redacted[key]} + return redacted + + def _risk_level(self, warnings: list[dict[str, str]]) -> str: + levels = {warning.get("level", "info") for warning in warnings} + if "error" in levels: + return "error" + if "warning" in levels: + return "warning" + return "ok" diff --git a/api/mcp_server_scan.py.dox.md b/api/mcp_server_scan.py.dox.md new file mode 100644 index 000000000..5487106aa --- /dev/null +++ b/api/mcp_server_scan.py.dox.md @@ -0,0 +1,45 @@ +# mcp_server_scan.py DOX + +## Purpose + +- Own the `mcp_server_scan.py` API endpoint. +- Provide static and optional runtime inspection for a single MCP server draft before it is added to global or project MCP config. +- Keep this file-level DOX profile synchronized with `mcp_server_scan.py` because this directory is intentionally flat. + +## Ownership + +- `mcp_server_scan.py` owns the runtime implementation. +- `mcp_server_scan.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `McpServerScan` (`ApiHandler`) + - `async process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- The request accepts a `server` draft object, `inspect_runtime`, `allow_remote_network`, and `allow_local_execution`. +- Remote runtime inspection may contact the configured MCP URL to list tools only when `allow_remote_network` is true and static checks have no errors. +- Local stdio runtime inspection must not execute unless `allow_local_execution` is true. +- Response data redacts `headers` and `env` values. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- Imported dependency areas include: `asyncio`, `helpers.api`, `helpers.mcp_handler`, `shutil`, `typing`, `urllib.parse`. + +## Key Concepts + +- Static checks report invalid URLs, non-HTTPS remote URLs, missing local commands, interpreter-style local commands, headers/env presence, and obvious prompt-injection markers in inspected tool descriptions. +- Static errors skip runtime inspection; remote network inspection and local command execution both require explicit trust flags. +- Runtime inspection creates a temporary `MCPConfig` in a worker thread so stdio/remote tool listing does not call `asyncio.run()` inside the request event loop. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Do not return secret values, raw environment values, or private files. +- Keep scanner warnings explicit about local command execution risk. + +## Verification + +- Run endpoint-specific or MCP helper tests for changed behavior; smoke-test remote URL and local-command scan paths when practical. + +## Child DOX Index + +No child DOX files. diff --git a/api/mcp_servers_apply.py b/api/mcp_servers_apply.py index 7ea5275db..36877807a 100644 --- a/api/mcp_servers_apply.py +++ b/api/mcp_servers_apply.py @@ -5,20 +5,27 @@ from typing import Any from helpers.mcp_handler import MCPConfig from helpers.settings import set_settings_delta +from helpers import projects class McpServersApply(ApiHandler): async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response: mcp_servers = input["mcp_servers"] + project_name = str(input.get("project_name", "") or "").strip() try: - # MCPConfig.update(mcp_servers) # done in settings automatically - set_settings_delta({"mcp_servers": "[]"}) # to force reinitialization - set_settings_delta({"mcp_servers": mcp_servers}) + if project_name: + projects.save_project_mcp_servers(project_name, mcp_servers) + config = MCPConfig.refresh_project(project_name) + else: + # MCPConfig.update(mcp_servers) # done in settings automatically + set_settings_delta({"mcp_servers": "[]"}) # to force reinitialization + set_settings_delta({"mcp_servers": mcp_servers}) - time.sleep(1) # wait at least a second - # MCPConfig.wait_for_lock() # wait until config lock is released - status = MCPConfig.get_instance().get_servers_status() - return {"success": True, "status": status} + time.sleep(1) # wait at least a second + # MCPConfig.wait_for_lock() # wait until config lock is released + config = MCPConfig.get_instance() + status = config.get_servers_status() + return {"success": True, "status": status, "mcp_servers": mcp_servers, "project_name": project_name} except Exception as e: return {"success": False, "error": str(e)} diff --git a/api/mcp_servers_apply.py.dox.md b/api/mcp_servers_apply.py.dox.md new file mode 100644 index 000000000..361c331b2 --- /dev/null +++ b/api/mcp_servers_apply.py.dox.md @@ -0,0 +1,47 @@ +# mcp_servers_apply.py DOX + +## Purpose + +- Own the `mcp_servers_apply.py` API endpoint. +- This module handles MCP servers apply requests for global or project scope. +- Keep this file-level DOX profile synchronized with `mcp_servers_apply.py` because this directory is intentionally flat. + +## Ownership + +- `mcp_servers_apply.py` owns the runtime implementation. +- `mcp_servers_apply.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `McpServersApply` (`ApiHandler`) + - `async process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- The request accepts `config` and optional `project_name`. +- Without `project_name`, the endpoint persists global `mcp_servers_config` through settings and refreshes the global `MCPConfig`. +- With `project_name`, the endpoint saves `.a0proj/mcp_servers.json` through `helpers.projects.save_project_mcp_servers(...)` and refreshes that project's merged MCP config. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `McpServersApply` is an `ApiHandler`. +- `McpServersApply` defines `process(...)`. +- Observed side-effect areas: filesystem writes, settings/state persistence. +- Imported dependency areas include: `helpers.api`, `helpers.mcp_handler`, `helpers.projects`, `helpers.settings`, `time`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `set_settings_delta`, `projects.save_project_mcp_servers`, `MCPConfig.refresh_project`, `time.sleep`, `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_instance`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/mcp_servers_status.py b/api/mcp_servers_status.py index 20f8a64c8..01afff421 100644 --- a/api/mcp_servers_status.py +++ b/api/mcp_servers_status.py @@ -9,7 +9,9 @@ class McpServersStatuss(ApiHandler): async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response: # try: - status = MCPConfig.get_instance().get_servers_status() + project_name = (input or {}).get("project_name") if isinstance(input, dict) else None + config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance() + status = config.get_servers_status() return {"success": True, "status": status} # except Exception as e: # return {"success": False, "error": str(e)} diff --git a/api/mcp_servers_status.py.dox.md b/api/mcp_servers_status.py.dox.md new file mode 100644 index 000000000..90b4065be --- /dev/null +++ b/api/mcp_servers_status.py.dox.md @@ -0,0 +1,45 @@ +# mcp_servers_status.py DOX + +## Purpose + +- Own the `mcp_servers_status.py` API endpoint. +- This module handles MCP servers status requests for global or project scope. +- Keep this file-level DOX profile synchronized with `mcp_servers_status.py` because this directory is intentionally flat. + +## Ownership + +- `mcp_servers_status.py` owns the runtime implementation. +- `mcp_servers_status.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `McpServersStatuss` (`ApiHandler`) + - `async process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- The request accepts optional `project_name`; when present, status resolves through the merged project-scoped MCP configuration. +- `tool_count` reports enabled MCP tools only; tools disabled by a server `disabled_tools` list stay hidden from agent-facing status counts. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `McpServersStatuss` is an `ApiHandler`. +- `McpServersStatuss` defines `process(...)`. +- Imported dependency areas include: `helpers.api`, `helpers.mcp_handler`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/message.py.dox.md b/api/message.py.dox.md new file mode 100644 index 000000000..9ec63fc71 --- /dev/null +++ b/api/message.py.dox.md @@ -0,0 +1,54 @@ +# message.py DOX + +## Purpose + +- Own the `message.py` API endpoint. +- This module submits a user message and runs agent processing synchronously through the UI API. +- Keep this file-level DOX profile synchronized with `message.py` because this directory is intentionally flat. + +## Ownership + +- `message.py` owns the runtime implementation. +- `message.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Message` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + - `async respond(self, task: DeferredTask, context: AgentContext)` + - `async communicate(self, input: dict, request: Request)` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Message` is an `ApiHandler`. +- `Message` defines `process(...)`. +- Observed side-effect areas: filesystem reads, filesystem writes, settings/state persistence, scheduler state. +- Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.defer`, `helpers.security`, `os`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `request.content_type.startswith`, `self.use_context`, `mq.log_user_message`, `self.communicate`, `self.respond`, `task.result`, `request.files.getlist`, `files.get_abs_path`, `request.get_json`, `extension.call_extensions_async`, `context.communicate`, `os.makedirs`, `UserMessage`, `safe_filename`, `attachment.save`, `context.get_agent`, `os.path.join`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/email_parser_test.py` + - `tests/rate_limiter_test.py` + - `tests/test_api_chat_lifetime.py` + - `tests/test_browser_agent_regressions.py` + - `tests/test_chat_compaction.py` + - `tests/test_docker_release_plan.py` + - `tests/test_document_query_fallback.py` + - `tests/test_download_toast_regressions.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/message_async.py.dox.md b/api/message_async.py.dox.md new file mode 100644 index 000000000..6a6ba0093 --- /dev/null +++ b/api/message_async.py.dox.md @@ -0,0 +1,42 @@ +# message_async.py DOX + +## Purpose + +- Own the `message_async.py` API endpoint. +- This module submits a user message for asynchronous agent processing. +- Keep this file-level DOX profile synchronized with `message_async.py` because this directory is intentionally flat. + +## Ownership + +- `message_async.py` owns the runtime implementation. +- `message_async.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `MessageAsync` (`Message`) + - `async respond(self, task: DeferredTask, context: AgentContext)` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- Observed side-effect areas: scheduler state. +- Imported dependency areas include: `agent`, `api.message`, `helpers.defer`. + +## 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 authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/message_queue_add.py.dox.md b/api/message_queue_add.py.dox.md new file mode 100644 index 000000000..f727a70f1 --- /dev/null +++ b/api/message_queue_add.py.dox.md @@ -0,0 +1,44 @@ +# message_queue_add.py DOX + +## Purpose + +- Own the `message_queue_add.py` API endpoint. +- This module handles message queue add API requests. +- Keep this file-level DOX profile synchronized with `message_queue_add.py` because this directory is intentionally flat. + +## Ownership + +- `message_queue_add.py` owns the runtime implementation. +- `message_queue_add.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `MessageQueueAdd` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `MessageQueueAdd` is an `ApiHandler`. +- `MessageQueueAdd` defines `process(...)`. +- Observed side-effect areas: settings/state persistence. +- Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.state_monitor_integration`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `input.get.strip`, `mq.add`, `mark_dirty_for_context`, `Response`, `mq.get_queue`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/message_queue_remove.py.dox.md b/api/message_queue_remove.py.dox.md new file mode 100644 index 000000000..8bf876b05 --- /dev/null +++ b/api/message_queue_remove.py.dox.md @@ -0,0 +1,44 @@ +# message_queue_remove.py DOX + +## Purpose + +- Own the `message_queue_remove.py` API endpoint. +- This module handles message queue remove API requests. +- Keep this file-level DOX profile synchronized with `message_queue_remove.py` because this directory is intentionally flat. + +## Ownership + +- `message_queue_remove.py` owns the runtime implementation. +- `message_queue_remove.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `MessageQueueRemove` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `MessageQueueRemove` is an `ApiHandler`. +- `MessageQueueRemove` defines `process(...)`. +- Observed side-effect areas: filesystem deletion, settings/state persistence. +- Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.state_monitor_integration`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `mq.remove`, `mark_dirty_for_context`, `Response`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/message_queue_send.py.dox.md b/api/message_queue_send.py.dox.md new file mode 100644 index 000000000..4f7294a94 --- /dev/null +++ b/api/message_queue_send.py.dox.md @@ -0,0 +1,44 @@ +# message_queue_send.py DOX + +## Purpose + +- Own the `message_queue_send.py` API endpoint. +- This module handles message queue send API requests. +- Keep this file-level DOX profile synchronized with `message_queue_send.py` because this directory is intentionally flat. + +## Ownership + +- `message_queue_send.py` owns the runtime implementation. +- `message_queue_send.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `MessageQueueSend` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `MessageQueueSend` is an `ApiHandler`. +- `MessageQueueSend` defines `process(...)`. +- Observed side-effect areas: settings/state persistence. +- Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.state_monitor_integration`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `mq.send_message`, `mark_dirty_for_context`, `Response`, `mq.has_queue`, `mq.send_all_aggregated`, `mq.pop_item`, `mq.pop_first`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/notification_create.py.dox.md b/api/notification_create.py.dox.md new file mode 100644 index 000000000..a83b82cf4 --- /dev/null +++ b/api/notification_create.py.dox.md @@ -0,0 +1,46 @@ +# notification_create.py DOX + +## Purpose + +- Own the `notification_create.py` API endpoint. +- This module handles notification notification create requests. +- Keep this file-level DOX profile synchronized with `notification_create.py` because this directory is intentionally flat. + +## Ownership + +- `notification_create.py` owns the runtime implementation. +- `notification_create.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `NotificationCreate` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `NotificationCreate` is an `ApiHandler`. +- `NotificationCreate` defines `process(...)`. +- `NotificationCreate` defines `requires_auth(...)`. +- Imported dependency areas include: `flask`, `helpers.api`, `helpers.notification`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `NotificationManager.send_notification`, `NotificationType`, `notification.output`, `notification_type.lower`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_download_toast_regressions.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/notifications_clear.py.dox.md b/api/notifications_clear.py.dox.md new file mode 100644 index 000000000..dc5503b73 --- /dev/null +++ b/api/notifications_clear.py.dox.md @@ -0,0 +1,45 @@ +# notifications_clear.py DOX + +## Purpose + +- Own the `notifications_clear.py` API endpoint. +- This module handles notification notifications clear requests. +- Keep this file-level DOX profile synchronized with `notifications_clear.py` because this directory is intentionally flat. + +## Ownership + +- `notifications_clear.py` owns the runtime implementation. +- `notifications_clear.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `NotificationsClear` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `NotificationsClear` is an `ApiHandler`. +- `NotificationsClear` defines `process(...)`. +- `NotificationsClear` defines `requires_auth(...)`. +- Imported dependency areas include: `agent`, `flask`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `AgentContext.get_notification_manager`, `notification_manager.clear_all`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/notifications_history.py.dox.md b/api/notifications_history.py.dox.md new file mode 100644 index 000000000..a14b5bc86 --- /dev/null +++ b/api/notifications_history.py.dox.md @@ -0,0 +1,45 @@ +# notifications_history.py DOX + +## Purpose + +- Own the `notifications_history.py` API endpoint. +- This module handles notification notifications history requests. +- Keep this file-level DOX profile synchronized with `notifications_history.py` because this directory is intentionally flat. + +## Ownership + +- `notifications_history.py` owns the runtime implementation. +- `notifications_history.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `NotificationsHistory` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `NotificationsHistory` is an `ApiHandler`. +- `NotificationsHistory` defines `process(...)`. +- `NotificationsHistory` defines `requires_auth(...)`. +- Imported dependency areas include: `agent`, `flask`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `AgentContext.get_notification_manager`, `notification_manager.output_all`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/notifications_mark_read.py.dox.md b/api/notifications_mark_read.py.dox.md new file mode 100644 index 000000000..a06cbbdcf --- /dev/null +++ b/api/notifications_mark_read.py.dox.md @@ -0,0 +1,45 @@ +# notifications_mark_read.py DOX + +## Purpose + +- Own the `notifications_mark_read.py` API endpoint. +- This module handles notification notifications mark read requests. +- Keep this file-level DOX profile synchronized with `notifications_mark_read.py` because this directory is intentionally flat. + +## Ownership + +- `notifications_mark_read.py` owns the runtime implementation. +- `notifications_mark_read.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `NotificationsMarkRead` (`ApiHandler`) + - `requires_auth(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `NotificationsMarkRead` is an `ApiHandler`. +- `NotificationsMarkRead` defines `process(...)`. +- `NotificationsMarkRead` defines `requires_auth(...)`. +- Imported dependency areas include: `agent`, `flask`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `AgentContext.get_notification_manager`, `notification_manager.mark_read_by_ids`, `notification_manager.mark_all_read`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/nudge.py.dox.md b/api/nudge.py.dox.md new file mode 100644 index 000000000..f1be2cf3d --- /dev/null +++ b/api/nudge.py.dox.md @@ -0,0 +1,44 @@ +# nudge.py DOX + +## Purpose + +- Own the `nudge.py` API endpoint. +- This module handles nudge API requests. +- Keep this file-level DOX profile synchronized with `nudge.py` because this directory is intentionally flat. + +## Ownership + +- `nudge.py` owns the runtime implementation. +- `nudge.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Nudge` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Nudge` is an `ApiHandler`. +- `Nudge` defines `process(...)`. +- Imported dependency areas include: `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.use_context`, `context.nudge`, `context.log.log`, `Exception`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_browser_agent_regressions.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/pause.py.dox.md b/api/pause.py.dox.md new file mode 100644 index 000000000..37d0528e9 --- /dev/null +++ b/api/pause.py.dox.md @@ -0,0 +1,45 @@ +# pause.py DOX + +## Purpose + +- Own the `pause.py` API endpoint. +- This module handles pause API requests. +- Keep this file-level DOX profile synchronized with `pause.py` because this directory is intentionally flat. + +## Ownership + +- `pause.py` owns the runtime implementation. +- `pause.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Pause` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Pause` is an `ApiHandler`. +- `Pause` defines `process(...)`. +- Imported dependency areas include: `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.use_context`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_multi_tab_isolation.py` + - `tests/test_snapshot_schema_v1.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/plugins.py.dox.md b/api/plugins.py.dox.md new file mode 100644 index 000000000..e64b22dc9 --- /dev/null +++ b/api/plugins.py.dox.md @@ -0,0 +1,52 @@ +# plugins.py DOX + +## Purpose + +- Own the `plugins.py` API endpoint. +- This module manages plugin actions and plugin settings through the core API. +- Keep this file-level DOX profile synchronized with `plugins.py` because this directory is intentionally flat. + +## Ownership + +- `plugins.py` owns the runtime implementation. +- `plugins.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Plugins` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Plugins` is an `ApiHandler`. +- `Plugins` defines `process(...)`. +- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, subprocess/runtime control, plugin state, settings/state persistence. +- Imported dependency areas include: `helpers`, `helpers.api`, `helpers.localization`, `json`, `os`, `subprocess`, `sys`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `Response`, `plugins.find_plugin_assets`, `plugins.get_plugin_meta`, `plugins.get_default_plugin_config`, `plugins.save_plugin_config`, `plugins.toggle_plugin`, `plugins.find_plugin_dir`, `files.get_abs_path`, `Localization.get.now_iso`, `plugins.determine_plugin_asset_path`, `self._get_config`, `self._get_toggle_status`, `self._list_configs`, `self._delete_config`, `self._delete_plugin`, `self._get_default_config`, `self._save_config`, `self._toggle_plugin`, `self._get_doc`, `self._run_execute_script`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_a0_connector_computer_use_metadata.py` + - `tests/test_a0_connector_prompt_gating.py` + - `tests/test_browser_agent_regressions.py` + - `tests/test_chat_compaction.py` + - `tests/test_default_prompt_budget.py` + - `tests/test_document_query_plugin.py` + - `tests/test_error_retry_plugin.py` + - `tests/test_host_browser_connector.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/plugins_list.py.dox.md b/api/plugins_list.py.dox.md new file mode 100644 index 000000000..599b5a307 --- /dev/null +++ b/api/plugins_list.py.dox.md @@ -0,0 +1,46 @@ +# plugins_list.py DOX + +## Purpose + +- Own the `plugins_list.py` API endpoint. +- This module returns plugin inventory and activation metadata for plugin UI surfaces. +- Keep this file-level DOX profile synchronized with `plugins_list.py` because this directory is intentionally flat. + +## Ownership + +- `plugins_list.py` owns the runtime implementation. +- `plugins_list.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `PluginsList` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `PluginsList` is an `ApiHandler`. +- `PluginsList` defines `process(...)`. +- Observed side-effect areas: plugin state, settings/state persistence. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `plugins.get_enhanced_plugins_list`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_plugin_activation_ui.py` + - `tests/test_speech_plugin_split.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/poll.py.dox.md b/api/poll.py.dox.md new file mode 100644 index 000000000..851a03318 --- /dev/null +++ b/api/poll.py.dox.md @@ -0,0 +1,52 @@ +# poll.py DOX + +## Purpose + +- Own the `poll.py` API endpoint. +- This module returns chat/log/status changes for polling clients. +- Keep this file-level DOX profile synchronized with `poll.py` because this directory is intentionally flat. + +## Ownership + +- `poll.py` owns the runtime implementation. +- `poll.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Poll` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Poll` is an `ApiHandler`. +- `Poll` defines `process(...)`. +- Observed side-effect areas: settings/state persistence. +- Imported dependency areas include: `helpers.api`, `helpers.state_snapshot`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `build_snapshot`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_multi_tab_isolation.py` + - `tests/test_oauth_github_copilot.py` + - `tests/test_oauth_providers.py` + - `tests/test_office_document_store.py` + - `tests/test_snapshot_parity.py` + - `tests/test_snapshot_schema_v1.py` + - `tests/test_timezone_regressions.py` + - `tests/test_tunnel_remote_link.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/projects.py.dox.md b/api/projects.py.dox.md new file mode 100644 index 000000000..d55500c21 --- /dev/null +++ b/api/projects.py.dox.md @@ -0,0 +1,59 @@ +# projects.py DOX + +## Purpose + +- Own the `projects.py` API endpoint. +- This module manages project create, update, delete, clone, and metadata flows. +- Keep this file-level DOX profile synchronized with `projects.py` because this directory is intentionally flat. + +## Ownership + +- `projects.py` owns the runtime implementation. +- `projects.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Projects` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + - `get_active_projects_list(self)` + - `get_active_projects_options(self)` + - `create_project(self, project: dict | None)` + - `clone_project(self, project: dict | None)` + - `load_project(self, name: str | None)` + - `update_project(self, project: dict | None)` + - `delete_project(self, name: str | None)` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Projects` is an `ApiHandler`. +- `Projects` defines `process(...)`. +- Observed side-effect areas: filesystem deletion, settings/state persistence. +- Imported dependency areas include: `helpers`, `helpers.api`, `helpers.notification`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `projects.get_active_projects_list`, `projects.BasicProjectData`, `projects.create_project`, `projects.load_edit_project_data`, `NotificationManager.send_notification`, `projects.EditProjectData`, `projects.update_project`, `projects.delete_project`, `projects.activate_project`, `projects.deactivate_project`, `projects.load_basic_project_data`, `projects.get_file_structure`, `self.use_context`, `Exception`, `projects.clone_git_project`, `self.get_active_projects_list`, `self.get_active_projects_options`, `self.load_project`, `self.create_project`, `self.clone_project`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_model_config_project_presets.py` + - `tests/test_office_document_store.py` + - `tests/test_plugin_activation_ui.py` + - `tests/test_projects.py` + - `tests/test_skills_runtime.py` + - `tests/test_task_scheduler_timezone.py` + - `tests/test_time_travel.py` + - `tests/test_tool_action_contracts.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/rename_work_dir_file.py.dox.md b/api/rename_work_dir_file.py.dox.md new file mode 100644 index 000000000..624bedc54 --- /dev/null +++ b/api/rename_work_dir_file.py.dox.md @@ -0,0 +1,47 @@ +# rename_work_dir_file.py DOX + +## Purpose + +- Own the `rename_work_dir_file.py` API endpoint. +- This module handles workdir file operations for rename work dir file. +- Keep this file-level DOX profile synchronized with `rename_work_dir_file.py` because this directory is intentionally flat. + +## Ownership + +- `rename_work_dir_file.py` owns the runtime implementation. +- `rename_work_dir_file.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `RenameWorkDirFile` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` +- Top-level functions: +- `async rename_item(file_path: str, new_name: str) -> bool` +- `async create_folder(parent_path: str, folder_name: str) -> bool` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `RenameWorkDirFile` is an `ApiHandler`. +- `RenameWorkDirFile` defines `process(...)`. +- Observed side-effect areas: filesystem writes. +- Imported dependency areas include: `api`, `helpers`, `helpers.api`, `helpers.file_browser`, `posixpath`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `FileBrowser`, `browser.rename_item`, `browser.create_folder`, `strip`, `runtime.call_development_function`, `posixpath.join`, `file_path.startswith`, `extension.call_extensions_async`, `str.rstrip`, `posixpath.dirname`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/restart.py.dox.md b/api/restart.py.dox.md new file mode 100644 index 000000000..4e3156f6e --- /dev/null +++ b/api/restart.py.dox.md @@ -0,0 +1,48 @@ +# restart.py DOX + +## Purpose + +- Own the `restart.py` API endpoint. +- This module requests server restart or reload behavior. +- Keep this file-level DOX profile synchronized with `restart.py` because this directory is intentionally flat. + +## Ownership + +- `restart.py` owns the runtime implementation. +- `restart.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Restart` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Restart` is an `ApiHandler`. +- `Restart` defines `process(...)`. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `process.reload`, `Response`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_browser_agent_regressions.py` + - `tests/test_download_toast_regressions.py` + - `tests/test_self_update_tag_filter.py` + - `tests/test_timezone_regressions.py` + - `tests/test_ws_manager.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/rfc.py.dox.md b/api/rfc.py.dox.md new file mode 100644 index 000000000..6598649ea --- /dev/null +++ b/api/rfc.py.dox.md @@ -0,0 +1,47 @@ +# rfc.py DOX + +## Purpose + +- Own the `rfc.py` API endpoint. +- This module dispatches remote function calls through the RFC helper layer. +- Keep this file-level DOX profile synchronized with `rfc.py` because this directory is intentionally flat. + +## Ownership + +- `rfc.py` owns the runtime implementation. +- `rfc.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `RFC` (`ApiHandler`) + - `requires_csrf(cls) -> bool` + - `requires_auth(cls) -> bool` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `RFC` is an `ApiHandler`. +- `RFC` defines `process(...)`. +- `RFC` defines `requires_auth(...)`. +- `RFC` defines `requires_csrf(...)`. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `runtime.handle_rfc`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/scheduler_task_create.py.dox.md b/api/scheduler_task_create.py.dox.md new file mode 100644 index 000000000..332118085 --- /dev/null +++ b/api/scheduler_task_create.py.dox.md @@ -0,0 +1,44 @@ +# scheduler_task_create.py DOX + +## Purpose + +- Own the `scheduler_task_create.py` API endpoint. +- This module handles scheduler task create requests. +- Keep this file-level DOX profile synchronized with `scheduler_task_create.py` because this directory is intentionally flat. + +## Ownership + +- `scheduler_task_create.py` owns the runtime implementation. +- `scheduler_task_create.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SchedulerTaskCreate` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SchedulerTaskCreate` is an `ApiHandler`. +- `SchedulerTaskCreate` defines `process(...)`. +- Observed side-effect areas: filesystem writes, secret handling, scheduler state. +- Imported dependency areas include: `helpers.api`, `helpers.localization`, `helpers.print_style`, `helpers.projects`, `helpers.task_scheduler`, `random`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `PrintStyle`, `scheduler.get_task_by_uuid`, `serialize_task`, `Localization.get.set_timezone`, `scheduler.reload`, `ValueError`, `ScheduledTask.create`, `scheduler.add_task`, `requested_project_slug.strip`, `load_basic_project_data`, `random.randint`, `schedule.split`, `TaskSchedule`, `PlannedTask.create`, `AdHocTask.create`, `printer.error`, `type`, `parse_task_plan`, `parse_task_schedule`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/scheduler_task_delete.py.dox.md b/api/scheduler_task_delete.py.dox.md new file mode 100644 index 000000000..2112e95ee --- /dev/null +++ b/api/scheduler_task_delete.py.dox.md @@ -0,0 +1,44 @@ +# scheduler_task_delete.py DOX + +## Purpose + +- Own the `scheduler_task_delete.py` API endpoint. +- This module handles scheduler task delete requests. +- Keep this file-level DOX profile synchronized with `scheduler_task_delete.py` because this directory is intentionally flat. + +## Ownership + +- `scheduler_task_delete.py` owns the runtime implementation. +- `scheduler_task_delete.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SchedulerTaskDelete` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SchedulerTaskDelete` is an `ApiHandler`. +- `SchedulerTaskDelete` defines `process(...)`. +- Observed side-effect areas: filesystem writes, filesystem deletion, settings/state persistence, scheduler state. +- Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.localization`, `helpers.task_scheduler`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `scheduler.get_task_by_uuid`, `Localization.get.set_timezone`, `scheduler.reload`, `self.use_context`, `scheduler.cancel_running_task`, `AgentContext.remove`, `persist_chat.remove_chat`, `scheduler.remove_task_by_uuid`, `context.reset`, `scheduler.update_task`, `scheduler.save`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/scheduler_task_run.py.dox.md b/api/scheduler_task_run.py.dox.md new file mode 100644 index 000000000..f4aa4b158 --- /dev/null +++ b/api/scheduler_task_run.py.dox.md @@ -0,0 +1,44 @@ +# scheduler_task_run.py DOX + +## Purpose + +- Own the `scheduler_task_run.py` API endpoint. +- This module handles scheduler task run requests. +- Keep this file-level DOX profile synchronized with `scheduler_task_run.py` because this directory is intentionally flat. + +## Ownership + +- `scheduler_task_run.py` owns the runtime implementation. +- `scheduler_task_run.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SchedulerTaskRun` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SchedulerTaskRun` is an `ApiHandler`. +- `SchedulerTaskRun` defines `process(...)`. +- Observed side-effect areas: settings/state persistence, scheduler state. +- Imported dependency areas include: `helpers.api`, `helpers.localization`, `helpers.print_style`, `helpers.task_scheduler`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `PrintStyle`, `scheduler.get_task_by_uuid`, `Localization.get.set_timezone`, `scheduler.reload`, `self._printer.error`, `scheduler.serialize_task`, `scheduler.run_task_by_uuid`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/scheduler_task_update.py.dox.md b/api/scheduler_task_update.py.dox.md new file mode 100644 index 000000000..a7c9bfced --- /dev/null +++ b/api/scheduler_task_update.py.dox.md @@ -0,0 +1,44 @@ +# scheduler_task_update.py DOX + +## Purpose + +- Own the `scheduler_task_update.py` API endpoint. +- This module handles scheduler task update requests. +- Keep this file-level DOX profile synchronized with `scheduler_task_update.py` because this directory is intentionally flat. + +## Ownership + +- `scheduler_task_update.py` owns the runtime implementation. +- `scheduler_task_update.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SchedulerTaskUpdate` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SchedulerTaskUpdate` is an `ApiHandler`. +- `SchedulerTaskUpdate` defines `process(...)`. +- Observed side-effect areas: settings/state persistence, secret handling, scheduler state. +- Imported dependency areas include: `helpers.api`, `helpers.localization`, `helpers.task_scheduler`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `scheduler.get_task_by_uuid`, `serialize_task`, `Localization.get.set_timezone`, `scheduler.reload`, `TaskState`, `scheduler.update_task`, `parse_task_schedule`, `parse_task_plan`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/scheduler_tasks_list.py.dox.md b/api/scheduler_tasks_list.py.dox.md new file mode 100644 index 000000000..5e5533206 --- /dev/null +++ b/api/scheduler_tasks_list.py.dox.md @@ -0,0 +1,44 @@ +# scheduler_tasks_list.py DOX + +## Purpose + +- Own the `scheduler_tasks_list.py` API endpoint. +- This module handles scheduler tasks list requests. +- Keep this file-level DOX profile synchronized with `scheduler_tasks_list.py` because this directory is intentionally flat. + +## Ownership + +- `scheduler_tasks_list.py` owns the runtime implementation. +- `scheduler_tasks_list.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SchedulerTasksList` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SchedulerTasksList` is an `ApiHandler`. +- `SchedulerTasksList` defines `process(...)`. +- Observed side-effect areas: scheduler state. +- Imported dependency areas include: `helpers.api`, `helpers.localization`, `helpers.print_style`, `helpers.task_scheduler`, `traceback`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `scheduler.serialize_all_tasks`, `Localization.get.set_timezone`, `scheduler.reload`, `PrintStyle.error`, `traceback.format_exc`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/scheduler_tick.py.dox.md b/api/scheduler_tick.py.dox.md new file mode 100644 index 000000000..d19ae305c --- /dev/null +++ b/api/scheduler_tick.py.dox.md @@ -0,0 +1,50 @@ +# scheduler_tick.py DOX + +## Purpose + +- Own the `scheduler_tick.py` API endpoint. +- This module handles scheduler tick requests. +- Keep this file-level DOX profile synchronized with `scheduler_tick.py` because this directory is intentionally flat. + +## Ownership + +- `scheduler_tick.py` owns the runtime implementation. +- `scheduler_tick.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SchedulerTick` (`ApiHandler`) + - `requires_loopback(cls) -> bool` + - `requires_auth(cls) -> bool` + - `requires_csrf(cls) -> bool` + - `async process(self, input: Input, request: Request) -> Output` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SchedulerTick` is an `ApiHandler`. +- `SchedulerTick` defines `process(...)`. +- `SchedulerTick` defines `requires_auth(...)`. +- `SchedulerTick` defines `requires_csrf(...)`. +- `SchedulerTick` defines `requires_loopback(...)`. +- Observed side-effect areas: settings/state persistence, scheduler state. +- Imported dependency areas include: `datetime`, `helpers.api`, `helpers.localization`, `helpers.print_style`, `helpers.task_scheduler`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `datetime.now.strftime`, `PrintStyle`, `scheduler.get_tasks`, `scheduler.serialize_all_tasks`, `Localization.get.set_timezone`, `scheduler.reload`, `scheduler.tick`, `datetime.now`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/self_update_get.py.dox.md b/api/self_update_get.py.dox.md new file mode 100644 index 000000000..261cac93f --- /dev/null +++ b/api/self_update_get.py.dox.md @@ -0,0 +1,45 @@ +# self_update_get.py DOX + +## Purpose + +- Own the `self_update_get.py` API endpoint. +- This module handles self update get API requests. +- Keep this file-level DOX profile synchronized with `self_update_get.py` because this directory is intentionally flat. + +## Ownership + +- `self_update_get.py` owns the runtime implementation. +- `self_update_get.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SelfUpdateGet` (`ApiHandler`) + - `get_methods(cls) -> list[str]` + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SelfUpdateGet` is an `ApiHandler`. +- `SelfUpdateGet` defines `process(...)`. +- `SelfUpdateGet` defines `get_methods(...)`. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self_update.get_update_info`, `runtime.is_dockerized`, `self_update.load_pending_update`, `self_update.load_last_status`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/self_update_schedule.py.dox.md b/api/self_update_schedule.py.dox.md new file mode 100644 index 000000000..b865070c9 --- /dev/null +++ b/api/self_update_schedule.py.dox.md @@ -0,0 +1,45 @@ +# self_update_schedule.py DOX + +## Purpose + +- Own the `self_update_schedule.py` API endpoint. +- This module handles self update schedule API requests. +- Keep this file-level DOX profile synchronized with `self_update_schedule.py` because this directory is intentionally flat. + +## Ownership + +- `self_update_schedule.py` owns the runtime implementation. +- `self_update_schedule.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SelfUpdateSchedule` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SelfUpdateSchedule` is an `ApiHandler`. +- `SelfUpdateSchedule` defines `process(...)`. +- Observed side-effect areas: filesystem writes, subprocess/runtime control. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `runtime.is_dockerized`, `self_update.schedule_update`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_self_update_tag_filter.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/self_update_tags.py.dox.md b/api/self_update_tags.py.dox.md new file mode 100644 index 000000000..fdf78cd23 --- /dev/null +++ b/api/self_update_tags.py.dox.md @@ -0,0 +1,43 @@ +# self_update_tags.py DOX + +## Purpose + +- Own the `self_update_tags.py` API endpoint. +- This module handles self update tags API requests. +- Keep this file-level DOX profile synchronized with `self_update_tags.py` because this directory is intentionally flat. + +## Ownership + +- `self_update_tags.py` owns the runtime implementation. +- `self_update_tags.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SelfUpdateTags` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SelfUpdateTags` is an `ApiHandler`. +- `SelfUpdateTags` defines `process(...)`. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `str.strip.lower`, `self_update.get_repo_version_info.get.strip.lower`, `self_update.get_available_branch_values`, `self_update.get_selector_tag_options`, `str.strip`, `self_update.get_repo_version_info.get.strip`, `runtime.is_dockerized`, `self_update.get_repo_version_info`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/settings_get.py.dox.md b/api/settings_get.py.dox.md new file mode 100644 index 000000000..e584283fd --- /dev/null +++ b/api/settings_get.py.dox.md @@ -0,0 +1,46 @@ +# settings_get.py DOX + +## Purpose + +- Own the `settings_get.py` API endpoint. +- This module returns current application settings. +- Keep this file-level DOX profile synchronized with `settings_get.py` because this directory is intentionally flat. + +## Ownership + +- `settings_get.py` owns the runtime implementation. +- `settings_get.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `GetSettings` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + - `get_methods(cls) -> list[str]` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `GetSettings` is an `ApiHandler`. +- `GetSettings` defines `process(...)`. +- `GetSettings` defines `get_methods(...)`. +- Observed side-effect areas: settings/state persistence. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `settings.get_settings`, `settings.convert_out`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/settings_set.py.dox.md b/api/settings_set.py.dox.md new file mode 100644 index 000000000..0f08f9862 --- /dev/null +++ b/api/settings_set.py.dox.md @@ -0,0 +1,44 @@ +# settings_set.py DOX + +## Purpose + +- Own the `settings_set.py` API endpoint. +- This module persists application settings updates. +- Keep this file-level DOX profile synchronized with `settings_set.py` because this directory is intentionally flat. + +## Ownership + +- `settings_set.py` owns the runtime implementation. +- `settings_set.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SetSettings` (`ApiHandler`) + - `async process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SetSettings` is an `ApiHandler`. +- `SetSettings` defines `process(...)`. +- Observed side-effect areas: settings/state persistence. +- Imported dependency areas include: `helpers`, `helpers.api`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `settings.convert_in`, `settings.set_settings`, `settings.convert_out`, `settings.Settings`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/settings_workdir_file_structure.py.dox.md b/api/settings_workdir_file_structure.py.dox.md new file mode 100644 index 000000000..e52a742ef --- /dev/null +++ b/api/settings_workdir_file_structure.py.dox.md @@ -0,0 +1,46 @@ +# settings_workdir_file_structure.py DOX + +## Purpose + +- Own the `settings_workdir_file_structure.py` API endpoint. +- This module handles settings workdir file structure API requests. +- Keep this file-level DOX profile synchronized with `settings_workdir_file_structure.py` because this directory is intentionally flat. + +## Ownership + +- `settings_workdir_file_structure.py` owns the runtime implementation. +- `settings_workdir_file_structure.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SettingsWorkdirFileStructure` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + - `get_methods(cls) -> list[str]` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SettingsWorkdirFileStructure` is an `ApiHandler`. +- `SettingsWorkdirFileStructure` defines `process(...)`. +- `SettingsWorkdirFileStructure` defines `get_methods(...)`. +- Observed side-effect areas: filesystem reads, settings/state persistence. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `files.get_abs_path_development`, `Exception`, `file_tree.file_tree`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/skills.py.dox.md b/api/skills.py.dox.md new file mode 100644 index 000000000..8da5af317 --- /dev/null +++ b/api/skills.py.dox.md @@ -0,0 +1,54 @@ +# skills.py DOX + +## Purpose + +- Own the `skills.py` API endpoint. +- This module lists and manages available skills for settings and agent-facing skill flows. +- Keep this file-level DOX profile synchronized with `skills.py` because this directory is intentionally flat. + +## Ownership + +- `skills.py` owns the runtime implementation. +- `skills.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Skills` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + - `list_skills(self, input: Input)` + - `delete_skill(self, input: Input)` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Skills` is an `ApiHandler`. +- `Skills` defines `process(...)`. +- Observed side-effect areas: filesystem reads, filesystem deletion. +- Imported dependency areas include: `helpers`, `helpers.api`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `skills.list_skills`, `result.sort`, `str.strip`, `skills.delete_skill`, `projects.get_project_folder`, `runtime.is_development`, `Exception`, `self.list_skills`, `strip`, `files.normalize_a0_path`, `files.get_abs_path`, `self.delete_skill`, `files.is_in_dir`, `projects.get_project_meta`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_a0_connector_prompt_gating.py` + - `tests/test_browser_agent_regressions.py` + - `tests/test_document_query_plugin.py` + - `tests/test_fasta2a_client.py` + - `tests/test_office_canvas_setup.py` + - `tests/test_office_document_store.py` + - `tests/test_skills_runtime.py` + - `tests/test_time_travel.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/skills_import.py.dox.md b/api/skills_import.py.dox.md new file mode 100644 index 000000000..4940e833a --- /dev/null +++ b/api/skills_import.py.dox.md @@ -0,0 +1,44 @@ +# skills_import.py DOX + +## Purpose + +- Own the `skills_import.py` API endpoint. +- This module handles skills import API requests. +- Keep this file-level DOX profile synchronized with `skills_import.py` because this directory is intentionally flat. + +## Ownership + +- `skills_import.py` owns the runtime implementation. +- `skills_import.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SkillsImport` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SkillsImport` is an `ApiHandler`. +- `SkillsImport` defines `process(...)`. +- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion. +- Imported dependency areas include: `__future__`, `helpers`, `helpers.api`, `helpers.skills_import`, `os`, `pathlib`, `time`, `uuid`, `werkzeug.datastructures`, `werkzeug.utils`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.use_context`, `strip.lower`, `Path`, `tmp_dir.mkdir`, `secure_filename`, `time.strftime`, `skills_file.save`, `strip`, `files.get_abs_path`, `base.lower.endswith`, `import_skills`, `files.deabsolute_path`, `uuid.uuid4`, `tmp_path.unlink`, `base.lower`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/skills_import_preview.py.dox.md b/api/skills_import_preview.py.dox.md new file mode 100644 index 000000000..107f55b2a --- /dev/null +++ b/api/skills_import_preview.py.dox.md @@ -0,0 +1,44 @@ +# skills_import_preview.py DOX + +## Purpose + +- Own the `skills_import_preview.py` API endpoint. +- This module handles skills import preview API requests. +- Keep this file-level DOX profile synchronized with `skills_import_preview.py` because this directory is intentionally flat. + +## Ownership + +- `skills_import_preview.py` owns the runtime implementation. +- `skills_import_preview.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SkillsImportPreview` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `SkillsImportPreview` is an `ApiHandler`. +- `SkillsImportPreview` defines `process(...)`. +- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion. +- Imported dependency areas include: `__future__`, `helpers`, `helpers.api`, `helpers.skills_import`, `os`, `pathlib`, `time`, `uuid`, `werkzeug.datastructures`, `werkzeug.utils`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.use_context`, `strip.lower`, `Path`, `tmp_dir.mkdir`, `secure_filename`, `time.strftime`, `skills_file.save`, `strip`, `files.get_abs_path`, `base.lower.endswith`, `import_skills`, `files.deabsolute_path`, `uuid.uuid4`, `tmp_path.unlink`, `base.lower`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/skills_scan.py b/api/skills_scan.py new file mode 100644 index 000000000..c1ba6871a --- /dev/null +++ b/api/skills_scan.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import shutil +import time +import uuid +from pathlib import Path +from typing import Any + +from helpers import files, skills +from helpers.api import ApiHandler, Request, Response +from helpers.skills_import import extract_skills_zip +from werkzeug.datastructures import FileStorage +from werkzeug.utils import secure_filename + + +class SkillsScan(ApiHandler): + """ + Prepare skill scan targets for the Settings > Skills scanner. + """ + + async def process(self, input: dict[str, Any], request: Request) -> dict[str, Any] | Response: + if "skills_file" in request.files: + return self._prepare_uploaded_archive(request.files["skills_file"]) + + action = str(input.get("action") or "targets").strip().lower() + if action == "targets": + return self._list_installed_targets() + + return {"success": False, "error": "Invalid action"} + + def _list_installed_targets(self) -> dict[str, Any]: + targets: list[dict[str, Any]] = [] + seen: set[str] = set() + total_skills = 0 + + for raw_root in skills.get_skill_roots(): + root = Path(raw_root) + if not root.is_dir(): + continue + + skill_files = skills.discover_skill_md_files(root) + if not skill_files: + continue + + key = str(root.resolve()) + if key in seen: + continue + seen.add(key) + + skill_count = len(skill_files) + total_skills += skill_count + targets.append( + { + "path": str(root), + "display_path": files.normalize_a0_path(str(root)), + "skill_count": skill_count, + } + ) + + targets.sort(key=lambda item: item["path"]) + return { + "success": True, + "target_type": "installed", + "target_label": "Installed Agent Zero skills", + "targets": targets, + "paths": [item["path"] for item in targets], + "skill_count": total_skills, + } + + def _prepare_uploaded_archive(self, skills_file: FileStorage) -> dict[str, Any]: + if not skills_file.filename: + return {"success": False, "error": "No file selected"} + + base = secure_filename(skills_file.filename) # type: ignore[arg-type] + if not base.lower().endswith(".zip"): + return {"success": False, "error": "Skill scan uploads must be .zip files"} + + tmp_dir = Path(files.get_abs_path("tmp", "uploads")) + tmp_dir.mkdir(parents=True, exist_ok=True) + unique = uuid.uuid4().hex[:8] + stamp = time.strftime("%Y%m%d_%H%M%S") + tmp_path = tmp_dir / f"skills_scan_{stamp}_{unique}_{base}" + skills_file.save(str(tmp_path)) + + cleanup_root: Path | None = None + try: + scan_root, cleanup_root = extract_skills_zip( + tmp_path, + tmp_subdir="skill_scans", + prefix=f"scan_{unique}", + ) + skill_files = skills.discover_skill_md_files(scan_root) + skill_entries = [ + { + "path": str(skill_md.parent), + "relative_path": str(skill_md.parent.relative_to(scan_root)), + } + for skill_md in skill_files + ] + warnings = [] + if not skill_entries: + warnings.append("No SKILL.md files were found in the uploaded archive.") + + return { + "success": True, + "target_type": "uploaded_archive", + "target_label": base, + "paths": [str(scan_root)], + "scan_path": str(scan_root), + "display_path": files.normalize_a0_path(str(scan_root)), + "cleanup_paths": [str(cleanup_root)], + "skill_count": len(skill_entries), + "skills": skill_entries, + "warnings": warnings, + } + except Exception as exc: + if cleanup_root: + shutil.rmtree(cleanup_root, ignore_errors=True) + return {"success": False, "error": f"Failed to prepare skill scan: {exc}"} + finally: + try: + tmp_path.unlink(missing_ok=True) # type: ignore[arg-type] + except Exception: + pass diff --git a/api/skills_scan.py.dox.md b/api/skills_scan.py.dox.md new file mode 100644 index 000000000..b277ae73e --- /dev/null +++ b/api/skills_scan.py.dox.md @@ -0,0 +1,46 @@ +# skills_scan.py DOX + +## Purpose + +- Own the `skills_scan.py` API endpoint. +- Provide scan target discovery and uploaded skills archive preparation for the Settings > Skills scanner. +- Keep this file-level DOX profile synchronized with `skills_scan.py` because this directory is intentionally flat. + +## Ownership + +- `skills_scan.py` owns the runtime implementation. +- `skills_scan.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SkillsScan` (`ApiHandler`) + - `async process(self, input: dict[str, Any], request: Request) -> dict[str, Any] | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- The JSON request action `targets` returns existing installed skill roots that contain at least one `SKILL.md`. +- Multipart requests with `skills_file` accept only `.zip` uploads, extract them into `tmp/skill_scans`, discover contained `SKILL.md` folders, and return `paths` plus `cleanup_paths` for the scanner prompt. +- Uploaded archives are not imported, installed, or executed by this endpoint. +- Temporary uploaded zip files under `tmp/uploads` are deleted after extraction or failure. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion. +- Imported dependency areas include: `__future__`, `helpers`, `helpers.api`, `helpers.skills_import`, `pathlib`, `shutil`, `time`, `typing`, `uuid`, `werkzeug.datastructures`, `werkzeug.utils`. + +## Key Concepts + +- Installed target discovery uses `helpers.skills.get_skill_roots()` and filters to roots where `discover_skill_md_files()` finds skills. +- Uploaded zip preparation uses `extract_skills_zip()` so zip entries remain bounded to the temp extraction root. +- Response paths are local absolute paths for the scanner agent, while `display_path` provides normalized `/a0/...` style display when possible. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Do not execute uploaded files or scan targets in this endpoint. +- Keep temp extraction paths explicit so the LLM-driven scan prompt can clean them up. + +## Verification + +- Run endpoint-specific or API tests for changed behavior; smoke-test uploaded zip and installed-skill scan modal flows when practical. + +## Child DOX Index + +No child DOX files. diff --git a/api/subagents.py.dox.md b/api/subagents.py.dox.md new file mode 100644 index 000000000..85777218f --- /dev/null +++ b/api/subagents.py.dox.md @@ -0,0 +1,49 @@ +# subagents.py DOX + +## Purpose + +- Own the `subagents.py` API endpoint. +- This module returns subordinate agent profile data for UI and delegation flows. +- Keep this file-level DOX profile synchronized with `subagents.py` because this directory is intentionally flat. + +## Ownership + +- `subagents.py` owns the runtime implementation. +- `subagents.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Subagents` (`ApiHandler`) + - `async process(self, input: Input, request: Request) -> Output` + - `get_subagents_list(self)` + - `load_agent(self, name: str | None)` + - `save_agent(self, name: str | None, data: dict | None)` + - `delete_agent(self, name: str | None)` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Subagents` is an `ApiHandler`. +- `Subagents` defines `process(...)`. +- Observed side-effect areas: filesystem writes, filesystem deletion. +- Imported dependency areas include: `helpers`, `helpers.api`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `subagents.get_agents_list`, `subagents.load_agent_data`, `subagents.SubAgent`, `subagents.save_agent_data`, `subagents.delete_agent_data`, `self.use_context`, `Exception`, `self.get_subagents_list`, `self.load_agent`, `self.save_agent`, `self.delete_agent`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_skills_runtime.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/tunnel.py.dox.md b/api/tunnel.py.dox.md new file mode 100644 index 000000000..3618edc6e --- /dev/null +++ b/api/tunnel.py.dox.md @@ -0,0 +1,48 @@ +# tunnel.py DOX + +## Purpose + +- Own the `tunnel.py` API endpoint. +- This module manages tunnel provider status, start, and stop actions. +- Keep this file-level DOX profile synchronized with `tunnel.py` because this directory is intentionally flat. + +## Ownership + +- `tunnel.py` owns the runtime implementation. +- `tunnel.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Tunnel` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` +- Top-level functions: +- `async process(input: dict) -> dict | Response` +- `stop()` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `Tunnel` is an `ApiHandler`. +- `Tunnel` defines `process(...)`. +- Observed side-effect areas: tunnel state. +- Imported dependency areas include: `helpers`, `helpers.api`, `helpers.tunnel_manager`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `TunnelManager.get_instance`, `tunnel_manager.stop_tunnel`, `runtime.get_web_ui_port`, `tunnel_manager.start_tunnel`, `tunnel_manager.get_last_error`, `process`, `tunnel_manager.get_notifications`, `stop`, `tunnel_manager.get_tunnel_url`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_tunnel_remote_link.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/tunnel_proxy.py.dox.md b/api/tunnel_proxy.py.dox.md new file mode 100644 index 000000000..b713482c9 --- /dev/null +++ b/api/tunnel_proxy.py.dox.md @@ -0,0 +1,46 @@ +# tunnel_proxy.py DOX + +## Purpose + +- Own the `tunnel_proxy.py` API endpoint. +- This module proxies tunnel-related HTTP traffic through the configured tunnel provider. +- Keep this file-level DOX profile synchronized with `tunnel_proxy.py` because this directory is intentionally flat. + +## Ownership + +- `tunnel_proxy.py` owns the runtime implementation. +- `tunnel_proxy.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `TunnelProxy` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` +- Top-level functions: +- `async process(input: dict) -> dict | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `TunnelProxy` is an `ApiHandler`. +- `TunnelProxy` defines `process(...)`. +- Observed side-effect areas: network calls, settings/state persistence, tunnel state. +- Imported dependency areas include: `helpers`, `helpers.api`, `helpers.tunnel_manager`, `requests`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `runtime.get_arg`, `requests.post`, `process`, `dotenv.get_dotenv_value`, `response.json`, `local_process`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/upload.py.dox.md b/api/upload.py.dox.md new file mode 100644 index 000000000..9fccb571f --- /dev/null +++ b/api/upload.py.dox.md @@ -0,0 +1,47 @@ +# upload.py DOX + +## Purpose + +- Own the `upload.py` API endpoint. +- This module accepts general uploads into runtime upload storage. +- Keep this file-level DOX profile synchronized with `upload.py` because this directory is intentionally flat. + +## Ownership + +- `upload.py` owns the runtime implementation. +- `upload.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `UploadFile` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` + - `allowed_file(self, filename)` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `UploadFile` is an `ApiHandler`. +- `UploadFile` defines `process(...)`. +- Observed side-effect areas: filesystem reads, settings/state persistence. +- Imported dependency areas include: `helpers`, `helpers.api`, `helpers.security`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `request.files.getlist`, `Exception`, `self.allowed_file`, `safe_filename`, `file.save`, `files.get_abs_path`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_browser_agent_regressions.py` + - `tests/test_image_get_security.py` + +## Child DOX Index + +No child DOX files. diff --git a/api/upload_work_dir_files.py.dox.md b/api/upload_work_dir_files.py.dox.md new file mode 100644 index 000000000..6cf9370ae --- /dev/null +++ b/api/upload_work_dir_files.py.dox.md @@ -0,0 +1,47 @@ +# upload_work_dir_files.py DOX + +## Purpose + +- Own the `upload_work_dir_files.py` API endpoint. +- This module handles workdir file operations for upload work dir files. +- Keep this file-level DOX profile synchronized with `upload_work_dir_files.py` because this directory is intentionally flat. + +## Ownership + +- `upload_work_dir_files.py` owns the runtime implementation. +- `upload_work_dir_files.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `UploadWorkDirFiles` (`ApiHandler`) + - `async process(self, input: dict, request: Request) -> dict | Response` +- Top-level functions: +- `async upload_files(uploaded_files: list[FileStorage], current_path: str)` +- `async upload_file(current_path: str, filename: str, base64_content: str)` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `UploadWorkDirFiles` is an `ApiHandler`. +- `UploadWorkDirFiles` defines `process(...)`. +- Observed side-effect areas: filesystem writes. +- Imported dependency areas include: `api`, `base64`, `helpers`, `helpers.api`, `helpers.file_browser`, `os`, `posixpath`, `werkzeug.datastructures`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `runtime.is_development`, `FileBrowser`, `browser.save_file_b64`, `request.files.getlist`, `browser.save_files`, `Exception`, `upload_files`, `runtime.call_development_function`, `file.stream.read`, `base64.b64encode.decode`, `extension.call_extensions_async`, `base64.b64encode`, `posixpath.join`, `str.rstrip`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/ws_dev_test.py.dox.md b/api/ws_dev_test.py.dox.md new file mode 100644 index 000000000..aaad42608 --- /dev/null +++ b/api/ws_dev_test.py.dox.md @@ -0,0 +1,44 @@ +# ws_dev_test.py DOX + +## Purpose + +- Own the `ws_dev_test.py` API endpoint. +- This module provides a development WebSocket test namespace. +- Keep this file-level DOX profile synchronized with `ws_dev_test.py` because this directory is intentionally flat. + +## Ownership + +- `ws_dev_test.py` owns the runtime implementation. +- `ws_dev_test.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `WsDevTest` (`WsHandler`) + - `async process(self, event: str, data: dict, sid: str) -> dict[str, Any] | WsResult | None` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `WsDevTest` is a `WsHandler`. +- `WsDevTest` defines `process(...)`. +- Observed side-effect areas: filesystem writes, network calls, WebSocket state. +- Imported dependency areas include: `asyncio`, `helpers`, `helpers.print_style`, `helpers.ws`, `helpers.ws_manager`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `event.startswith`, `self.manager.register_diagnostic_watcher`, `self.manager.unregister_diagnostic_watcher`, `PrintStyle.info`, `PrintStyle.debug`, `PrintStyle.warning`, `runtime.is_development`, `WsResult.error`, `self.broadcast`, `asyncio.sleep`, `self.emit_to`, `self.dispatch_to_all_sids`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/ws_hello.py.dox.md b/api/ws_hello.py.dox.md new file mode 100644 index 000000000..863b99167 --- /dev/null +++ b/api/ws_hello.py.dox.md @@ -0,0 +1,44 @@ +# ws_hello.py DOX + +## Purpose + +- Own the `ws_hello.py` API endpoint. +- This module provides a small WebSocket hello/test namespace. +- Keep this file-level DOX profile synchronized with `ws_hello.py` because this directory is intentionally flat. + +## Ownership + +- `ws_hello.py` owns the runtime implementation. +- `ws_hello.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `WsHello` (`WsHandler`) + - `async process(self, event: str, data: dict, sid: str) -> dict | None` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `WsHello` is a `WsHandler`. +- `WsHello` defines `process(...)`. +- Observed side-effect areas: WebSocket state. +- Imported dependency areas include: `helpers.print_style`, `helpers.ws`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `PrintStyle.info`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- 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/api/ws_webui.py.dox.md b/api/ws_webui.py.dox.md new file mode 100644 index 000000000..a9cebf317 --- /dev/null +++ b/api/ws_webui.py.dox.md @@ -0,0 +1,49 @@ +# ws_webui.py DOX + +## Purpose + +- Own the `ws_webui.py` API endpoint. +- This module owns the primary WebUI WebSocket namespace and event bridge. +- Keep this file-level DOX profile synchronized with `ws_webui.py` because this directory is intentionally flat. + +## Ownership + +- `ws_webui.py` owns the runtime implementation. +- `ws_webui.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `WsWebui` (`WsHandler`) + - `async on_connect(self, sid: str) -> None` + - `async on_disconnect(self, sid: str) -> None` + - `async process(self, event: str, data: dict, sid: str) -> dict | None` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- `WsWebui` is a `WsHandler`. +- `WsWebui` defines `process(...)`. +- Observed side-effect areas: network calls, WebSocket state, settings/state persistence. +- Imported dependency areas include: `helpers`, `helpers.ws`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `extension.call_extensions_async`. +- Keep request/response, tool, or helper semantics documented here at the same time as source changes. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Update frontend callers, plugin callers, and tests together when payload shape changes. +- Use `helpers.api.Response` for non-JSON responses, files, redirects, or status-specific replies. + +## Verification + +- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. +- Related tests observed by source search: + - `tests/test_state_sync_handler.py` + - `tests/test_state_sync_welcome_screen.py` + - `tests/test_ws_handlers.py` + +## Child DOX Index + +No child DOX files. diff --git a/conf/model_providers.yaml b/conf/model_providers.yaml index 12235e58f..58bb2e7cc 100644 --- a/conf/model_providers.yaml +++ b/conf/model_providers.yaml @@ -14,7 +14,7 @@ # # Optional fields: # kwargs: A dictionary of extra parameters to pass to LiteLLM. -# This is useful for `api_base`, `extra_headers`, etc. +# This is useful for `api_base`, `extra_headers`, non-secret local placeholders, etc. # # Optional model listing fields (used by the Model Configuration plugin): # models_list: @@ -83,6 +83,20 @@ chat: models_list: 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: + name: llama.cpp + litellm_provider: hosted_vllm + models_list: + 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: name: Mistral AI litellm_provider: mistral @@ -107,12 +121,26 @@ chat: endpoint_url: "/api/tags" format: "ollama" default_base: "http://host.docker.internal:11434" + kwargs: + a0_api_mode: chat + api_base: "http://host.docker.internal:11434" + omlx: + name: oMLX + litellm_provider: hosted_vllm + models_list: + 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: name: Ollama Cloud litellm_provider: openai models_list: endpoint_url: "/models" kwargs: + a0_api_mode: chat api_base: https://ollama.com/v1 openai: name: OpenAI @@ -150,9 +178,20 @@ 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 + vllm: + name: vLLM + litellm_provider: hosted_vllm + models_list: + 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: name: xAI litellm_provider: xai @@ -175,6 +214,8 @@ chat: other: name: Other OpenAI compatible litellm_provider: openai + kwargs: + a0_api_mode: chat embedding: huggingface: @@ -186,12 +227,29 @@ embedding: lm_studio: name: LM Studio litellm_provider: lm_studio + kwargs: + api_base: "http://host.docker.internal:1234/v1" + api_key: "lm-studio" + llama_cpp: + name: llama.cpp + litellm_provider: hosted_vllm + kwargs: + api_base: "http://host.docker.internal:8080/v1" + api_key: "llama-cpp" mistral: name: Mistral AI litellm_provider: mistral ollama: name: Ollama litellm_provider: ollama + kwargs: + api_base: "http://host.docker.internal:11434" + omlx: + name: oMLX + litellm_provider: hosted_vllm + kwargs: + api_base: "http://host.docker.internal:8000/v1" + api_key: "omlx" openai: name: OpenAI litellm_provider: openai @@ -225,6 +283,12 @@ embedding: endpoint_url: "https://api.venice.ai/api/v1/models" kwargs: api_base: https://api.venice.ai/api/v1 + vllm: + name: vLLM + litellm_provider: hosted_vllm + kwargs: + api_base: "http://host.docker.internal:8000/v1" + api_key: "vllm" other: name: Other OpenAI compatible litellm_provider: openai diff --git a/docker/AGENTS.md b/docker/AGENTS.md index 0b58f32e8..757608953 100644 --- a/docker/AGENTS.md +++ b/docker/AGENTS.md @@ -31,4 +31,9 @@ ## Child DOX Index -No child DOX files. +Direct child DOX files: + +| Child | Scope | +| --- | --- | +| [base/AGENTS.md](base/AGENTS.md) | Base image Dockerfile, copied filesystem, and installation scripts. | +| [run/AGENTS.md](run/AGENTS.md) | Runnable image Dockerfile, compose example, entrypoints, and install scripts. | diff --git a/docker/base/AGENTS.md b/docker/base/AGENTS.md new file mode 100644 index 000000000..bcfb986d7 --- /dev/null +++ b/docker/base/AGENTS.md @@ -0,0 +1,34 @@ +# Docker Base Image DOX + +## Purpose + +- Own the Agent Zero base image build context. +- Build the operating system, package, Python, SearXNG, SSH, and bootstrap layers reused by runnable images. + +## Ownership + +- `Dockerfile` owns base image layering and installation order. +- `build.txt` owns maintainer build and push command notes. +- `fs/ins/` owns installation scripts copied into the image. +- Files under `fs/` are copied to container root during the base build. + +## Local Contracts + +- Preserve cache-friendly package and runtime installation stages. +- Keep locale and timezone defaults compatible with the root Docker contract. +- Do not add secrets, user data, or local environment files to the image context. +- Installation scripts must be noninteractive and suitable for multi-architecture buildx runs. + +## Work Guidance + +- Keep base dependencies here only when they are common to runnable Agent Zero images. +- Coordinate Python runtime changes with root Docker documentation and runnable image setup. + +## Verification + +- Build `docker/base` when changing Dockerfile or install scripts. +- Run a runnable image smoke check when base runtime behavior changes. + +## Child DOX Index + +No child DOX files. diff --git a/docker/run/AGENTS.md b/docker/run/AGENTS.md new file mode 100644 index 000000000..b5607ae69 --- /dev/null +++ b/docker/run/AGENTS.md @@ -0,0 +1,39 @@ +# Docker Runtime Image DOX + +## Purpose + +- Own the runnable Agent Zero image context and local compose example. +- Install Agent Zero from a selected branch onto the base image and prepare runtime entrypoints. + +## Ownership + +- `Dockerfile` owns branch-based image assembly, exposed ports, and container startup command. +- `docker-compose.yml` owns the local compose service example. +- `build.txt` owns maintainer build and push command notes. +- `fs/exe/` owns runtime entrypoint, supervisor, self-update, Node eval, and service scripts. +- `fs/ins/` owns preinstall, installation, virtualenv, Playwright, SSH, and postinstall scripts. +- Files under `fs/` are copied to container root during the runtime build. + +## Local Contracts + +- `BRANCH` is required for branch-based Docker builds. +- Preserve exposed ports for SSH, HTTP, and tunneled services unless docs and workflows are updated together. +- Keep the two-runtime Python model aligned with the root contract. +- 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. + +## Work Guidance + +- Keep startup scripts explicit about framework runtime versus execution runtime. +- Coordinate tag, branch, and publishing changes with GitHub workflow automation. + +## Verification + +- Build `docker/run` when changing Dockerfile or install scripts. +- Smoke-test container startup after entrypoint, supervisor, port, or compose changes. + +## Child DOX Index + +No child DOX files. diff --git a/docker/run/docker-compose.yml b/docker/run/docker-compose.yml index cc48f3f1b..b80da0272 100644 --- a/docker/run/docker-compose.yml +++ b/docker/run/docker-compose.yml @@ -5,4 +5,10 @@ services: volumes: - ./agent-zero:/a0 ports: - - "50080:80" \ No newline at end of file + - "50080:80" + ulimits: + nofile: + soft: 65535 + hard: 65535 + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/docker/run/fs/exe/initialize.sh b/docker/run/fs/exe/initialize.sh index 8c329bb30..f4c4dcd25 100644 --- a/docker/run/fs/exe/initialize.sh +++ b/docker/run/fs/exe/initialize.sh @@ -9,9 +9,49 @@ if [ -z "$1" ]; then fi BRANCH="$1" +raise_open_file_limit() { + local requested="${A0_NOFILE_LIMIT:-65535}" + local soft + local hard + local target + + if ! [[ "$requested" =~ ^[0-9]+$ ]] || [ "$requested" -lt 1 ]; then + echo "Warning: invalid A0_NOFILE_LIMIT='$requested'; keeping open file limit at $(ulimit -S -n)." >&2 + return + fi + + soft="$(ulimit -S -n)" + hard="$(ulimit -H -n)" + + if [ "$soft" = "unlimited" ]; then + echo "Open file limit is already unlimited." + return + fi + + target="$requested" + if [ "$hard" != "unlimited" ] && [ "$target" -gt "$hard" ]; then + target="$hard" + fi + + if [ "$target" -gt "$soft" ]; then + if ulimit -S -n "$target"; then + echo "Raised open file soft limit from $soft to $(ulimit -S -n) (hard: $hard)." + else + echo "Warning: failed to raise open file soft limit from $soft to $target (hard: $hard)." >&2 + fi + else + echo "Open file soft limit is $soft (target: $requested, hard: $hard)." + fi +} + +raise_open_file_limit + # Copy all contents from persistent /per to root directory (/) without overwriting cp -r --no-preserve=ownership,mode /per/* / +# Ensure upload storage exists before API and connector callers can reference it. +mkdir -p /a0/usr/uploads + # allow execution of /root/.bashrc and /root/.profile chmod 444 /root/.bashrc chmod 444 /root/.profile diff --git a/docker/run/fs/exe/self_update_manager.py b/docker/run/fs/exe/self_update_manager.py index abf4e595f..4ae23ce28 100644 --- a/docker/run/fs/exe/self_update_manager.py +++ b/docker/run/fs/exe/self_update_manager.py @@ -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" diff --git a/docker/run/fs/ins/install_A0.sh b/docker/run/fs/ins/install_A0.sh index 0aeaf13ff..7b5d0d807 100644 --- a/docker/run/fs/ins/install_A0.sh +++ b/docker/run/fs/ins/install_A0.sh @@ -36,8 +36,6 @@ fi # Install remaining A0 python packages uv pip install -r /git/agent-zero/requirements.txt -# override for packages that have unnecessarily strict dependencies -uv pip install -r /git/agent-zero/requirements2.txt # install playwright bash /ins/install_playwright.sh "$@" diff --git a/docker/run/fs/ins/install_additional.sh b/docker/run/fs/ins/install_additional.sh index 31546deaa..e9c844be7 100644 --- a/docker/run/fs/ins/install_additional.sh +++ b/docker/run/fs/ins/install_additional.sh @@ -46,16 +46,11 @@ install_xpra_repo() { apt-get update if ! xpra_install_check; then - if [ "$arch" != "amd64" ]; then - echo "xpra packages are not installable from ${uri} ${suite} for ${arch}; falling back to https://xpra.org trixie" - XPRA_PACKAGES=(xpra-server xpra-x11 xpra-html5) - configure_xpra_repo "https://xpra.org" "trixie" "$arch" - apt-get update - if ! xpra_install_check; then - cat /tmp/xpra-install-check.log - exit 1 - fi - else + echo "xpra packages are not installable from ${uri} ${suite} for ${arch}; falling back to https://xpra.org trixie" + XPRA_PACKAGES=(xpra-server xpra-x11 xpra-html5) + configure_xpra_repo "https://xpra.org" "trixie" "$arch" + apt-get update + if ! xpra_install_check; then cat /tmp/xpra-install-check.log exit 1 fi 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/README.md b/docs/README.md index 655dc0e44..5c668f029 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,8 +12,9 @@ docs focus on practical setup, screenshots, and user workflows. ## Quick Start - **[Quickstart Guide](quickstart.md):** Get up and running in 5 minutes with Agent Zero. -- **[First-Run Onboarding](guides/onboarding.md):** Choose Cloud or Local, add a provider key, and select main and utility models. -- **[Installation Guide](setup/installation.md):** Install scripts, updates, and advanced Docker setup (includes [How to Update](setup/installation.md#how-to-update-agent-zero)). +- **[Agent Zero Launcher](guides/launcher.md):** Use the desktop app to set up Docker, install Agent Zero, open Instances, or connect a remote Instance. +- **[First-Run Onboarding](guides/onboarding.md):** Choose Cloud, AI account, or Local access, then select main and utility models. +- **[Installation Guide](setup/installation.md):** A0 Launcher downloads, A0 Install, direct Docker, updates, and advanced Docker setup (includes [How to Update](setup/installation.md#how-to-update-agent-zero)). - **[A0 CLI Connector](guides/a0-cli-connector.md):** Install the host connector for a running Agent Zero instance, use the command palette, and switch Browser modes. - **[Self Update](guides/self-update.md):** How the in-app updater works (technical reference). - **[VPS Deployment](setup/vps-deployment.md):** Deploy Agent Zero on a remote server. @@ -22,12 +23,13 @@ docs focus on practical setup, screenshots, and user workflows. ## User Guides - **[Usage Guide](guides/usage.md):** Practical tour of Agent Zero's main workflows. +- **[Agent Zero Launcher](guides/launcher.md):** Fresh-machine Launcher walkthrough, Docker setup gate, Installs, Instances, and docs screenshot capture with Playwright/Electron. - **[First-Run Onboarding](guides/onboarding.md):** Set up OpenRouter, our proxy API or another provider with the guided wizard. - **[Browser Guide](guides/browser.md):** Use the built-in Browser, live Canvas surface, annotations, screenshots, host browser mode, and extensions. - **[Desktop Guide](guides/desktop.md):** Use the built-in Linux desktop, GUI apps, and LibreOffice Writer/Calc/Impress Cowork. - **[A0 CLI Connector](guides/a0-cli-connector.md):** Terminal-first host connector for Agent Zero, with screenshots of the host picker, connected shell, command palette, and Browser modes. - **[Create a Small Plugin](guides/create-plugin.md):** Build and review a tiny Web UI plugin that adds an unread dot to the chat list. -- **[Skills Guide](guides/skills.md):** Open the Skills selector, add active skills, and remove prompt extras you no longer need. +- **[Skills Guide](guides/skills.md):** Open the Skills selector, add active skills, and remove prompt protocol entries you no longer need. - **[Agent Profiles](guides/agent-profiles.md):** Switch the current chat profile or create a new guided profile from the chat input. - **[Model Presets](guides/model-presets.md):** Create simple named shortcuts for model setups. - **[Memory Guide](guides/memory.md):** Search, edit, delete, and curate memories so useful context does not become stale noise. @@ -63,6 +65,7 @@ docs focus on practical setup, screenshots, and user workflows. - [Quick Start](#quick-start) - [Quickstart Guide](quickstart.md) + - [Agent Zero Launcher](guides/launcher.md) - [First-Run Onboarding](guides/onboarding.md) - [Installation Guide](setup/installation.md) - [How to Update Agent Zero](setup/installation.md#how-to-update-agent-zero) @@ -113,6 +116,7 @@ docs focus on practical setup, screenshots, and user workflows. - [File Browser](guides/usage.md#file-browser) - [Memory Management](guides/usage.md#memory-management) - [Backup And Restore](guides/usage.md#backup-and-restore) + - [Agent Zero Launcher](guides/launcher.md) - [Browser Guide](guides/browser.md) - [Desktop Guide](guides/desktop.md) - [A0 CLI Connector](guides/a0-cli-connector.md) 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` | + ![Chrome remote debugging setting](../res/usage/browser/host-browser-remote-debugging-setting.png) 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. ![Chrome remote debugging allow prompt](../res/usage/browser/host-browser-remote-debugging-allow.png) @@ -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/agent-profiles.md b/docs/guides/agent-profiles.md index 41b885fd2..b3e9d9751 100644 --- a/docs/guides/agent-profiles.md +++ b/docs/guides/agent-profiles.md @@ -66,6 +66,10 @@ These controls are related, but they solve different problems. | **Project** | Files, workspace, memories, instructions, secrets, and long-running context. | | **Model Preset** | Which models are used for the chat. | +For small local models that narrate instead of calling tools, use the bundled +**Tiny Local** profile or the project-scoped Prompt Include recipe in +[Local Model Tool Use](local-model-tool-use.md). + Example: - use a **Project** for a client repository; 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/..." +``` ![Host browser remote debugging setting](../res/usage/browser/host-browser-remote-debugging-setting.png) diff --git a/docs/guides/launcher.md b/docs/guides/launcher.md new file mode 100644 index 000000000..2036eb88e --- /dev/null +++ b/docs/guides/launcher.md @@ -0,0 +1,177 @@ +# Agent Zero Launcher + +Agent Zero Launcher is the desktop app for installing, running, switching, and +opening Dockerized Agent Zero Instances without starting from Docker commands. + +Use it when you are setting up a new machine, when you want a quiet inventory of +installed Agent Zero images, or when you want one place to open local and remote +Instances. + +## Start Fresh On A New Machine + +1. Download Agent Zero Launcher from the + [A0 Launcher releases](https://github.com/agent0ai/a0-launcher/releases). +2. Open the app. +3. If the launcher cannot reach Docker yet, follow the setup dialog. +4. If Agent Zero is already hosted on another computer or VPS, click + **Add remote Instance** instead of setting up local Docker. + +![Launcher runtime setup dialog](../res/usage/launcher/launcher-runtime-setup.png) + +The first setup dialog keeps the choice simple: + +- **Continue** starts the local runtime setup or refreshes the runtime state. +- **Refresh** checks again after you start Docker yourself. +- **Add remote Instance** saves an existing Agent Zero URL and lets you use the + Launcher without local Docker. + +## Installs + +When Docker is ready, Launcher opens to **Installs**. This page shows official +Agent Zero release lines and local images. + +![Launcher Installs view](../res/usage/launcher/launcher-installs.png) + +Cards usually mean: + +- **latest** tracks the newest published Agent Zero release image. +- **ready** tracks the development-ready image when you intentionally work from + that branch. +- Version cards such as **1.20**, **1.19**, or **1.18** are pinned release + images. +- **Install** downloads an image. +- **Run** starts an installed image as a local Instance. + +## Instances + +Open **Instances** after you run Agent Zero. This is where local containers and +saved remote Instances live. + +Use the Instance card to: + +- open the Web UI; +- start, stop, rename, or delete the container; +- open logs; +- use **Backup `/a0/usr`** to download the same user-data backup you can create + from Agent Zero Core; +- use **Restore `/a0/usr`** to restore that backup zip into the selected + Instance; +- open A0 CLI when the host connector is installed. + +Launcher keeps local Instances and remote Instances separate, so deleting a +container is not the same as deleting a saved remote URL or a workspace backup. + +## Updating With Launcher + +For same-major Agent Zero updates, the Web UI **Self Update** is still the +normal path. + +For a major image jump such as v1.20 -> v2.0, use Launcher or Docker to start a +new v2.0 Instance, then restore a backup from the old Instance. In Launcher, the +flow is: **Instances -> Backup `/a0/usr`** on the old v1.20 Instance, **Installs +-> latest -> Install/Run**, then **Instances -> Restore `/a0/usr`** on the new +v2.0 Instance. This avoids mixing an old root install with a new Docker image. + +See [Updating from v1.20 to v2.0](../setup/installation.md#updating-from-v120-to-v20). + +## Capture Launcher Screenshots With Playwright + +Launcher is an Electron app, so browser-only Playwright commands are not enough. +Use Playwright's Electron bridge and the local Electron binary from the Launcher +repo. + +The pattern below installs Playwright into a temporary folder outside the repo, +launches local Launcher content, waits for the `a0app://content/` window, and +saves a screenshot. + +```bash +mkdir -p /tmp/a0-launcher-playwright +npm install --prefix /tmp/a0-launcher-playwright playwright +``` + +```bash +NODE_PATH=/tmp/a0-launcher-playwright/node_modules node <<'JS' +const { _electron: electron } = require("playwright"); + +const launcher = "/home/eclypso/a0/a0-launcher"; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +(async () => { + const app = await electron.launch({ + executablePath: `${launcher}/node_modules/electron/dist/electron`, + args: [launcher], + env: { + ...process.env, + A0_LAUNCHER_LOCAL_REPO: launcher, + ELECTRON_DISABLE_SECURITY_WARNINGS: "true", + }, + }); + + const windows = []; + app.on("window", (page) => windows.push(page)); + windows.push(await app.firstWindow()); + + let page = null; + const deadline = Date.now() + 45000; + while (Date.now() < deadline && !page) { + page = windows.find((item) => + item && !item.isClosed() && item.url().startsWith("a0app://content/") + ) || null; + if (!page) { + await app.waitForEvent("window", { timeout: 1000 }) + .then((item) => windows.push(item)) + .catch(() => null); + await sleep(250); + } + } + + if (!page) throw new Error("Launcher content window did not open"); + + await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => null); + await sleep(3000); + await page.screenshot({ + path: `${launcher}/output/playwright/launcher-installs.png`, + fullPage: false, + }); + await app.close(); +})(); +JS +``` + +For docs screenshots that show the first-run runtime gate without changing the +real machine state, open the real Launcher page and render the real runtime-gate +component with a minimal demo state: + +```js +await page.evaluate(async () => { + const { renderRuntimeGate } = await import( + "a0app://content/components/docker-manager/runtime-gate/runtime-gate.js" + ); + + renderRuntimeGate({ + stateLoaded: true, + dockerAvailable: false, + runtime: { + platform: "linux", + state: "not_provisioned", + action: "install", + canProvision: true, + setupActionLabel: "Setup Agent Zero", + detail: "No local container runtime was found.", + }, + versions: [{ id: "latest", availability: "available" }], + images: [], + containers: [], + remoteInstances: [], + }, { + refresh() {}, + provisionRuntime() {}, + openDockerDownload() {}, + addRemoteInstance() {}, + }); +}); + +await page.locator(".dm-runtime-gate").screenshot({ + path: "/home/eclypso/a0/a0-launcher/output/playwright/launcher-runtime-setup.png", +}); +``` diff --git a/docs/guides/local-model-tool-use.md b/docs/guides/local-model-tool-use.md new file mode 100644 index 000000000..803583b77 --- /dev/null +++ b/docs/guides/local-model-tool-use.md @@ -0,0 +1,65 @@ +# Local Model Tool Use + +Small local models can struggle with Agent Zero's full default communication shape. The safest first fix is prompt/profile/plugin-only: use a smaller behavior contract while leaving Agent Zero's core parser and execution code unchanged. + +Use this guide for Ollama, LM Studio, Qwen, and similar local chat models when the model explains commands instead of calling tools. + +## Use The Tiny Local Profile + +Choose the **Tiny Local** profile when starting or switching a chat that uses a small local model. + +The bundled profile lives at: + +```text +agents/tiny-local/ +``` + +Tiny Local keeps the normal Agent Zero tool-call shape, but removes visible reasoning fields from the communication prompt. It tells the model to emit one executable JSON object with `tool_name` and `tool_args`. + +## Use A Project Prompt Include + +If you want to keep your current profile, create a project-local file that matches the Prompt Include plugin pattern (`*.promptinclude.md`): + +```text +local-model-tool-use.promptinclude.md +``` + +Put this content in that file: + +```markdown +## Local model tool-use discipline + +You are Agent Zero. Act on the user's behalf. + +When the user asks you to do something, do it directly. Do not explain how the user could do it themselves. + +Your visible assistant message must be exactly one valid JSON object. + +Use exactly these top-level fields: `tool_name` and `tool_args`. + +Do not include markdown fences, prose before the JSON, prose after the JSON, hidden reasoning, analysis, thoughts, or headlines. + +Choose a tool from the tools listed in the system prompt. Do not invent tool names, action names, or generic names such as `read`, `write`, `terminal`, or `multi`. + +For a final user-facing answer, use the `response` tool: + +`{"tool_name":"response","tool_args":{"text":"Done."}}` + +Use `response` only when the work is complete, blocked, or no tool is needed. If the user says "proceed", "continue", "go ahead", or similar after the agent named a next step, call the next appropriate tool instead of replying with a promise or status update. + +For work that requires a command, file action, browser action, or any other available tool, call the appropriate tool immediately. + +If the framework warns that your prior message was malformed, repeated, or reasoning-only, output a corrected JSON tool request immediately without explaining the warning. +``` + +## Keep This Prompt-Only + +Do not change `agent.py` for this workflow. + +Do not change `helpers/extract_tools.py` for this workflow. + +Do not create parser repair code for this workflow. + +Do not add duplicate execution suppression, LiteLLM transport changes, memory runtime changes, or text-editor file operation changes for this workflow. + +If a specific local model still cannot follow the prompt/profile/plugin-only contract, capture the exact model, prompt, response, and tool warning before considering deeper framework changes. diff --git a/docs/guides/model-presets.md b/docs/guides/model-presets.md index 18f3bc0f2..ff23a06fd 100644 --- a/docs/guides/model-presets.md +++ b/docs/guides/model-presets.md @@ -83,7 +83,7 @@ You can always rename them later. | **Model Preset** | Which models power the chat. | | **Agent Profile** | The agent's role, tone, and prompt behavior. | | **Project** | Workspace, files, memory, secrets, and project instructions. | -| **Skill** | A specific procedure added to prompt extras. | +| **Skill** | A specific procedure added to prompt protocol. | For example, you can use the same "Researcher" Agent Profile with a cheaper preset for simple questions and a stronger preset for difficult investigations. diff --git a/docs/guides/onboarding.md b/docs/guides/onboarding.md index 012e56c5c..1b9879190 100644 --- a/docs/guides/onboarding.md +++ b/docs/guides/onboarding.md @@ -1,62 +1,59 @@ # First-Run Onboarding Use onboarding the first time you open Agent Zero, or any time the Web UI says -your models still need setup. The wizard helps you pick a provider, add the -needed key or connection, choose the main model, choose the utility model, and -start chatting. +your models still need setup. The wizard helps you pick Cloud, AI account, or +Local access, configure a main model, choose a utility model, and start +chatting. -This example uses **Agent Zero API** with a fake demo key and -`claude-opus-4-6`. Replace the demo key with your own key. +This example uses **OpenRouter** with a masked demo key. Replace the demo key +with your own key. -## Choose Cloud Or Local +## Open Onboarding -Open the Web UI and click **Start Onboarding** from the welcome banner. On the -first screen, choose whether Agent Zero should use a hosted provider or a local -model server. +Open the Web UI. The welcome screen can show account shortcuts, and the message +composer is ready immediately. -![Onboarding start screen with Cloud and Local choices](../res/usage/onboarding/onboarding-start.png) +![Welcome screen with account and channel setup cards](../res/usage/onboarding/onboarding-start.png) -Choose **Cloud** when you want to use Agent Zero API, OpenRouter, Anthropic, -OpenAI, Google, Venice, or another hosted provider. Choose **Local** when you -want to connect to Ollama, LM Studio, or another model server running on your -machine. +If you send a message before models are configured, Agent Zero creates the chat, +holds the message, and shows the model gate inside the conversation. Choose +**Cloud provider**, **AI account**, or **Local model** to open onboarding. -## Pick Agent Zero API +![Chat model gate with Cloud provider, AI account, and Local model choices](../res/usage/onboarding/onboarding-model-gate.png) -On the Cloud provider screen, click **Agent Zero API**. +## Choose A Provider Path -![Cloud provider picker with Agent Zero API](../res/usage/onboarding/onboarding-cloud-provider.png) +Choose **Cloud** when you want to paste an API key for OpenRouter, OpenAI, +Anthropic, Google, Venice, or another hosted provider. -The ChatGPT/Codex account option is also available on this Cloud screen if you -want to connect by device code instead of pasting a provider key. +![Cloud provider picker with hosted providers](../res/usage/onboarding/onboarding-cloud-provider.png) -## Add The Key And Main Model +Choose **Account** when you want to sign in with Codex/ChatGPT, GitHub Copilot, +Google Cloud Gemini, or xAI Grok instead of pasting a provider key. -Paste your Agent Zero API key in **API key**. The screenshot uses a fake example -key, and the field is masked. +![AI account picker with Codex, Copilot, Google Cloud Gemini, and Grok](../res/usage/onboarding/onboarding-account-provider.png) -Click the magnifier in **Main model** to open the model list, then choose -`claude-opus-4-6`. +Choose **Local** when you want to connect to Ollama, LM Studio, oMLX, llama.cpp, +vLLM, or another model server running on your machine. -![Agent Zero API setup with model dropdown open](../res/usage/onboarding/onboarding-agent-zero-api-model-dropdown.png) +![Local model provider picker with Ollama, LM Studio, oMLX, llama.cpp, and vLLM](../res/usage/onboarding/onboarding-local-ollama-main.png) -After the model is selected, click **Choose utility model**. +## Configure The Main Model -![Agent Zero API setup with key and main model selected](../res/usage/onboarding/onboarding-agent-zero-api-key-model.png) +For API-key providers, choose the main model and paste the provider key. The +screenshot uses a masked demo key. + +![OpenRouter setup with main model and masked API key](../res/usage/onboarding/onboarding-agent-zero-api-key-model.png) + +After the main model is selected, click **Choose utility model**. ## Choose The Utility Model -The utility model handles supporting work such as summarizing, organizing -memory, and other background tasks. +The utility model handles quick internal tasks such as summaries, naming, and +memory. A small, fast, cheap model usually works best here. The wizard may +prefill a utility provider and model, but it remains an explicit choice. -Leave **Use same as Main Model** checked if you want both roles to use -`claude-opus-4-6`. - -![Utility model step using the same model as main](../res/usage/onboarding/onboarding-utility-same-model.png) - -You can also uncheck **Use same as Main Model** and choose a different utility -provider and model. For example, you might keep a strong main model for chat and -use a faster or cheaper model for utility tasks. +![Utility model step with OpenRouter and a fast Gemini model](../res/usage/onboarding/onboarding-utility-same-model.png) Click **Finish setup** when the utility model looks right. diff --git a/docs/guides/self-update.md b/docs/guides/self-update.md index 22e490a09..e52b6a438 100644 --- a/docs/guides/self-update.md +++ b/docs/guides/self-update.md @@ -10,6 +10,8 @@ For day-to-day upgrades inside a running instance: The UI will tell you when a new A0 update is available for download. Backups are automatically managed internally during the update process. +![Self Update modal showing the current v2.0 state](../res/usage/updating/self-update-v2-current.png) + --- ## Technical reference @@ -71,6 +73,24 @@ Self-update is intentionally limited to changes within the same major line. If a newer major line exists, the UI points you to the Docker setup guide because those upgrades require downloading a new Docker image. They can include operating system level changes or other breaking changes outside the repository checkout. +## v1.20 to v2.0 + +Use the Docker image update path for v1.20 -> v2.0. Self Update can show that a +newer major release line exists, but it intentionally keeps the version selector +inside the current major line. + +![Self Update warning for a newer major release line](../res/usage/updating/self-update-v1-to-v2-warning.png) + +The important part is moving a backup zip into a fresh v2.0 container: + +1. In the old v1.20 Web UI, create a backup from **Settings -> Check for Updates -> Backup & Restore -> Create Backup**. +2. Pull `agent0ai/agent-zero:latest` in Docker Desktop or Docker CLI. For the v2.0 release, `latest` is the v2.0 image. +3. Start a new container from that image, or use the **latest** card in **Agent Zero Launcher**. +4. Restore the downloaded backup zip into the new v2.0 Instance. +5. Verify the new Instance before deleting the old v1.20 container. + +For command examples, see [Updating from v1.20 to v2.0](../setup/installation.md#updating-from-v120-to-v20). + ## Safety notes - Gitignored paths are preserved during update diff --git a/docs/guides/skills.md b/docs/guides/skills.md index 215d87586..3a959d157 100644 --- a/docs/guides/skills.md +++ b/docs/guides/skills.md @@ -29,7 +29,7 @@ Click a skill to add it. Active skills are shown at the top of the selector. To remove a skill, use the remove button in **Active skills** or uncheck it in the list. -Active skills are added to the **Extras** part of the system prompt. That means +Active skills are added to the **Protocol** part of the prompt. That means Agent Zero sees them every turn while they are active. > [!TIP] @@ -56,7 +56,7 @@ usually easier for the agent to follow. | Control | What it changes | | --- | --- | -| **Skills** | Adds a specific procedure to the current prompt extras. | +| **Skills** | Adds a specific procedure to the current prompt protocol. | | **Agent Profiles** | Changes the broader role and behavior of the chat. | | **Projects** | Adds workspace, files, memory, secrets, and project instructions. | 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/docs/guides/usage.md b/docs/guides/usage.md index d5bc931e5..76dda859c 100644 --- a/docs/guides/usage.md +++ b/docs/guides/usage.md @@ -74,7 +74,7 @@ Use the selector to add or remove active skills. ![Skills selector](../res/usage/webui/skills-selector-checked.png) -Active skills are added to the **Extras** part of the system prompt, so keep the +Active skills are added to the **Protocol** part of the prompt, so keep the list short and intentional. See the [Skills guide](skills.md). ### Agent Profiles diff --git a/docs/quickstart.md b/docs/quickstart.md index 59c569fe3..bdc446cf3 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -5,7 +5,13 @@ Agent Zero, add a model or API key, open the Web UI, and give it a concrete job. ## Installation (recommended) -Run one command; the script handles Docker, image pull, and container setup. +Choose the path that matches your machine: + +- Use [A0 Launcher](guides/launcher.md) if you want a desktop app on a fresh + machine. It can set up the local runtime, download Agent Zero, open + Instances, or save a remote Instance URL. +- Use A0 Install if you want the terminal path. The script handles Docker + detection, image pull, and container setup. **macOS / Linux:** ```bash @@ -23,7 +29,7 @@ Follow the CLI prompts for port and authentication, complete onboarding, then op > To update later, open **Settings UI -> Update tab -> Open Self Update** (see [How to Update](setup/installation.md#how-to-update-agent-zero)). Backups are automatically managed internally. > [!NOTE] -> For manual Docker Desktop setup, volume mapping, and platform-specific detail, see the [Installation Guide](setup/installation.md#manual-installation-advanced). +> For Launcher downloads, headless installer flags, direct Docker, manual Docker Desktop setup, volume mapping, and platform-specific detail, see the [Installation Guide](setup/installation.md). ## Use Agent Zero on your real local files @@ -56,18 +62,19 @@ For the full setup flow, host picker screenshots, command palette guidance, Brow ### Open the Web UI and complete onboarding Open your browser and navigate to `http://localhost:`. The Web UI will -show the onboarding banner. Click **Start Onboarding** to choose Cloud or -Local, add a provider key or account connection, and select your main and -utility models. +open on the welcome screen. If models still need setup, send a message or use +the setup shortcuts to choose Cloud, AI account, or Local access, then select +your main and utility models. ![Agent Zero Web UI](res/setup/6-docker-a0-running-new.png) -For a screenshot walkthrough using **Agent Zero API** with -`claude-opus-4-6`, see the [First-Run Onboarding guide](guides/onboarding.md). +For a screenshot walkthrough using OpenRouter, see the +[First-Run Onboarding guide](guides/onboarding.md). > [!NOTE] -> Agent Zero supports hosted providers and local models. You can use the same -> provider for main and utility work, or choose separate providers for each. +> Agent Zero supports hosted providers, account-backed providers, and local +> models. Choose a strong main model for chat and a fast utility model for +> internal tasks. ### Start your first chat @@ -141,7 +148,7 @@ Explains how to search, edit, delete, export, and curate memories before stale c ### [Open A0 Skills Guide](guides/skills.md) -Shows the chat input **+** menu, the Skills selector, and how active skills are added to prompt extras. +Shows the chat input **+** menu, the Skills selector, and how active skills are added to prompt protocol. ### [Open A0 Agent Profiles Guide](guides/agent-profiles.md) diff --git a/docs/res/usage/launcher/launcher-installs.png b/docs/res/usage/launcher/launcher-installs.png new file mode 100644 index 000000000..01f90fe78 Binary files /dev/null and b/docs/res/usage/launcher/launcher-installs.png differ diff --git a/docs/res/usage/launcher/launcher-runtime-setup.png b/docs/res/usage/launcher/launcher-runtime-setup.png new file mode 100644 index 000000000..fc1029bec Binary files /dev/null and b/docs/res/usage/launcher/launcher-runtime-setup.png differ diff --git a/docs/res/usage/onboarding/onboarding-account-provider.png b/docs/res/usage/onboarding/onboarding-account-provider.png new file mode 100644 index 000000000..803b1ea46 Binary files /dev/null and b/docs/res/usage/onboarding/onboarding-account-provider.png differ diff --git a/docs/res/usage/onboarding/onboarding-agent-zero-api-key-model.png b/docs/res/usage/onboarding/onboarding-agent-zero-api-key-model.png index c46498169..28aeebef7 100644 Binary files a/docs/res/usage/onboarding/onboarding-agent-zero-api-key-model.png and b/docs/res/usage/onboarding/onboarding-agent-zero-api-key-model.png differ diff --git a/docs/res/usage/onboarding/onboarding-agent-zero-api-model-dropdown.png b/docs/res/usage/onboarding/onboarding-agent-zero-api-model-dropdown.png index b899b35ad..28aeebef7 100644 Binary files a/docs/res/usage/onboarding/onboarding-agent-zero-api-model-dropdown.png and b/docs/res/usage/onboarding/onboarding-agent-zero-api-model-dropdown.png differ diff --git a/docs/res/usage/onboarding/onboarding-cloud-provider.png b/docs/res/usage/onboarding/onboarding-cloud-provider.png index efcb181fd..dcdbcc481 100644 Binary files a/docs/res/usage/onboarding/onboarding-cloud-provider.png and b/docs/res/usage/onboarding/onboarding-cloud-provider.png differ diff --git a/docs/res/usage/onboarding/onboarding-local-ollama-main.png b/docs/res/usage/onboarding/onboarding-local-ollama-main.png new file mode 100644 index 000000000..3c7891c44 Binary files /dev/null and b/docs/res/usage/onboarding/onboarding-local-ollama-main.png differ diff --git a/docs/res/usage/onboarding/onboarding-model-gate.png b/docs/res/usage/onboarding/onboarding-model-gate.png new file mode 100644 index 000000000..457939151 Binary files /dev/null and b/docs/res/usage/onboarding/onboarding-model-gate.png differ diff --git a/docs/res/usage/onboarding/onboarding-ready.png b/docs/res/usage/onboarding/onboarding-ready.png index 81b215f68..c11a6ce38 100644 Binary files a/docs/res/usage/onboarding/onboarding-ready.png and b/docs/res/usage/onboarding/onboarding-ready.png differ diff --git a/docs/res/usage/onboarding/onboarding-start.png b/docs/res/usage/onboarding/onboarding-start.png index 5102833d1..eb618ed4d 100644 Binary files a/docs/res/usage/onboarding/onboarding-start.png and b/docs/res/usage/onboarding/onboarding-start.png differ diff --git a/docs/res/usage/onboarding/onboarding-utility-same-model.png b/docs/res/usage/onboarding/onboarding-utility-same-model.png index 992497535..f0c7048d9 100644 Binary files a/docs/res/usage/onboarding/onboarding-utility-same-model.png and b/docs/res/usage/onboarding/onboarding-utility-same-model.png differ diff --git a/docs/res/usage/updating/self-update-v1-to-v2-warning.png b/docs/res/usage/updating/self-update-v1-to-v2-warning.png new file mode 100644 index 000000000..1454ea2f8 Binary files /dev/null and b/docs/res/usage/updating/self-update-v1-to-v2-warning.png differ diff --git a/docs/res/usage/updating/self-update-v2-current.png b/docs/res/usage/updating/self-update-v2-current.png new file mode 100644 index 000000000..f593adf5d Binary files /dev/null and b/docs/res/usage/updating/self-update-v2-current.png differ diff --git a/docs/setup/installation.md b/docs/setup/installation.md index a966b9191..3571d0f99 100644 --- a/docs/setup/installation.md +++ b/docs/setup/installation.md @@ -6,24 +6,77 @@ ## Quick Start (Recommended) -The install script is the fastest way to get Agent Zero running. It handles Docker, image pulling, and container setup automatically. +Agent Zero runs as a Docker container, and you now have two friendly ways to get +there: -**macOS / Linux:** +- **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):** +#### Windows PowerShell ```powershell irm https://ps.agent-zero.ai | iex ``` -**Docker (run directly):** +#### Headless / scripted + +For servers and automation, Quick Start mode creates one instance and exits +without opening menus: + ```bash -docker run -p 80:80 agent0ai/agent-zero +curl -fsSL https://bash.agent-zero.ai | bash -s -- --quick-start --name agent-zero --port 5080 ``` -Once the install completes, open the URL shown in your terminal to access the Web UI. Follow the prompts in the CLI to set your port and authentication, complete onboarding, add your API key, then continue to [Step 3: Configure Agent Zero](#step-3-configure-agent-zero). +```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. @@ -44,6 +97,54 @@ You'll also be prompted through the UI when a new A0 version is released. Backup 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. + +![Self Update warning for a newer major release line](../res/usage/updating/self-update-v1-to-v2-warning.png) + +#### 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. @@ -61,9 +162,9 @@ If you are upgrading from Agent Zero v0.9.8 or earlier to v1.1 or newer, use the 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` -4. On the **old** instance: **Settings -> Backup & Restore -> Create Backup** -5. On the **new** instance: **Restore** the backup +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] @@ -300,10 +401,10 @@ docker run -p 0:80 -v /path/to/your/work_dir:/a0/usr agent0ai/agent-zero ## Step 3: Configure Agent Zero -The UI will show a welcome banner when model setup is missing. Click -**Start Onboarding** to choose Cloud or Local, add a provider key or account -connection, and select your main and utility models. For the screenshot -walkthrough, see the [First-Run Onboarding guide](../guides/onboarding.md). +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 @@ -438,10 +539,19 @@ Use the naming format required by your selected provider: | 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. @@ -461,6 +571,113 @@ Use the naming format required by your selected provider: --- +## 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. @@ -527,13 +744,13 @@ Replace `` with the name of the model you want to use. For example: 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. Provide your API base URL to your Ollama API endpoint, usually `http://host.docker.internal:11434` +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. ![ollama](../res/setup/settings/4-local-models.png) > [!NOTE] -> If Agent Zero runs in Docker and Ollama runs on the host, ensure port **11434** is reachable from the container. If both services are in the same Docker network, you can use `http://:11434` instead of `host.docker.internal`. +> 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 diff --git a/extensions/python/AGENTS.md b/extensions/python/AGENTS.md index 53538a8e1..079c0ca5b 100644 --- a/extensions/python/AGENTS.md +++ b/extensions/python/AGENTS.md @@ -31,4 +31,37 @@ ## Child DOX Index -No child DOX files. +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_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) | Start-of-monologue behavior such as chat renaming. | +| [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 new file mode 100644 index 000000000..4a7331962 --- /dev/null +++ b/extensions/python/_functions/AGENTS.md @@ -0,0 +1,30 @@ +# 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. + +## 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/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 new file mode 100644 index 000000000..be026d7e2 --- /dev/null +++ b/extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py @@ -0,0 +1,44 @@ +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.json_parse_dirty(message) is not None: + 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/agent_init/AGENTS.md b/extensions/python/agent_init/AGENTS.md new file mode 100644 index 000000000..3121c8244 --- /dev/null +++ b/extensions/python/agent_init/AGENTS.md @@ -0,0 +1,26 @@ +# 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/banners/AGENTS.md b/extensions/python/banners/AGENTS.md new file mode 100644 index 000000000..f2ce20f10 --- /dev/null +++ b/extensions/python/banners/AGENTS.md @@ -0,0 +1,28 @@ +# 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 3b05b21b3..19145fef3 100644 --- a/extensions/python/banners/_10_unsecured_connection.py +++ b/extensions/python/banners/_10_unsecured_connection.py @@ -24,7 +24,7 @@ class UnsecuredConnectionCheck(Extension): "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" diff --git a/extensions/python/banners/_30_system_resources.py b/extensions/python/banners/_30_system_resources.py index 25c65a670..3c1949f4f 100644 --- a/extensions/python/banners/_30_system_resources.py +++ b/extensions/python/banners/_30_system_resources.py @@ -69,34 +69,37 @@ class SystemResourcesCheck(Extension): "priority": 10, "title": "System Resources", "html": ( - "
" - "
" - "
" - "
" - "
CPU
" - f"
{cpu_value}
" + "
" + "
" + "
" + "
CPU
" + f"
{cpu_value}
" "
" f"{cpu_bar}" "
" - "
" - "
" - "
RAM
" - f"
{ram_value}
" + "
" + "
" + "
RAM
" + f"
{ram_value}
" "
" f"{ram_bar}" "
" - "
" - "
" - "
Disk
" - f"
{disk_value}
" + "
" + "
" + "
Disk
" + f"
{disk_value}
" "
" f"{disk_bar}" "
" + "
" + "
" + "
Load (1/5/15)
" + f"
{load_value}
" + "
" + "
" + "
Net (since boot)
" + f"
{net_sent} sent / {net_recv} recv
" "
" - "
" - "
" - f"
Load (1/5/15)
{load_value}
" - f"
Net (since boot)
{net_sent} sent / {net_recv} recv
" "
" "
" ), @@ -117,9 +120,8 @@ class SystemResourcesCheck(Extension): color = "#22c55e" return ( - "
" - f"
" + "
" + f"" "
" ) diff --git a/extensions/python/before_main_llm_call/AGENTS.md b/extensions/python/before_main_llm_call/AGENTS.md new file mode 100644 index 000000000..36214e3b3 --- /dev/null +++ b/extensions/python/before_main_llm_call/AGENTS.md @@ -0,0 +1,26 @@ +# 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/error_format/AGENTS.md b/extensions/python/error_format/AGENTS.md new file mode 100644 index 000000000..6dd136a73 --- /dev/null +++ b/extensions/python/error_format/AGENTS.md @@ -0,0 +1,26 @@ +# 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/hist_add_before/AGENTS.md b/extensions/python/hist_add_before/AGENTS.md new file mode 100644 index 000000000..791b47294 --- /dev/null +++ b/extensions/python/hist_add_before/AGENTS.md @@ -0,0 +1,26 @@ +# 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_tool_result/AGENTS.md b/extensions/python/hist_add_tool_result/AGENTS.md new file mode 100644 index 000000000..c1dd0ecf2 --- /dev/null +++ b/extensions/python/hist_add_tool_result/AGENTS.md @@ -0,0 +1,27 @@ +# 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 21d6ea0ee..fd5a32854 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,4 +1,5 @@ from typing import Any +from agent import AgentContextType from helpers.extension import Extension from helpers import files, persist_chat import os, re @@ -9,6 +10,9 @@ 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 diff --git a/extensions/python/job_loop/AGENTS.md b/extensions/python/job_loop/AGENTS.md new file mode 100644 index 000000000..afa7597a3 --- /dev/null +++ b/extensions/python/job_loop/AGENTS.md @@ -0,0 +1,26 @@ +# 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/message_loop_end/AGENTS.md b/extensions/python/message_loop_end/AGENTS.md new file mode 100644 index 000000000..2200c4aa3 --- /dev/null +++ b/extensions/python/message_loop_end/AGENTS.md @@ -0,0 +1,27 @@ +# 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 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 new file mode 100644 index 000000000..d18b1c5a1 --- /dev/null +++ b/extensions/python/message_loop_prompts_after/AGENTS.md @@ -0,0 +1,31 @@ +# 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. + +## 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/_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 b3f88f988..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 @@ -1,38 +1,78 @@ from helpers.extension import Extension -from helpers import skills -from tools.skills_tool import DATA_NAME_LOADED_SKILLS +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 - extras = loop_data.extras_persistent - - # Get loaded skills names - skill_names = self.agent.data.get(DATA_NAME_LOADED_SKILLS) + skill_names = skills.get_loaded_skill_names(self.agent) if not skill_names: return - # load skill text here - content = "" + # 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: - if not skills.find_skill(skill_name, agent=self.agent): + skill = skills.find_skill(skill_name, agent=self.agent) + if not skill: continue - visible_skill_names.append(skill_name) - skill_data = skills.load_skill_for_agent(skill_name=skill_name, agent=self.agent) - content += "\n\n" + skill_data - self.agent.data[DATA_NAME_LOADED_SKILLS] = visible_skill_names - content = content.strip() - if not content: + 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 - # Inject into extras - extras["loaded_skills"] = self.agent.read_prompt( - "agent.system.skills.loaded.md", - skills=content, - ) + 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/_72_include_parallel_jobs.py b/extensions/python/message_loop_prompts_after/_72_include_parallel_jobs.py new file mode 100644 index 000000000..70bc0ce4e --- /dev/null +++ b/extensions/python/message_loop_prompts_after/_72_include_parallel_jobs.py @@ -0,0 +1,13 @@ +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_before/AGENTS.md b/extensions/python/message_loop_prompts_before/AGENTS.md new file mode 100644 index 000000000..3483592c7 --- /dev/null +++ b/extensions/python/message_loop_prompts_before/AGENTS.md @@ -0,0 +1,26 @@ +# 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 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/message_loop_start/AGENTS.md b/extensions/python/message_loop_start/AGENTS.md new file mode 100644 index 000000000..a0c6c1790 --- /dev/null +++ b/extensions/python/message_loop_start/AGENTS.md @@ -0,0 +1,26 @@ +# 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/monologue_end/AGENTS.md b/extensions/python/monologue_end/AGENTS.md new file mode 100644 index 000000000..b842ba071 --- /dev/null +++ b/extensions/python/monologue_end/AGENTS.md @@ -0,0 +1,26 @@ +# 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_start/AGENTS.md b/extensions/python/monologue_start/AGENTS.md new file mode 100644 index 000000000..3c4b32c48 --- /dev/null +++ b/extensions/python/monologue_start/AGENTS.md @@ -0,0 +1,27 @@ +# Monologue Start Extensions DOX + +## Purpose + +- Own backend behavior that runs when a monologue starts. + +## Ownership + +- Ordered Python files own automatic chat renaming and future monologue-start setup. + +## Local Contracts + +- Keep automatic rename behavior bounded and non-destructive. +- Do not override explicit user chat names without the intended guard conditions. +- Surface Utility Model rename failures with one scoped error notification per chat. + +## Work Guidance + +- Coordinate rename behavior with chat persistence and WebUI refresh after successful saves. + +## Verification + +- Smoke-test new chat naming and existing named chat behavior after changes. + +## Child DOX Index + +No child DOX files. diff --git a/extensions/python/monologue_start/_60_rename_chat.py b/extensions/python/monologue_start/_60_rename_chat.py index c23486b94..ee2361cf6 100644 --- a/extensions/python/monologue_start/_60_rename_chat.py +++ b/extensions/python/monologue_start/_60_rename_chat.py @@ -1,5 +1,7 @@ from helpers import persist_chat, tokens from helpers.extension import Extension +from helpers.notification import NotificationManager, NotificationPriority, NotificationType +from helpers.state_monitor_integration import mark_dirty_all from agent import LoopData import asyncio @@ -29,16 +31,35 @@ class RenameChat(Extension): "fw.rename_chat.msg.md", current_name=current_name, history=history_text ) # call utility model - new_name = await self.agent.call_utility_model( - system=system, message=message, background=True - ) + try: + new_name = await self.agent.call_utility_model( + system=system, message=message, background=True + ) + except Exception: + NotificationManager.send_notification( + type=NotificationType.ERROR, + priority=NotificationPriority.NORMAL, + title="Chat Rename Failed", + message="Automatic chat renaming failed because the Utility Model was not reachable.", + detail=( + "Automatic chat renaming uses the Utility Model. Check Settings > Models > " + "Utility Model, provider/API key, and network reachability." + ), + display_time=10, + group="chat_rename", + id=f"chat_rename_failed_{self.agent.context.id}", + ) + return # update name if new_name: - # trim name to max length if needed + new_name = " ".join(str(new_name).split()) if len(new_name) > 40: new_name = new_name[:40] + "..." + if not new_name: + return # apply to context and save self.agent.context.name = new_name persist_chat.save_tmp_chat(self.agent.context) - except Exception as e: + mark_dirty_all(reason="monologue_start.RenameChat.change_name") + except Exception: pass # non-critical diff --git a/extensions/python/process_chain_end/AGENTS.md b/extensions/python/process_chain_end/AGENTS.md new file mode 100644 index 000000000..c70340417 --- /dev/null +++ b/extensions/python/process_chain_end/AGENTS.md @@ -0,0 +1,26 @@ +# 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/reasoning_stream/AGENTS.md b/extensions/python/reasoning_stream/AGENTS.md new file mode 100644 index 000000000..6cd3e6b2e --- /dev/null +++ b/extensions/python/reasoning_stream/AGENTS.md @@ -0,0 +1,26 @@ +# 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_chunk/AGENTS.md b/extensions/python/reasoning_stream_chunk/AGENTS.md new file mode 100644 index 000000000..4e7461224 --- /dev/null +++ b/extensions/python/reasoning_stream_chunk/AGENTS.md @@ -0,0 +1,26 @@ +# 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_end/AGENTS.md b/extensions/python/reasoning_stream_end/AGENTS.md new file mode 100644 index 000000000..d702b22ad --- /dev/null +++ b/extensions/python/reasoning_stream_end/AGENTS.md @@ -0,0 +1,26 @@ +# 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/response_stream/AGENTS.md b/extensions/python/response_stream/AGENTS.md new file mode 100644 index 000000000..53a42df09 --- /dev/null +++ b/extensions/python/response_stream/AGENTS.md @@ -0,0 +1,27 @@ +# 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. +- 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_chunk/AGENTS.md b/extensions/python/response_stream_chunk/AGENTS.md new file mode 100644 index 000000000..9ceff97a6 --- /dev/null +++ b/extensions/python/response_stream_chunk/AGENTS.md @@ -0,0 +1,26 @@ +# 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_end/AGENTS.md b/extensions/python/response_stream_end/AGENTS.md new file mode 100644 index 000000000..963620e5d --- /dev/null +++ b/extensions/python/response_stream_end/AGENTS.md @@ -0,0 +1,26 @@ +# 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. + +## 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/startup_migration/AGENTS.md b/extensions/python/startup_migration/AGENTS.md new file mode 100644 index 000000000..fc75b8a8b --- /dev/null +++ b/extensions/python/startup_migration/AGENTS.md @@ -0,0 +1,28 @@ +# 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. + +## 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 new file mode 100644 index 000000000..8a188d6d0 --- /dev/null +++ b/extensions/python/startup_migration/_10_self_update_manager.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import os +import shutil +import stat +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)", +) + + +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"]) + 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 _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 new file mode 100644 index 000000000..c4aa1bfd8 --- /dev/null +++ b/extensions/python/system_prompt/AGENTS.md @@ -0,0 +1,28 @@ +# 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. + +## 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/_12_mcp_prompt.py b/extensions/python/system_prompt/_12_mcp_prompt.py index 30ca34454..44c760a7b 100644 --- a/extensions/python/system_prompt/_12_mcp_prompt.py +++ b/extensions/python/system_prompt/_12_mcp_prompt.py @@ -22,7 +22,7 @@ class MCPToolsPrompt(Extension): @extensible async def build_prompt(agent: Agent) -> str: - mcp_config = MCPConfig.get_instance() + mcp_config = MCPConfig.get_for_agent(agent) if not mcp_config.servers: return "" diff --git a/extensions/python/system_prompt/_14_project_prompt.py b/extensions/python/system_prompt/_14_project_prompt.py index 41948f5ed..4afa620cd 100644 --- a/extensions/python/system_prompt/_14_project_prompt.py +++ b/extensions/python/system_prompt/_14_project_prompt.py @@ -15,17 +15,31 @@ class ProjectPrompt(Extension): ): if not self.agent: return - prompt = await build_prompt(self.agent) + prompt = await build_prompt(self.agent, loop_data=loop_data) if prompt: system_prompt.append(prompt) @extensible -async def build_prompt(agent: Agent) -> str: +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 ) diff --git a/extensions/python/tool_execute_after/AGENTS.md b/extensions/python/tool_execute_after/AGENTS.md new file mode 100644 index 000000000..54c4557c5 --- /dev/null +++ b/extensions/python/tool_execute_after/AGENTS.md @@ -0,0 +1,26 @@ +# 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_before/AGENTS.md b/extensions/python/tool_execute_before/AGENTS.md new file mode 100644 index 000000000..e9f11e78a --- /dev/null +++ b/extensions/python/tool_execute_before/AGENTS.md @@ -0,0 +1,27 @@ +# 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/_20_block_parallel_recursion.py b/extensions/python/tool_execute_before/_20_block_parallel_recursion.py new file mode 100644 index 000000000..a5547ed8f --- /dev/null +++ b/extensions/python/tool_execute_before/_20_block_parallel_recursion.py @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..55428a4d7 --- /dev/null +++ b/extensions/python/user_message_ui/AGENTS.md @@ -0,0 +1,26 @@ +# 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. +- 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/util_model_call_before/AGENTS.md b/extensions/python/util_model_call_before/AGENTS.md new file mode 100644 index 000000000..dc0bd77f8 --- /dev/null +++ b/extensions/python/util_model_call_before/AGENTS.md @@ -0,0 +1,26 @@ +# 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/webui_ws_connect/AGENTS.md b/extensions/python/webui_ws_connect/AGENTS.md new file mode 100644 index 000000000..f722ae7b2 --- /dev/null +++ b/extensions/python/webui_ws_connect/AGENTS.md @@ -0,0 +1,26 @@ +# 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_disconnect/AGENTS.md b/extensions/python/webui_ws_disconnect/AGENTS.md new file mode 100644 index 000000000..703652891 --- /dev/null +++ b/extensions/python/webui_ws_disconnect/AGENTS.md @@ -0,0 +1,26 @@ +# 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_event/AGENTS.md b/extensions/python/webui_ws_event/AGENTS.md new file mode 100644 index 000000000..a47e147b2 --- /dev/null +++ b/extensions/python/webui_ws_event/AGENTS.md @@ -0,0 +1,26 @@ +# 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/webui/AGENTS.md b/extensions/webui/AGENTS.md index 11f949ca5..f2c530b90 100644 --- a/extensions/webui/AGENTS.md +++ b/extensions/webui/AGENTS.md @@ -31,4 +31,18 @@ ## Child DOX Index -No child DOX files. +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 new file mode 100644 index 000000000..963be5f11 --- /dev/null +++ b/extensions/webui/fetch_api_call_after/AGENTS.md @@ -0,0 +1,26 @@ +# 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 new file mode 100644 index 000000000..c715757d5 --- /dev/null +++ b/extensions/webui/fetch_api_call_before/AGENTS.md @@ -0,0 +1,26 @@ +# 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 new file mode 100644 index 000000000..ea0a36977 --- /dev/null +++ b/extensions/webui/get_message_handler/AGENTS.md @@ -0,0 +1,27 @@ +# 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 new file mode 100644 index 000000000..7390427de --- /dev/null +++ b/extensions/webui/initFw_end/AGENTS.md @@ -0,0 +1,27 @@ +# 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 new file mode 100644 index 000000000..51462c2bf --- /dev/null +++ b/extensions/webui/initFw_end/restoreRestorableModals.js @@ -0,0 +1,5 @@ +import { restoreRestorableModalStack } from "/js/modals.js"; + +export default function restoreRestorableModals() { + restoreRestorableModalStack(); +} diff --git a/extensions/webui/json_api_call_after/AGENTS.md b/extensions/webui/json_api_call_after/AGENTS.md new file mode 100644 index 000000000..96f356b8d --- /dev/null +++ b/extensions/webui/json_api_call_after/AGENTS.md @@ -0,0 +1,26 @@ +# 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_before/AGENTS.md b/extensions/webui/json_api_call_before/AGENTS.md new file mode 100644 index 000000000..b14f04ea9 --- /dev/null +++ b/extensions/webui/json_api_call_before/AGENTS.md @@ -0,0 +1,26 @@ +# 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 new file mode 100644 index 000000000..014adda27 --- /dev/null +++ b/extensions/webui/right-canvas-panels/AGENTS.md @@ -0,0 +1,28 @@ +# 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 new file mode 100644 index 000000000..fc0e7a1e6 --- /dev/null +++ b/extensions/webui/right-canvas-panels/files-panel.html @@ -0,0 +1,11 @@ +
+ +
diff --git a/extensions/webui/right_canvas_register_surfaces/AGENTS.md b/extensions/webui/right_canvas_register_surfaces/AGENTS.md new file mode 100644 index 000000000..1387cda74 --- /dev/null +++ b/extensions/webui/right_canvas_register_surfaces/AGENTS.md @@ -0,0 +1,27 @@ +# 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 new file mode 100644 index 000000000..f1cd6bd40 --- /dev/null +++ b/extensions/webui/right_canvas_register_surfaces/register-files.js @@ -0,0 +1,44 @@ +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/set_messages_after_loop/AGENTS.md b/extensions/webui/set_messages_after_loop/AGENTS.md new file mode 100644 index 000000000..724987ba7 --- /dev/null +++ b/extensions/webui/set_messages_after_loop/AGENTS.md @@ -0,0 +1,26 @@ +# 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. + +## 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 new file mode 100644 index 000000000..d417ba566 --- /dev/null +++ b/extensions/webui/set_messages_before_loop/AGENTS.md @@ -0,0 +1,26 @@ +# 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 new file mode 100644 index 000000000..58d838730 --- /dev/null +++ b/extensions/webui/webui_ws_push/AGENTS.md @@ -0,0 +1,27 @@ +# 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/helpers/AGENTS.md b/helpers/AGENTS.md index bb70bf289..39d24d71e 100644 --- a/helpers/AGENTS.md +++ b/helpers/AGENTS.md @@ -15,20 +15,26 @@ - 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. +- 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 diff --git a/helpers/api.py.dox.md b/helpers/api.py.dox.md new file mode 100644 index 000000000..d9d6bb546 --- /dev/null +++ b/helpers/api.py.dox.md @@ -0,0 +1,71 @@ +# 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`. +- 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.dox.md b/helpers/attachment_manager.py.dox.md new file mode 100644 index 000000000..46dd4a930 --- /dev/null +++ b/helpers/attachment_manager.py.dox.md @@ -0,0 +1,47 @@ +# 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 1aa651893..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: @@ -240,8 +241,12 @@ class BackupService: return translated_patterns - async def test_patterns(self, metadata: Dict[str, Any], max_files: int = 1000) -> List[Dict[str, Any]]: - """Test backup patterns and return list of matched files""" + 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) @@ -258,6 +263,7 @@ class BackupService: # 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 @@ -285,7 +291,7 @@ class BackupService: dirs[:] = dirs_to_keep for file in files_list: - if processed_count >= max_files: + if has_limit and processed_count >= max_files: break file_path = os.path.join(root, file) @@ -317,10 +323,10 @@ class BackupService: # Skip files we can't access continue - if processed_count >= max_files: + if has_limit and processed_count >= max_files: break - if processed_count >= max_files: + if has_limit and processed_count >= max_files: break except Exception as e: @@ -344,8 +350,10 @@ class BackupService: "include_hidden": include_hidden } - # Get matched files - matched_files = await self.test_patterns(metadata, max_files=50000) + # 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") @@ -824,7 +832,7 @@ class BackupService: # Find existing files that match the translated user-edited patterns try: - existing_files = await self.test_patterns(metadata, max_files=10000) + existing_files = await self.test_patterns(metadata, max_files=None) # Convert to delete operations format files_to_delete = [] diff --git a/helpers/backup.py.dox.md b/helpers/backup.py.dox.md new file mode 100644 index 000000000..5612447ec --- /dev/null +++ b/helpers/backup.py.dox.md @@ -0,0 +1,52 @@ +# 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/**`. + +## 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.dox.md b/helpers/browser.py.dox.md new file mode 100644 index 000000000..0ac4afcca --- /dev/null +++ b/helpers/browser.py.dox.md @@ -0,0 +1,46 @@ +# 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.dox.md b/helpers/cache.py.dox.md new file mode 100644 index 000000000..abe865f75 --- /dev/null +++ b/helpers/cache.py.dox.md @@ -0,0 +1,64 @@ +# 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.dox.md b/helpers/call_llm.py.dox.md new file mode 100644 index 000000000..0ba5bd966 --- /dev/null +++ b/helpers/call_llm.py.dox.md @@ -0,0 +1,43 @@ +# 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.dox.md b/helpers/chat_media.py.dox.md new file mode 100644 index 000000000..5aa9af9fb --- /dev/null +++ b/helpers/chat_media.py.dox.md @@ -0,0 +1,60 @@ +# 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.dox.md b/helpers/cli_tunnel.py.dox.md new file mode 100644 index 000000000..da8fc4a0c --- /dev/null +++ b/helpers/cli_tunnel.py.dox.md @@ -0,0 +1,54 @@ +# 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.dox.md b/helpers/cloudflare_tunnel.py.dox.md new file mode 100644 index 000000000..e2172dcae --- /dev/null +++ b/helpers/cloudflare_tunnel.py.dox.md @@ -0,0 +1,43 @@ +# 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.dox.md b/helpers/context.py.dox.md new file mode 100644 index 000000000..8b6e76a51 --- /dev/null +++ b/helpers/context.py.dox.md @@ -0,0 +1,54 @@ +# 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.dox.md b/helpers/context_utils.py.dox.md new file mode 100644 index 000000000..725340ecd --- /dev/null +++ b/helpers/context_utils.py.dox.md @@ -0,0 +1,41 @@ +# 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 + +- Important called helpers/classes observed in the source: `AgentContext.use`, `AgentContext.first`, `AgentContext`, `Exception`, `initialize_agent`. +- 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/crypto.py.dox.md b/helpers/crypto.py.dox.md new file mode 100644 index 000000000..a66e730ca --- /dev/null +++ b/helpers/crypto.py.dox.md @@ -0,0 +1,48 @@ +# 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.dox.md b/helpers/defer.py.dox.md new file mode 100644 index 000000000..cccc3da9f --- /dev/null +++ b/helpers/defer.py.dox.md @@ -0,0 +1,55 @@ +# 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)` + - `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. +- 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 8b731bee4..8e7b21012 100644 --- a/helpers/dirty_json.py +++ b/helpers/dirty_json.py @@ -214,7 +214,7 @@ class DirtyJson: def _parse_key(self): self._skip_whitespace() if self.current_char in ['"', "'"]: - return self._parse_string() + return self._parse_string(is_key=True) else: return self._parse_unquoted_key() @@ -260,11 +260,18 @@ class DirtyJson: self._pop_stack() return - def _parse_string(self): + 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 and self.current_char != quote_char: + 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"]: @@ -298,6 +305,67 @@ class DirtyJson: 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 diff --git a/helpers/dirty_json.py.dox.md b/helpers/dirty_json.py.dox.md new file mode 100644 index 000000000..030a5c652 --- /dev/null +++ b/helpers/dirty_json.py.dox.md @@ -0,0 +1,51 @@ +# 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.dox.md b/helpers/docker.py.dox.md new file mode 100644 index 000000000..e932e7bcc --- /dev/null +++ b/helpers/docker.py.dox.md @@ -0,0 +1,53 @@ +# 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.dox.md b/helpers/document_query.py.dox.md new file mode 100644 index 000000000..7d205d988 --- /dev/null +++ b/helpers/document_query.py.dox.md @@ -0,0 +1,41 @@ +# 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.dox.md b/helpers/dotenv.py.dox.md new file mode 100644 index 000000000..2e142d657 --- /dev/null +++ b/helpers/dotenv.py.dox.md @@ -0,0 +1,48 @@ +# 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)` +- 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`. + +## 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.dox.md b/helpers/duckduckgo_search.py.dox.md new file mode 100644 index 000000000..5090f5042 --- /dev/null +++ b/helpers/duckduckgo_search.py.dox.md @@ -0,0 +1,40 @@ +# 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.dox.md b/helpers/email_client.py.dox.md new file mode 100644 index 000000000..976e32cd9 --- /dev/null +++ b/helpers/email_client.py.dox.md @@ -0,0 +1,39 @@ +# 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.dox.md b/helpers/ephemeral_images.py.dox.md new file mode 100644 index 000000000..62b7a36a0 --- /dev/null +++ b/helpers/ephemeral_images.py.dox.md @@ -0,0 +1,58 @@ +# 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.dox.md b/helpers/errors.py.dox.md new file mode 100644 index 000000000..c551fc8d5 --- /dev/null +++ b/helpers/errors.py.dox.md @@ -0,0 +1,54 @@ +# 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.dox.md b/helpers/extension.py.dox.md new file mode 100644 index 000000000..4271cc57c --- /dev/null +++ b/helpers/extension.py.dox.md @@ -0,0 +1,63 @@ +# 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_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`, `_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`. +- 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 39e837855..0c49df968 100644 --- a/helpers/extract_tools.py +++ b/helpers/extract_tools.py @@ -8,16 +8,16 @@ def json_parse_dirty(json: str) -> dict[str, Any] | None: if not json or not isinstance(json, str): return None - ext_json = extract_json_object_string(json.strip()) - if ext_json: - try: - data = DirtyJson.parse_string(ext_json) - if isinstance(data, dict): - return data - except Exception: - # If parsing fails, return None instead of crashing - return None - 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 normalize_tool_request(tool_request: Any) -> tuple[str, dict]: @@ -46,26 +46,82 @@ def normalize_tool_request(tool_request: Any) -> tuple[str, dict]: 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 None + return [] - start = content.find("{") - if start == -1: - return None - first_array = content.find("[") - if first_array != -1 and first_array < start: - return None + if content.lstrip().startswith("["): + return [] - parser = DirtyJson() + 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: - parser.parse(content[start:]) + data = DirtyJson.parse_string(root) except Exception: return None + return data if isinstance(data, dict) else None - if not parser.completed: - return None - return content[start : start + parser.index] +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): diff --git a/helpers/extract_tools.py.dox.md b/helpers/extract_tools.py.dox.md new file mode 100644 index 000000000..d932d79c2 --- /dev/null +++ b/helpers/extract_tools.py.dox.md @@ -0,0 +1,52 @@ +# 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` +- `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, so a leading text preamble or incidental non-tool object does not force a misformat warning when a valid tool call follows. +- Streaming tool snapshots use the same valid-tool preference through `extract_json_root_string`, while preserving the first complete object fallback when no valid tool-call object is present. +- 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.dox.md b/helpers/faiss_monkey_patch.py.dox.md new file mode 100644 index 000000000..bfbacf50a --- /dev/null +++ b/helpers/faiss_monkey_patch.py.dox.md @@ -0,0 +1,38 @@ +# 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.dox.md b/helpers/fasta2a_client.py.dox.md new file mode 100644 index 000000000..e5ef5b6a8 --- /dev/null +++ b/helpers/fasta2a_client.py.dox.md @@ -0,0 +1,50 @@ +# 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 89afade1c..dfac9da24 100644 --- a/helpers/fasta2a_server.py +++ b/helpers/fasta2a_server.py @@ -2,11 +2,13 @@ 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 @@ -61,6 +63,27 @@ except ImportError: # pragma: no cover – library not installed _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.""" @@ -242,7 +265,7 @@ class DynamicA2AProxy: } # Create new FastA2A app with proper thread safety - new_app = FastA2A( # type: ignore + new_app = AgentZeroFastA2A( # type: ignore storage=storage, broker=broker, name="Agent Zero", diff --git a/helpers/fasta2a_server.py.dox.md b/helpers/fasta2a_server.py.dox.md new file mode 100644 index 000000000..1f2dcba78 --- /dev/null +++ b/helpers/fasta2a_server.py.dox.md @@ -0,0 +1,57 @@ +# 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.dox.md b/helpers/file_browser.py.dox.md new file mode 100644 index 000000000..3f8f2e43a --- /dev/null +++ b/helpers/file_browser.py.dox.md @@ -0,0 +1,51 @@ +# 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` + - `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`. +- 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` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/file_tree.py.dox.md b/helpers/file_tree.py.dox.md new file mode 100644 index 000000000..db3625d70 --- /dev/null +++ b/helpers/file_tree.py.dox.md @@ -0,0 +1,63 @@ +# 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.dox.md b/helpers/files.py.dox.md new file mode 100644 index 000000000..b4f153240 --- /dev/null +++ b/helpers/files.py.dox.md @@ -0,0 +1,82 @@ +# 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)` +- `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`, `_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.dox.md b/helpers/functions.py.dox.md new file mode 100644 index 000000000..c2e00daac --- /dev/null +++ b/helpers/functions.py.dox.md @@ -0,0 +1,45 @@ +# 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.dox.md b/helpers/git.py.dox.md new file mode 100644 index 000000000..857c3966c --- /dev/null +++ b/helpers/git.py.dox.md @@ -0,0 +1,70 @@ +# 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). +- `update_repo(repo_path: str) -> Repo` +- `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. +- 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.dox.md b/helpers/guids.py.dox.md new file mode 100644 index 000000000..9fea993bf --- /dev/null +++ b/helpers/guids.py.dox.md @@ -0,0 +1,40 @@ +# 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 4fdefd179..1ff802d87 100644 --- a/helpers/history.py +++ b/helpers/history.py @@ -40,9 +40,12 @@ MessageContent = Union[ ] -class OutputMessage(TypedDict): +class OutputMessage(TypedDict, total=False): ai: bool content: MessageContent + metadata: dict[str, Any] + id: str + sequence: int class Record: @@ -82,10 +85,20 @@ class Record: class Message(Record): - def __init__(self, ai: bool, content: MessageContent, tokens: int = 0, id: str = ""): + 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() @@ -106,7 +119,15 @@ class Message(Record): return False def output(self): - return [OutputMessage(ai=self.ai, content=self.summary or self.content)] + 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()) @@ -120,6 +141,8 @@ class Message(Record): "id": self.id, "ai": self.ai, "content": self.content, + "metadata": self.metadata, + "sequence": self.sequence, "summary": self.summary, "tokens": self.tokens, } @@ -127,7 +150,13 @@ class Message(Record): @staticmethod def from_dict(data: dict, history: "History"): content = data.get("content", "Content lost") - msg = Message(ai=data["ai"], content=content, id=data.get("id", "")) + msg = Message( + ai=data["ai"], + content=content, + id=data.get("id", ""), + metadata=data.get("metadata", {}) if isinstance(data.get("metadata"), dict) else {}, + sequence=int(data.get("sequence", 0) or 0), + ) msg.summary = data.get("summary", "") msg.tokens = data.get("tokens", 0) return msg @@ -146,9 +175,22 @@ class Topic(Record): return sum(msg.get_tokens() for msg in self.messages) def add_message( - self, ai: bool, content: MessageContent, tokens: int = 0, id: str = "" + 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) + msg = Message( + ai=ai, + content=content, + tokens=tokens, + id=id, + metadata=metadata, + sequence=sequence, + ) self.messages.append(msg) return msg @@ -335,10 +377,22 @@ class History(Record): return self.current.get_tokens() def add_message( - self, ai: bool, content: MessageContent, tokens: int = 0, id: str = "" + 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) + 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: @@ -353,6 +407,35 @@ class History(Record): 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 @@ -640,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 @@ -679,6 +784,19 @@ 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) diff --git a/helpers/history.py.dox.md b/helpers/history.py.dox.md new file mode 100644 index 000000000..53a7149e0 --- /dev/null +++ b/helpers/history.py.dox.md @@ -0,0 +1,111 @@ +# 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. +- 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.dox.md b/helpers/images.py.dox.md new file mode 100644 index 000000000..1d2cd0320 --- /dev/null +++ b/helpers/images.py.dox.md @@ -0,0 +1,48 @@ +# 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 d78f3ff6e..b4c4cc361 100644 --- a/helpers/integration_commands.py +++ b/helpers/integration_commands.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from dataclasses import dataclass from typing import TYPE_CHECKING from helpers import message_queue as mq @@ -14,7 +15,93 @@ if TYPE_CHECKING: _CLEAR_VALUES = {"", "default", "none", "clear", "off"} -_SUPPORTED_COMMANDS = {"/send", "/queue", "/project", "/config", "/preset"} + + +@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|default]", + ), + 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: @@ -26,36 +113,132 @@ def extract_command_line(text: str) -> str: return "" -def parse_command(text: str) -> tuple[str, str] | None: +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 = command.strip().lower() - if command not in _SUPPORTED_COMMANDS: + command = _normalize_command_token(command) + resolved = resolve_command(command, integration=integration) + if not resolved: return None - return command, args.strip() + return f"/{resolved.name}", args.strip() -def try_handle_command(context: "AgentContext", text: str) -> str | None: - parsed = parse_command(text) +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 in {"/config", "/preset"}: - return _handle_config(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) @@ -68,8 +251,13 @@ def _handle_queue(context: "AgentContext", args: str) -> str: "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 the queue." + return "Unknown queue action. Use /queue send to flush or /queue clear to clear." if count == 0: return "Queue is empty." @@ -80,6 +268,91 @@ def _handle_queue(context: "AgentContext", args: str) -> str: return f"Sent {sent_count} queued {noun} as one batch." +def _handle_status(context: "AgentContext") -> str: + project_name = context.get_data("project") or "none" + override = context.get_data("chat_model_override") + 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: {_describe_override(override)}\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 "" @@ -115,7 +388,7 @@ def _handle_project(context: "AgentContext", args: str) -> str: return f"Switched project to {match.get('title') or match['name']}." -def _handle_config(context: "AgentContext", args: str) -> str: +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") @@ -123,12 +396,12 @@ def _handle_config(context: "AgentContext", args: str) -> str: if not args: current_label = _describe_override(current_override) available = ", ".join(preset["name"] for preset in presets) or "none" - suffix = "Use /config to switch, or /config default to clear it." + suffix = "Use /model to switch, or /model default to clear it." if not allowed: suffix = "Per-chat config switching is disabled in Model Configuration." return ( - f"Current config: {current_label}\n" - f"Available configs: {available}\n" + f"Current model: {current_label}\n" + f"Available presets: {available}\n" f"{suffix}" ) @@ -159,7 +432,45 @@ def _handle_config(context: "AgentContext", args: str) -> str: 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 config to {preset_name}." + 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: @@ -170,6 +481,19 @@ def _format_project_entry(item: dict) -> str: 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" @@ -201,6 +525,20 @@ def _normalize_lookup(value: str) -> str: 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, diff --git a/helpers/integration_commands.py.dox.md b/helpers/integration_commands.py.dox.md new file mode 100644 index 000000000..1d00fb083 --- /dev/null +++ b/helpers/integration_commands.py.dox.md @@ -0,0 +1,55 @@ +# integration_commands.py DOX + +## Purpose + +- Own the `integration_commands.py` helper module. +- This module parses external integration slash commands such as project/config/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. +- Top-level functions: +- `extract_command_line(text: str) -> str` +- `parse_command(text: str) -> tuple[str, str] | None` +- `try_handle_command(context: 'AgentContext', text: str) -> str | None` +- `_handle_queue(context: 'AgentContext', args: str) -> str` +- `_handle_project(context: 'AgentContext', args: str) -> str` +- `_handle_config(context: 'AgentContext', args: str) -> str` +- `_format_project_entry(item: dict) -> str` +- `_describe_project(items: list[dict], current_name: str) -> str` +- `_describe_override(override: dict | None) -> str` +- `_strip_quotes(value: str) -> str` +- `_normalize_lookup(value: str) -> str` +- `_match_named_item(items: list[dict], desired: str, keys: tuple[str, ...]) -> tuple[dict | None, list[dict]]` +- Notable constants/configuration names: `_CLEAR_VALUES`, `_SUPPORTED_COMMANDS`. + +## 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. + +## Key Concepts + +- Important called helpers/classes observed in the source: `splitlines`, `extract_command_line`, `line.partition`, `command.strip.lower`, `parse_command`, `mq.get_queue`, `args.strip.lower`, `mq.send_all_aggregated`, `mark_dirty_for_context`, `_strip_quotes`, `_match_named_item`, `projects.activate_project`, `initialize_agent`, `model_config.is_chat_override_allowed`, `context.get_data`, `context.set_data`, `save_tmp_chat`, `str.strip`, `value.strip`, `value.lower.strip`, `re.sub`. +- 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.dox.md b/helpers/job_loop.py.dox.md new file mode 100644 index 000000000..b0414749d --- /dev/null +++ b/helpers/job_loop.py.dox.md @@ -0,0 +1,46 @@ +# 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.dox.md b/helpers/kvp.py.dox.md new file mode 100644 index 000000000..d7e6e0de0 --- /dev/null +++ b/helpers/kvp.py.dox.md @@ -0,0 +1,52 @@ +# 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 new file mode 100644 index 000000000..a3dd40bf8 --- /dev/null +++ b/helpers/litellm_transport.py @@ -0,0 +1,2007 @@ +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" + + +CHAT_COMPLETIONS_ALIASES = { + "chat", + "chat_completion", + "chat_completions", + "completion", + "completions", +} +RESPONSES_ALIASES = {"", "auto", "default", "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", "responses") or "").lower().strip() + if value in CHAT_COMPLETIONS_ALIASES: + return TransportMode.CHAT_COMPLETIONS + if value in RESPONSES_ALIASES: + return TransportMode.RESPONSES + return TransportMode.RESPONSES + + @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: + parsed = ChatCompletionsTransport.parse( + completion(**self._chat_request(stream=False)) + ) + self.last_result = self._llm_result_from_chat(parsed) + 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: + parsed = ChatCompletionsTransport.parse( + await acompletion(**self._chat_request(stream=False)) + ) + self.last_result = self._llm_result_from_chat(parsed) + 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) -> LLMResult: + return LLMResult.from_chat( + response=parsed["response_delta"], + reasoning=parsed["reasoning_delta"], + 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: + return 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(), + ) + + def _stream_result_from_parser( + self, parser: "ResponsesEventParser", request: dict[str, Any] + ) -> LLMResult | None: + if parser.completed_response is None: + return None + return self._llm_result_from_response(parser.completed_response, request) + + def _stream_result_from_chat_parser( + self, parser: "ChatCompletionsStreamParser" + ) -> LLMResult | None: + output_items = parser.output_items() + if not output_items: + return None + return LLMResult.from_chat( + response=parser.function_calls_text(), + 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]) + return json.dumps( + {"tool_name": "parallel_tool_calls", "tool_args": {"calls": calls}} + ) + + @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 + + def parse(self, chunk: Any) -> ChatChunk: + 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 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) + + @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.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": + 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) -> None: + key = self._event_key(event) + if not key: + return + current = self.function_calls.setdefault(key, {"type": "function_call"}) + current["arguments"] = str(current.get("arguments") or "") + str( + _get_value(event, "delta") or "" + ) + + 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") + 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 "" + return self._emit_function_call(key, self.function_calls[key]) + + 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 _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", + ) + ) + + +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 new file mode 100644 index 000000000..add4be9ab --- /dev/null +++ b/helpers/litellm_transport.py.dox.md @@ -0,0 +1,50 @@ +# 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. +- 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. +- Prefer Responses API when configured, but fallback to Chat Completions when the provider does not support Responses. +- 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. +- 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-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 new file mode 100644 index 000000000..874edec9b --- /dev/null +++ b/helpers/llm_result.py @@ -0,0 +1,315 @@ +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 = "", + 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", + 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]) + return json.dumps( + {"tool_name": "parallel_tool_calls", "tool_args": {"calls": calls}} + ) + + 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: self.to_dict()} + + +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 new file mode 100644 index 000000000..78b472885 --- /dev/null +++ b/helpers/llm_result.py.dox.md @@ -0,0 +1,39 @@ +# 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 data under `RESPONSE_METADATA_KEY` so history can round-trip provider state. +- `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 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.dox.md b/helpers/localization.py.dox.md new file mode 100644 index 000000000..b33e410b8 --- /dev/null +++ b/helpers/localization.py.dox.md @@ -0,0 +1,50 @@ +# 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.dox.md b/helpers/log.py.dox.md new file mode 100644 index 000000000..ce74dec69 --- /dev/null +++ b/helpers/log.py.dox.md @@ -0,0 +1,68 @@ +# 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. + +## 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.dox.md b/helpers/login.py.dox.md new file mode 100644 index 000000000..344895198 --- /dev/null +++ b/helpers/login.py.dox.md @@ -0,0 +1,49 @@ +# 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 ea23d61f4..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 @@ -43,10 +44,14 @@ 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 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: @@ -92,6 +97,60 @@ def _is_streaming_http_type(server_type: str) -> bool: 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: @@ -146,23 +205,45 @@ class MCPTool(Tool): encoded: str, mime_type: str, label: str, - ) -> tuple[str, dict[str, Any] | None]: + index: int, + preferred_name: str = "", + ) -> tuple[str, dict[str, Any] | None, str]: try: - image = media_artifacts.image_data_url_from_base64( + safe_mime = media_artifacts.normalize_mime( + mime_type, + default="image/png", + required_prefix="image/", + ) + artifact = media_artifacts.save_base64_artifact( encoded, - mime_type=mime_type, + 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 + 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"MCP returned a {label} attachment that could not be decoded.", + None, + "", + ) return ( - f"MCP returned {label} attachment ({image.mime}, {image.size} bytes).", + ( + f"Saved MCP {label} attachment " + f"({artifact.mime}, {artifact.size} bytes) to {artifact.path}." + ), { "type": "image_url", - "image_url": {"url": image.url}, + "image_url": {"url": artifact.path}, }, + artifact.path, ) def _materialize_binary_content( @@ -246,6 +327,7 @@ class MCPTool(Tool): 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): @@ -258,14 +340,17 @@ class MCPTool(Tool): continue if item_type == "image": - note, raw_content = self._format_image_content( + 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": @@ -292,10 +377,12 @@ class MCPTool(Tool): _mcp_get(resource, "mimeType", "") or "application/octet-stream" ).strip().lower() if mime_type.startswith("image/"): - note, raw_content = self._format_image_content( + 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( @@ -306,9 +393,12 @@ class MCPTool(Tool): 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: @@ -336,6 +426,8 @@ class MCPTool(Tool): "raw_content": raw_images, "preview": f"", "_tokens": MCP_MEDIA_TOKENS_ESTIMATE * len(raw_images), + "attachments": image_paths, + "media_paths": image_paths, } return message, additional @@ -344,7 +436,7 @@ class MCPTool(Tool): error = "" additional: dict[str, Any] | None = None try: - response: CallToolResult = await MCPConfig.get_instance().call_tool( + response: CallToolResult = await MCPConfig.get_for_agent(self.agent).call_tool( self.name, kwargs ) message, additional = self._format_tool_result(response) @@ -439,6 +531,8 @@ class MCPServerRemote(BaseModel): 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) @@ -457,12 +551,26 @@ class MCPServerRemote(BaseModel): return self.__client.get_log() # type: ignore def get_tools(self) -> List[dict[str, Any]]: - """Get all tools from the server""" + """Get enabled tools from the server""" with self.__lock: - return self.__client.tools # type: ignore + 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 @@ -470,9 +578,12 @@ class MCPServerRemote(BaseModel): self, tool_name: str, input_data: Dict[str, Any] ) -> CallToolResult: """Call a tool with the given input data""" - with self.__lock: - # We already run in an event loop, dont believe Pylance - return await self.__client.call_tool(tool_name, input_data) # type: ignore + 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: @@ -487,12 +598,16 @@ class MCPServerRemote(BaseModel): "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 @@ -517,6 +632,8 @@ class MCPServerLocal(BaseModel): 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) @@ -535,12 +652,26 @@ class MCPServerLocal(BaseModel): return self.__client.get_log() # type: ignore def get_tools(self) -> List[dict[str, Any]]: - """Get all tools from the server""" + """Get enabled tools from the server""" with self.__lock: - return self.__client.tools # type: ignore + 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 @@ -548,12 +679,24 @@ class MCPServerLocal(BaseModel): self, tool_name: str, input_data: Dict[str, Any] ) -> CallToolResult: """Call a tool with the given input data""" - with self.__lock: - # We already run in an event loop, dont believe Pylance - return await self.__client.call_tool(tool_name, input_data) # type: ignore + 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", @@ -567,10 +710,18 @@ class MCPServerLocal(BaseModel): "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": @@ -590,16 +741,136 @@ MCPServer = Annotated[ 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=[]) - return cls.__instance + 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): @@ -608,97 +879,20 @@ class MCPConfig(BaseModel): @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: - servers_data: List[Dict[str, Any]] = [] # Default to empty list - - if ( - config_str and config_str.strip() - ): # Only parse if non-empty and not just whitespace - try: - # Try with standard json.loads first, as it should handle escaped strings correctly - parsed_value = dirty_json.try_parse(config_str) - normalized = cls.normalize_config(parsed_value) - - if isinstance(normalized, list): - valid_servers = [] - for item in normalized: - if isinstance(item, dict): - valid_servers.append(item) - else: - PrintStyle( - background_color="yellow", - font_color="black", - padding=True, - ).print( - f"Warning: MCP config item (from json.loads) was not a dictionary and was ignored: {item}" - ) - servers_data = valid_servers - else: - PrintStyle( - background_color="red", font_color="white", padding=True - ).print( - f"Error: Parsed MCP config (from json.loads) top-level structure is not a list. Config string was: '{config_str}'" - ) - # servers_data remains empty - except ( - Exception - ) as e_json: # Catch json.JSONDecodeError specifically if possible, or general Exception - PrintStyle.error( - f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'" - ) - - # # Fallback to DirtyJson or log error if standard json.loads fails - # PrintStyle(background_color="orange", font_color="black", padding=True).print( - # f"Standard json.loads failed for MCP config: {e_json}. Attempting DirtyJson as fallback." - # ) - # try: - # parsed_value = DirtyJson.parse_string(config_str) - # if isinstance(parsed_value, list): - # valid_servers = [] - # for item in parsed_value: - # if isinstance(item, dict): - # valid_servers.append(item) - # else: - # PrintStyle(background_color="yellow", font_color="black", padding=True).print( - # f"Warning: MCP config item (from DirtyJson) was not a dictionary and was ignored: {item}" - # ) - # servers_data = valid_servers - # else: - # PrintStyle(background_color="red", font_color="white", padding=True).print( - # f"Error: Parsed MCP config (from DirtyJson) top-level structure is not a list. Config string was: '{config_str}'" - # ) - # # servers_data remains empty - # except Exception as e_dirty: - # PrintStyle(background_color="red", font_color="white", padding=True).print( - # f"Error parsing MCP config string with DirtyJson as well: {e_dirty}. Config string was: '{config_str}'" - # ) - # # servers_data remains empty, allowing graceful degradation - - # Initialize/update the singleton instance with the (potentially empty) list of server data - instance = cls.get_instance() - # Directly update the servers attribute of the existing instance or re-initialize carefully - # For simplicity and to ensure __init__ logic runs if needed for setup: - new_instance_data = { - "servers": servers_data - } # Prepare data for re-initialization or update - - # Option 1: Re-initialize the existing instance (if __init__ is idempotent for other fields) - instance.__init__(servers_list=servers_data) - - # Option 2: Or, if __init__ has side effects we don't want to repeat, - # and 'servers' is the primary thing 'update' changes: - # instance.servers = [] # Clear existing servers first - # for server_item_data in servers_data: - # try: - # if server_item_data.get("url", None): - # instance.servers.append(MCPServerRemote(server_item_data)) - # else: - # instance.servers.append(MCPServerLocal(server_item_data)) - # except Exception as e_init: - # PrintStyle(background_color="grey", font_color="red", padding=True).print( - # f"MCPConfig.update: Failed to create MCPServer from item '{server_item_data.get('name', 'Unknown')}': {e_init}" - # ) - + # 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 @@ -708,23 +902,24 @@ class MCPConfig(BaseModel): if isinstance(servers, list): for server in servers: if isinstance(server, dict): - normalized.append(server) + 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): - value["name"] = key - normalized.append(value) + 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(server) + normalized.append(dict(server)) else: - normalized.append(servers) # single server? + normalized.append(dict(servers)) # single server? return normalized - def __init__(self, servers_list: List[Dict[str, Any]]): + 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 @@ -741,6 +936,7 @@ class MCPConfig(BaseModel): # 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 = [] @@ -775,6 +971,11 @@ class MCPConfig(BaseModel): ) 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") @@ -867,10 +1068,10 @@ class MCPConfig(BaseModel): name = server.name # get tool count tool_count = len(server.get_tools()) - # check if server is connected - connected = True # tool_count > 0 # 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() != "" @@ -878,6 +1079,9 @@ class MCPConfig(BaseModel): 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, @@ -890,6 +1094,9 @@ class MCPConfig(BaseModel): 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, @@ -904,12 +1111,15 @@ class MCPConfig(BaseModel): for server in self.servers: if server.name == server_name: try: - tools = server.get_tools() + 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 {} @@ -986,9 +1196,10 @@ class MCPConfig(BaseModel): def has_tool(self, tool_name: str) -> bool: """Check if a tool is available""" - if "." not in tool_name: + try: + server_name_part, tool_name_part = _split_qualified_tool_name(tool_name) + except ValueError: return False - server_name_part, tool_name_part = tool_name.split(".") with self.__lock: for server in self.servers: if server.name == server_name_part: @@ -996,6 +1207,9 @@ class MCPConfig(BaseModel): 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): return None return MCPTool(agent=agent, name=tool_name, method=None, args={}, message="", loop_data=None) @@ -1004,14 +1218,16 @@ class MCPConfig(BaseModel): self, tool_name: str, input_data: Dict[str, Any] ) -> CallToolResult: """Call a tool with the given input data""" - if "." not in tool_name: - raise ValueError(f"Tool {tool_name} not found") - server_name_part, tool_name_part = tool_name.split(".") + 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): - return await server.call_tool(tool_name_part, input_data) + 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") @@ -1031,6 +1247,49 @@ class MCPClientBase(ABC): 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( @@ -1053,54 +1312,56 @@ class MCPClientBase(ABC): """ operation_name = coro_func.__name__ # For logging # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Creating new session for operation '{operation_name}'...") - # Store the original exception outside the async block original_exception = None + result: T | None = None + has_result = False + temp_stack = AsyncExitStack() try: - async with AsyncExitStack() as temp_stack: - 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() - 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) - - return result - except Exception as e: - # Store the original exception and raise a dummy exception - excs = getattr(e, "exceptions", None) # Python 3.11+ ExceptionGroup - if excs: - original_exception = excs[0] - else: - original_exception = e - # Create a dummy exception to break out of the async block - raise RuntimeError("Dummy exception to break out of async block") + result = await coro_func(session) + has_result = True except Exception as e: - # Check if this is our dummy exception - if original_exception is not None: - e = original_exception - # We have the original exception stored + 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(e).__name__}: {e}" + f"MCPClientBase ({self.server.name} - {operation_name}): Error during operation: {type(original_exception).__name__}: {original_exception}" ) - raise e # Re-raise the original exception - # finally: - # PrintStyle(font_color="cyan").print( - # f"MCPClientBase ({self.server.name} - {operation_name}): Session and transport will be closed by AsyncExitStack." - # ) - # This line should ideally be unreachable if the try/except/finally logic within the 'async with' is exhaustive. - # Adding it to satisfy linters that might not fully trace the raise/return paths through async context managers. + 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." ) @@ -1119,16 +1380,25 @@ class MCPClientBase(ABC): } 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: - set = settings.get_settings() - await self._execute_with_session( - list_tools_op, - read_timeout_seconds=self.server.init_timeout - or set["mcp_client_init_timeout"], + 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] @@ -1155,7 +1425,7 @@ class MCPClientBase(ABC): def get_tools(self) -> List[dict[str, Any]]: """Get all tools from the server (uses cached tools)""" with self.__lock: - return self.tools + return [dict(tool) for tool in self.tools] async def call_tool( self, tool_name: str, input_data: Dict[str, Any] @@ -1177,19 +1447,35 @@ class MCPClientBase(ABC): 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): - set = settings.get_settings() # 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=set["mcp_client_tool_timeout"]), + 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: - return await self._execute_with_session(call_tool_op) + 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( @@ -1304,11 +1590,19 @@ class MCPClientRemote(MCPClientBase): ]: """Connect to an MCP server, init client and save stdio/write streams""" server: MCPServerRemote = cast(MCPServerRemote, self.server) - set = settings.get_settings() + current_settings = settings.get_settings() # Resolve timeout: check server config first, then settings, defaulting to 5s/10s - init_timeout = server.init_timeout or set["mcp_client_init_timeout"] or 5 - tool_timeout = server.tool_timeout or set["mcp_client_tool_timeout"] or 10 + 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 diff --git a/helpers/mcp_handler.py.dox.md b/helpers/mcp_handler.py.dox.md new file mode 100644 index 000000000..1cab10612 --- /dev/null +++ b/helpers/mcp_handler.py.dox.md @@ -0,0 +1,115 @@ +# 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. +- 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` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/mcp_server.py.dox.md b/helpers/mcp_server.py.dox.md new file mode 100644 index 000000000..4151d3d89 --- /dev/null +++ b/helpers/mcp_server.py.dox.md @@ -0,0 +1,54 @@ +# 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.dox.md b/helpers/media_artifacts.py.dox.md new file mode 100644 index 000000000..9f4d0c39f --- /dev/null +++ b/helpers/media_artifacts.py.dox.md @@ -0,0 +1,60 @@ +# media_artifacts.py DOX + +## Purpose + +- Own the `media_artifacts.py` helper module. +- This module validates, decodes, names, and normalizes media artifact payloads. +- Keep this file-level DOX profile synchronized with `media_artifacts.py` because this directory is intentionally flat. + +## Ownership + +- `media_artifacts.py` owns the runtime implementation. +- `media_artifacts.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `MediaArtifactError` (`ValueError`) +- `EmptyBase64Data` (`MediaArtifactError`) +- `InvalidBase64Data` (`MediaArtifactError`) +- `ArtifactTooLarge` (`MediaArtifactError`) +- `Base64Payload` (no explicit base class) +- `ImageDataUrl` (no explicit base class) +- `SavedArtifact` (no explicit base class) +- Top-level functions: +- `compact_base64(data: str) -> str` +- `estimated_base64_decoded_size(data: str) -> int` +- `decode_base64_payload(data: str, max_bytes: int | None=...) -> Base64Payload` +- `normalize_mime(mime_type: str, default: str=..., required_prefix: str=...) -> str` +- `guess_extension(mime_type: str, fallback: str=...) -> str` +- `filename_from_uri(uri: str) -> str` +- `safe_filename(value: str, default: str=..., default_extension: str=...) -> str` +- `image_data_url_from_base64(data: str, mime_type: str=..., max_bytes: int | None=...) -> ImageDataUrl` +- `save_base64_artifact(data: str, mime_type: str=..., directory_parts: tuple[str, ...], preferred_name: str=..., default_filename: str=..., max_bytes: int | None=...) -> SavedArtifact` +- Notable constants/configuration names: `DEFAULT_MAX_ARTIFACT_SIZE_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, network calls. +- Imported dependency areas include: `__future__`, `base64`, `binascii`, `dataclasses`, `helpers`, `mimetypes`, `pathlib`, `urllib.parse`, `uuid`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `dataclass`, `join`, `compact_base64`, `Base64Payload`, `str.strip.lower`, `str.strip`, `urlparse`, `decode_base64_payload`, `normalize_mime`, `ImageDataUrl`, `guess_extension`, `safe_filename`, `Path`, `artifact_dir.mkdir`, `path.write_bytes`, `SavedArtifact`, `super.__init__`, `EmptyBase64Data`, `estimated_base64_decoded_size`, `base64.b64decode`. +- 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_mcp_handler_multimodal.py` + - `tests/test_media_artifacts.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/message_queue.py.dox.md b/helpers/message_queue.py.dox.md new file mode 100644 index 000000000..df300ed67 --- /dev/null +++ b/helpers/message_queue.py.dox.md @@ -0,0 +1,53 @@ +# message_queue.py DOX + +## Purpose + +- Own the `message_queue.py` helper module. +- This module stores and drains queued user messages for a context. +- Keep this file-level DOX profile synchronized with `message_queue.py` because this directory is intentionally flat. + +## Ownership + +- `message_queue.py` owns the runtime implementation. +- `message_queue.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `get_queue(context: 'AgentContext') -> list`: Get current queue from context.data. +- `_get_next_seq(context: 'AgentContext') -> int`: Get next sequence number. +- `_sync_output(context: 'AgentContext')`: Sync queue to output_data for frontend polling. +- `add(context: 'AgentContext', text: str, attachments: list[str] | None=..., item_id: str | None=...) -> dict`: Add message to queue. Attachments should be filenames, will be converted to full paths. +- `remove(context: 'AgentContext', item_id: str | None=...) -> int`: Remove item(s). If item_id is None, clears all. Returns remaining count. +- `pop_first(context: 'AgentContext') -> dict | None`: Remove and return first item. +- `pop_item(context: 'AgentContext', item_id: str) -> dict | None`: Remove and return specific item. +- `has_queue(context: 'AgentContext') -> bool`: Check if queue has items. +- `log_user_message(context: 'AgentContext', message: str, attachment_paths: list[str], message_id: str | None=..., source: str=...)`: Log user message to console and UI. Used by message API and queue processing. +- `send_message(context: 'AgentContext', item: dict, source: str=...)`: Send a single queued message (log + communicate). +- `send_next(context: 'AgentContext') -> bool`: Send next queued message. Returns True if sent, False if queue empty. +- `send_all_aggregated(context: 'AgentContext') -> int`: Aggregate and send all queued messages as one. Returns count of items sent. +- Notable constants/configuration names: `QUEUE_KEY`, `QUEUE_SEQ_KEY`, `UPLOAD_FOLDER`. + +## 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, settings/state persistence. +- Imported dependency areas include: `helpers`, `helpers.print_style`, `os`, `typing`, `uuid`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `context.set_data`, `get_queue`, `context.set_output_data`, `_sync_output`, `queue.pop`, `context.log.log`, `log_user_message`, `context.communicate`, `pop_first`, `has_queue`, `join`, `context.get_data`, `att.startswith`, `_get_next_seq`, `uuid.uuid4`, `UserMessage`, `send_message`, `guids.generate_id`, `os.path.basename`, `PrintStyle`. +- 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/messages.py.dox.md b/helpers/messages.py.dox.md new file mode 100644 index 000000000..e61772114 --- /dev/null +++ b/helpers/messages.py.dox.md @@ -0,0 +1,50 @@ +# messages.py DOX + +## Purpose + +- Own the `messages.py` helper module. +- This module truncates message payloads for display and storage safety. +- Keep this file-level DOX profile synchronized with `messages.py` because this directory is intentionally flat. + +## Ownership + +- `messages.py` owns the runtime implementation. +- `messages.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `truncate_text(agent, output, threshold=...)` +- `truncate_dict_by_ratio(agent, data: dict | list | str, threshold_chars: int, truncate_to: int)` + +## 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: `json`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `agent.read_prompt`, `process_item`, `json.dumps`, `truncate_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/email_parser_test.py` + - `tests/test_browser_agent_regressions.py` + - `tests/test_chat_compaction.py` + - `tests/test_document_query_fallback.py` + - `tests/test_download_toast_regressions.py` + - `tests/test_fasta2a_client.py` + - `tests/test_mcp_handler_multimodal.py` + - `tests/test_oauth_codex.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/microsoft_tunnel.py.dox.md b/helpers/microsoft_tunnel.py.dox.md new file mode 100644 index 000000000..32ee7027c --- /dev/null +++ b/helpers/microsoft_tunnel.py.dox.md @@ -0,0 +1,50 @@ +# microsoft_tunnel.py DOX + +## Purpose + +- Own the `microsoft_tunnel.py` helper module. +- This module implements Microsoft Dev Tunnel provider behavior. +- Keep this file-level DOX profile synchronized with `microsoft_tunnel.py` because this directory is intentionally flat. + +## Ownership + +- `microsoft_tunnel.py` owns the runtime implementation. +- `microsoft_tunnel.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `AgentZeroMicrosoftTunnel` (`MicrosoftTunnel`) + - `notify(self, event, message, data=...)` + - `agent_zero_notifications(self)` +- `MicrosoftDevTunnel` (`FlaredanticTunnelHelper`) + - `build_tunnel(self)` + - `start(self)` +- Top-level functions: +- `default_microsoft_tunnel_id()` +- Notable constants/configuration names: `MICROSOFT_TUNNEL_ID_ENV_KEYS`, `MICROSOFT_TUNNEL_TIMEOUT`. + +## 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, tunnel state. +- Imported dependency areas include: `flaredantic`, `getpass`, `hashlib`, `helpers`, `helpers.tunnel_common`, `os`, `socket`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `join`, `strip`, `hashlib.sha256.hexdigest`, `self.notify`, `callable`, `self._notify_progress`, `self._run_cmd`, `MicrosoftConfig`, `AgentZeroMicrosoftTunnel`, `getpass.getuser`, `socket.gethostname`, `files.get_abs_path`, `super.notify`, `parent`, `super.start`, `hashlib.sha256`, `MicrosoftTunnelError`, `default_microsoft_tunnel_id`, `RuntimeError`, `seed.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_tunnel_remote_link.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/migration.py.dox.md b/helpers/migration.py.dox.md new file mode 100644 index 000000000..c548951ab --- /dev/null +++ b/helpers/migration.py.dox.md @@ -0,0 +1,52 @@ +# migration.py DOX + +## Purpose + +- Own the `migration.py` helper module. +- This module migrates legacy user data and runtime layout at startup. +- Keep this file-level DOX profile synchronized with `migration.py` because this directory is intentionally flat. + +## Ownership + +- `migration.py` owns the runtime implementation. +- `migration.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `startup_migration() -> None` +- `migrate_user_data() -> None`: Migrate user data from /tmp and other locations to /usr. +- `convert_agents_json_yaml() -> None` +- `_move_dir(src: str, dst: str, overwrite: bool=...) -> None`: Move a directory from src to dst if src exists and dst does not. +- `_move_file(src: str, dst: str, overwrite: bool=...) -> None`: Move a file from src to dst if src exists and dst does not. +- `_migrate_memory(base_path: str=...) -> None`: Migrate memory subdirectories. +- `_merge_dir_contents(src_parent: str, dst_parent: str) -> None`: Moves all items from src_parent to dst_parent. +- `_cleanup_obsolete() -> None`: Remove directories that are no longer needed. + +## 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, scheduler state. +- Imported dependency areas include: `helpers`, `helpers.print_style`, `json`, `os`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `migrate_user_data`, `convert_agents_json_yaml`, `extension.call_extensions_sync`, `_move_dir`, `_move_file`, `_migrate_memory`, `_merge_dir_contents`, `_cleanup_obsolete`, `subagents.get_agents_roots`, `files.get_subdirectories`, `files.list_files`, `files.deabsolute_path`, `files.exists`, `files.move_dir`, `files.move_file`, `files.get_abs_path`, `os.path.isdir`, `os.path.join`, `files.delete_dir`, `os.path.isfile`. +- 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_office_canvas_setup.py` + - `tests/test_office_document_store.py` + - `tests/test_speech_plugin_split.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/modules.py.dox.md b/helpers/modules.py.dox.md new file mode 100644 index 000000000..9f42143ed --- /dev/null +++ b/helpers/modules.py.dox.md @@ -0,0 +1,53 @@ +# modules.py DOX + +## Purpose + +- Own the `modules.py` helper module. +- This module imports modules and classes dynamically with namespace cache control. +- Keep this file-level DOX profile synchronized with `modules.py` because this directory is intentionally flat. + +## Ownership + +- `modules.py` owns the runtime implementation. +- `modules.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `import_module(file_path: str) -> ModuleType` +- `load_classes_from_folder(folder: str, name_pattern: str, base_class: Type[T], one_per_file: bool=...) -> list[Type[T]]` +- `load_classes_from_file(file: str, base_class: type[T], one_per_file: bool=...) -> list[type[T]]` +- `purge_namespace(namespace: str)` +- 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 reads, filesystem deletion. +- Imported dependency areas include: `fnmatch`, `helpers.files`, `importlib`, `importlib.util`, `inspect`, `os`, `re`, `sys`, `types`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `TypeVar`, `get_abs_path`, `os.path.basename.replace`, `importlib.util.spec_from_file_location`, `importlib.util.module_from_spec`, `spec.loader.exec_module`, `import_module`, `inspect.getmembers`, `to_delete.sort`, `importlib.invalidate_caches`, `ImportError`, `os.path.join`, `os.path.basename`, `issubclass`, `os.listdir`, `name.startswith`, `n.count`, `fnmatch`, `file_name.endswith`. +- 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_docker_release_plan.py` + - `tests/test_error_retry_plugin.py` + - `tests/test_file_tree_visualize.py` + - `tests/test_git_version_label.py` + - `tests/test_mcp_handler_multimodal.py` + - `tests/test_memory_quality.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/network.py.dox.md b/helpers/network.py.dox.md new file mode 100644 index 000000000..792836311 --- /dev/null +++ b/helpers/network.py.dox.md @@ -0,0 +1,55 @@ +# network.py DOX + +## Purpose + +- Own the `network.py` helper module. +- This module validates public URLs and fetches remote resources safely. +- Keep this file-level DOX profile synchronized with `network.py` because this directory is intentionally flat. + +## Ownership + +- `network.py` owns the runtime implementation. +- `network.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `HttpFetchResult` (no explicit base class) +- `UnsafeUrlError` (`ValueError`) +- Top-level functions: +- `_build_request_headers() -> dict[str, str]` +- `_normalize_content_type(content_type: str | None) -> str | None` +- `resolve_host_ips(hostname: str) -> tuple[ipaddress._BaseAddress, ...]` +- `validate_public_http_url(url: str) -> tuple[ipaddress._BaseAddress, ...]` +- `fetch_public_http_resource(url: str, max_bytes: int, max_redirects: int=..., timeout: tuple[float, float]=...) -> HttpFetchResult` +- `is_loopback_address(address: str) -> bool`: Check whether *address* resolves to a loopback interface. +- Notable constants/configuration names: `SAFE_HTTP_SCHEMES`, `DEFAULT_FETCH_TIMEOUT`, `DEFAULT_HTTP_USER_AGENT`. + +## 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: network calls, secret handling. +- Imported dependency areas include: `__future__`, `dataclasses`, `ipaddress`, `os`, `requests`, `socket`, `struct`, `urllib.parse`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `frozenset`, `dataclass`, `strip`, `urlparse`, `parsed.hostname.rstrip.lower`, `resolve_host_ips`, `requests.Session`, `ValueError`, `content_type.split.strip.lower`, `socket.getaddrinfo`, `ipaddress.ip_address`, `seen.add`, `UnsafeUrlError`, `hostname.endswith`, `validate_public_http_url`, `socket.inet_pton`, `_checkers`, `parsed.hostname.rstrip`, `os.getenv`, `content_type.split.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_oauth_gemini_api.py` + - `tests/test_oauth_github_copilot.py` + - `tests/test_oauth_xai_grok.py` + - `tests/test_plugin_scan_prompt.py` + - `tests/test_tunnel_remote_link.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/notification.py.dox.md b/helpers/notification.py.dox.md new file mode 100644 index 000000000..d8f198f4a --- /dev/null +++ b/helpers/notification.py.dox.md @@ -0,0 +1,62 @@ +# notification.py DOX + +## Purpose + +- Own the `notification.py` helper module. +- This module owns notification data models and notification manager state. +- Keep this file-level DOX profile synchronized with `notification.py` because this directory is intentionally flat. + +## Ownership + +- `notification.py` owns the runtime implementation. +- `notification.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `NotificationType` (`Enum`) +- `NotificationPriority` (`Enum`) +- `NotificationItem` (no explicit base class) + - `mark_read(self)` + - `output(self)` +- `NotificationManager` (no explicit base class) + - `send_notification(type: NotificationType, priority: NotificationPriority, message: str, title: str=..., detail: str=..., display_time: int=..., group: str=..., id: str=...) -> NotificationItem` + - `add_notification(self, type: NotificationType, priority: NotificationPriority, message: str, title: str=..., detail: str=..., display_time: int=..., group: str=..., id: str=...) -> NotificationItem` + - `get_recent_notifications(self, seconds: int=...) -> list[NotificationItem]` + - `output(self, start: int | None=..., end: int | None=...) -> list[dict]` + - `output_all(self) -> list[dict]` + - `mark_read_by_ids(self, notification_ids: list[str]) -> int` + - `update_item(self, no: int, **kwargs) -> None` + - `mark_all_read(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: filesystem deletion, settings/state persistence. +- Imported dependency areas include: `dataclasses`, `datetime`, `enum`, `helpers.localization`, `threading`, `uuid`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.manager.update_item`, `threading.RLock`, `AgentContext.get_notification_manager.add_notification`, `mark_dirty_all`, `self._update_item`, `NotificationType`, `Localization.get.serialize_datetime`, `uuid.uuid4`, `Localization.get.now`, `timedelta`, `n.output`, `AgentContext.get_notification_manager`, `next`, `NotificationPriority`, `NotificationItem`, `self._enforce_limit`, `seen.add`, `notifications.output`, `nid.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_download_toast_regressions.py` + - `tests/test_multi_tab_isolation.py` + - `tests/test_self_update_tag_filter.py` + - `tests/test_snapshot_parity.py` + - `tests/test_snapshot_schema_v1.py` + - `tests/test_state_monitor.py` + - `tests/test_state_sync_handler.py` + - `tests/test_state_sync_welcome_screen.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/parallel_tools.py b/helpers/parallel_tools.py new file mode 100644 index 000000000..0b9c9f72a --- /dev/null +++ b/helpers/parallel_tools.py @@ -0,0 +1,754 @@ +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from dataclasses import dataclass, field, replace +from typing import Any, Literal, TYPE_CHECKING + +from helpers import extract_tools +from helpers.defer import DeferredTask, THREAD_BACKGROUND +from helpers.extension import call_extensions_async +from helpers.print_style import PrintStyle + +if TYPE_CHECKING: + from agent import Agent, AgentConfig, AgentContext + from helpers.log import LogItem + + +PARALLEL_JOBS_KEY = "_parallel_jobs" +PARALLEL_WORKER_PARENT_CONTEXT_KEY = "_parallel_parent_context_id" +PARALLEL_WORKER_JOB_KEY = "_parallel_job_id" +PARALLEL_WORKER_KIND_KEY = "_parallel_worker_kind" + +CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id" +CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind" +CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label" +CHILD_PARALLEL_JOB_ID_KEY = "parallel_job_id" +CHILD_PARALLEL_TOOL_NAME_KEY = "parallel_tool_name" + +DEFAULT_MAX_CALLS = 8 +DEFAULT_TIMEOUT_SECONDS = 300 +POLL_INTERVAL_SECONDS = 0.5 +DISALLOWED_PARALLEL_TOOLS = {"document_query"} + +TERMINAL_STATES = {"success", "error", "cancelled", "timeout"} +JobState = Literal["pending", "running", "success", "error", "cancelled", "timeout"] +JobKind = Literal["tool", "subordinate"] + + +@dataclass +class NormalizedToolCall: + index: int + tool_name: str + tool_args: dict[str, Any] + + +@dataclass +class ParallelJob: + id: str + parent_context_id: str + index: int + tool_name: str + tool_args: dict[str, Any] + kind: JobKind + state: JobState = "pending" + created_at: float = field(default_factory=time.time) + started_at: float | None = None + completed_at: float | None = None + result: str | None = None + error: str | None = None + worker_context_id: str | None = None + log_id: str = field(default_factory=lambda: str(uuid.uuid4())) + log_item: "LogItem | None" = field(default=None, repr=False) + deferred_task: DeferredTask | None = field(default=None, repr=False) + + def elapsed(self) -> float: + end = self.completed_at or time.time() + start = self.started_at or self.created_at + return max(0.0, end - start) + + +def extract_tool_calls(args: dict[str, Any]) -> Any: + for key in ("tool_calls", "calls", "items"): + if key in args: + return args.get(key) + return None + + +def normalize_parallel_tool_calls(raw_calls: Any) -> list[NormalizedToolCall]: + if isinstance(raw_calls, str): + try: + raw_calls = json.loads(raw_calls) + except json.JSONDecodeError as exc: + raise ValueError( + "`tool_calls` must be an array of normal tool-call objects." + ) from exc + if not isinstance(raw_calls, list): + raise ValueError("`tool_calls` must be an array of normal tool-call objects.") + if not raw_calls: + raise ValueError("`tool_calls` must contain at least one tool call.") + if len(raw_calls) > DEFAULT_MAX_CALLS: + raise ValueError(f"`tool_calls` supports at most {DEFAULT_MAX_CALLS} items.") + + calls: list[NormalizedToolCall] = [] + for index, raw_call in enumerate(raw_calls): + try: + tool_name, tool_args = extract_tools.normalize_tool_request(raw_call) + except ValueError as exc: + raise ValueError(f"tool_calls[{index}] is not a valid tool call: {exc}") from exc + + if tool_name == "parallel": + raise ValueError("`parallel` cannot be nested inside another `parallel` call.") + if tool_name in DISALLOWED_PARALLEL_TOOLS: + raise ValueError( + f"`{tool_name}` cannot be used inside `parallel`; call it sequentially." + ) + + calls.append( + NormalizedToolCall( + index=index, + tool_name=tool_name, + tool_args=dict(tool_args), + ) + ) + return calls + + +def normalize_job_ids(raw_job_ids: Any) -> list[str]: + if raw_job_ids is None: + return [] + if isinstance(raw_job_ids, str): + return [raw_job_ids] + if isinstance(raw_job_ids, list): + return [str(item) for item in raw_job_ids if str(item).strip()] + raise ValueError("`job_ids` must be a string or an array of strings.") + + +def coerce_bool(value: Any, default: bool) -> 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"}: + return False + return bool(value) + + +def coerce_timeout(value: Any) -> int: + if value in (None, ""): + return DEFAULT_TIMEOUT_SECONDS + try: + timeout = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"`timeout` must be an integer number of seconds, got {value!r}.") from exc + if timeout <= 0: + raise ValueError("`timeout` must be greater than 0.") + return timeout + + +def _parallel_worker_kind(agent: "Agent | None") -> JobKind | None: + context = getattr(agent, "context", None) + if not context: + return None + kind = context.get_data(PARALLEL_WORKER_KIND_KEY) + if kind in {"tool", "subordinate"}: + return kind + if context.get_data(PARALLEL_WORKER_JOB_KEY): + return "tool" + return None + + +def is_parallel_worker(agent: "Agent | None") -> bool: + return _parallel_worker_kind(agent) == "tool" + + +def _jobs_for_context(context: "AgentContext") -> dict[str, ParallelJob]: + jobs = context.get_data(PARALLEL_JOBS_KEY) + if not isinstance(jobs, dict): + jobs = {} + context.set_data(PARALLEL_JOBS_KEY, jobs) + return jobs + + +def _get_job(parent_context_id: str, job_id: str) -> ParallelJob | None: + from agent import AgentContext + + context = AgentContext.get(parent_context_id) + if not context: + return None + job = _jobs_for_context(context).get(job_id) + return job if isinstance(job, ParallelJob) else None + + +def _new_job_id(tool_name: str) -> str: + prefix = "".join(ch for ch in tool_name if ch.isalnum())[:12] or "job" + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +async def start_parallel_jobs( + agent: "Agent", + calls: list[NormalizedToolCall], +) -> list[ParallelJob]: + jobs: list[ParallelJob] = [] + context = agent.context + job_store = _jobs_for_context(context) + + for call in calls: + kind: JobKind = "subordinate" if call.tool_name == "call_subordinate" else "tool" + job = ParallelJob( + id=_new_job_id(call.tool_name), + parent_context_id=context.id, + index=call.index, + tool_name=call.tool_name, + tool_args=call.tool_args, + kind=kind, + ) + job_store[job.id] = job + jobs.append(job) + _log_parallel_child_started(agent, job) + + try: + job.state = "running" + job.started_at = time.time() + task = DeferredTask(thread_name=THREAD_BACKGROUND) + job.deferred_task = task + task.start_task(_run_parallel_job, context.id, job.id) + except Exception as exc: + _finish_job(job, "error", error=str(exc)) + + return jobs + + +async def await_parallel_jobs( + agent: "Agent", + job_ids: list[str], + timeout: int = DEFAULT_TIMEOUT_SECONDS, + *, + collect: bool = True, + wait: bool = True, +) -> list[dict[str, Any]]: + if not job_ids: + raise ValueError("No `job_ids` were provided to await.") + + deadline = time.time() + timeout + known_job_ids = set(job_ids) + wait_timed_out_job_ids: set[str] = set() + while True: + await refresh_parallel_jobs(agent) + jobs = [_jobs_for_context(agent.context).get(job_id) for job_id in job_ids] + missing = [job_id for job_id, job in zip(job_ids, jobs) if job is None] + if missing: + raise ValueError(f"Unknown parallel job id(s): {', '.join(missing)}") + + active = [job for job in jobs if job and job.state not in TERMINAL_STATES] + if not wait or not active: + break + + if time.time() >= deadline: + wait_timed_out_job_ids = {job.id for job in active} + break + + await asyncio.sleep(POLL_INTERVAL_SECONDS) + + snapshots = [] + for job_id in job_ids: + job = _jobs_for_context(agent.context).get(job_id) + if job: + snapshot = _job_snapshot(job, include_result=True) + if job.id in wait_timed_out_job_ids and job.state not in TERMINAL_STATES: + snapshot["wait_timed_out"] = True + snapshots.append(snapshot) + + if collect: + for job_id in known_job_ids: + job = _jobs_for_context(agent.context).get(job_id) + if job and job.state in TERMINAL_STATES: + await cleanup_parallel_job(agent, job) + _jobs_for_context(agent.context).pop(job_id, None) + + return snapshots + + +async def cancel_parallel_jobs(agent: "Agent", job_ids: list[str]) -> list[dict[str, Any]]: + if not job_ids: + raise ValueError("No `job_ids` were provided to cancel.") + + await refresh_parallel_jobs(agent) + snapshots = [] + for job_id in job_ids: + job = _jobs_for_context(agent.context).get(job_id) + if not job: + raise ValueError(f"Unknown parallel job id: {job_id}") + await _cancel_job(job) + snapshots.append(_job_snapshot(job, include_result=True)) + await cleanup_parallel_job(agent, job) + _jobs_for_context(agent.context).pop(job_id, None) + return snapshots + + +async def refresh_parallel_jobs(agent: "Agent") -> list[ParallelJob]: + jobs = list(_jobs_for_context(agent.context).values()) + for job in jobs: + if job.state in TERMINAL_STATES: + continue + task = job.deferred_task + if not task: + continue + if task.is_ready(): + try: + await task.result() + except asyncio.CancelledError: + _finish_job(job, "cancelled", error="Parallel job was cancelled.") + except Exception as exc: + _finish_job(job, "error", error=str(exc)) + elif task.is_alive(): + job.state = "running" + if job.started_at is None: + job.started_at = time.time() + return jobs + + +async def cleanup_parallel_job(agent: "Agent", job: ParallelJob) -> None: + if job.deferred_task and job.deferred_task.is_alive(): + job.deferred_task.kill() + if job.kind == "tool": + await _remove_context(job.worker_context_id) + + +async def build_parallel_jobs_extras(agent: "Agent") -> str: + await refresh_parallel_jobs(agent) + jobs = [ + job + for job in _jobs_for_context(agent.context).values() + if isinstance(job, ParallelJob) and job.state not in {"cancelled", "timeout"} + ] + if not jobs: + return "" + + active = [job for job in jobs if job.state not in TERMINAL_STATES] + ready = [job for job in jobs if job.state in TERMINAL_STATES] + if not active and not ready: + return "" + + lines = ["parallel jobs:"] + if active: + lines.append("running:") + for job in active: + lines.append( + f"- {job.id}: {job.tool_name} [{job.state}], running for {job.elapsed():.1f}s" + ) + if ready: + lines.append("ready to collect with `parallel` and `job_ids`:") + for job in ready: + lines.append( + f"- {job.id}: {job.tool_name} [{job.state}], duration {job.elapsed():.1f}s" + ) + lines.append("call the `parallel` tool with `job_ids` to await/collect results or `action: \"cancel\"` to cancel.") + return "\n".join(lines) + + +def format_started_jobs(jobs: list[ParallelJob]) -> str: + payload = { + "status": "started", + "jobs": [_job_snapshot(job, include_result=False) for job in jobs], + "instruction": "Use the parallel tool with job_ids to await or cancel these background jobs.", + } + return json.dumps(payload, indent=2, ensure_ascii=False) + + +def format_parallel_results(results: list[dict[str, Any]]) -> str: + states = [result.get("state") for result in results] + has_active_jobs = any(state not in TERMINAL_STATES for state in states) + wait_timed_out = any(result.get("wait_timed_out") for result in results) + if has_active_jobs: + status = "waiting" if wait_timed_out else "running" + elif states and all(state == "success" for state in states): + status = "success" + elif states and all(state == "cancelled" for state in states): + status = "cancelled" + elif any(state == "success" for state in states): + status = "partial" + else: + status = "error" + + payload = { + "status": status, + "jobs": results, + } + if wait_timed_out: + payload["wait_timeout"] = True + if has_active_jobs: + payload["instruction"] = ( + "Some jobs are still running. Call `parallel` with `action: \"await\"` " + "and the listed `job_ids` to wait again, or `action: \"cancel\"` to stop them." + ) + return json.dumps(payload, indent=2, ensure_ascii=False) + + +async def _run_parallel_job(parent_context_id: str, job_id: str) -> None: + job = _get_job(parent_context_id, job_id) + if not job: + return + try: + if job.kind == "subordinate": + result = await _run_subordinate_context_job(parent_context_id, job) + else: + result = await _run_direct_tool_job(parent_context_id, job) + _finish_job(job, "success", result=result) + except asyncio.CancelledError: + _finish_job(job, "cancelled", error="Parallel job was cancelled.") + raise + except Exception as exc: + _finish_job(job, "error", error=str(exc)) + PrintStyle.error(f"Parallel job {job.id} failed: {exc}") + + +async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob) -> str: + from agent import AgentContext, AgentContextType, UserMessage + from helpers import message_queue, persist_chat + + parent_context = AgentContext.get(parent_context_id) + if not parent_context: + raise ValueError("Parent context not found.") + + args = job.tool_args + message = str(args.get("message") or "").strip() + if not message: + raise ValueError("call_subordinate requires `tool_args.message`.") + + profile = str(args.get("profile") or args.get("agent_profile") or "").strip() + attachments = args.get("attachments") if isinstance(args.get("attachments"), list) else [] + attachments = [str(item) for item in attachments] + + child_name = _subordinate_context_name(job) + worker_context = AgentContext( + config=_clone_config(parent_context.config, profile=profile), + name=child_name, + type=AgentContextType.USER, + ) + job.worker_context_id = worker_context.id + if job.deferred_task: + worker_context.task = job.deferred_task + + worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context.id) + worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id) + worker_context.set_data(PARALLEL_WORKER_KIND_KEY, job.kind) + worker_context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent_context.id) + worker_context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "parallel") + worker_context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, child_name) + worker_context.set_output_data(CHILD_PARALLEL_JOB_ID_KEY, job.id) + worker_context.set_output_data(CHILD_PARALLEL_TOOL_NAME_KEY, job.tool_name) + _copy_project(parent_context, worker_context) + + system_prompt = _subordinate_worker_system_prompt(profile) + message_queue.log_user_message(worker_context, message, attachments, source=" (parallel)") + worker_context.agent0.hist_add_user_message( + UserMessage( + message=message, + attachments=attachments, + system_message=[system_prompt], + ) + ) + persist_chat.save_tmp_chat(worker_context) + + try: + result = await worker_context.agent0.monologue() + worker_context.agent0.history.new_topic() + return result + finally: + persist_chat.save_tmp_chat(worker_context) + + +async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str: + from agent import AgentContext, AgentContextType, LoopData + + parent_context = AgentContext.get(parent_context_id) + if not parent_context: + raise ValueError("Parent context not found.") + + worker_context: AgentContext | None = None + try: + worker_context = AgentContext( + config=_clone_config(parent_context.config), + name=f"parallel:{job.tool_name}", + type=AgentContextType.BACKGROUND, + ) + worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context_id) + worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id) + worker_context.set_data(PARALLEL_WORKER_KIND_KEY, job.kind) + job.worker_context_id = worker_context.id + _copy_project(parent_context, worker_context) + + worker_agent = worker_context.agent0 + worker_agent.loop_data = LoopData() + 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], + *, + log_item: "LogItem | None" = None, +) -> str: + if tool_name == "parallel": + raise ValueError("`parallel` cannot be nested inside a parallel worker.") + + 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() + await tool.before_execution(**tool_args) + await agent.handle_intervention() + await call_extensions_async( + "tool_execute_before", + agent, + tool_args=tool_args or {}, + tool_name=tool_name, + ) + response = await tool.execute(**tool_args) + await agent.handle_intervention() + await call_extensions_async( + "tool_execute_after", + agent, + response=response, + tool_name=tool_name, + ) + await tool.after_execution(response) + 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, + *, + state: JobState = "cancelled", + message: str = "Parallel job was cancelled.", +) -> None: + if job.deferred_task and job.deferred_task.is_alive(): + job.deferred_task.kill() + _finish_job(job, state, error=message) + + +def _finish_job( + job: ParallelJob, + state: JobState, + *, + result: str | None = None, + error: str | None = None, +) -> None: + job.state = state + job.completed_at = time.time() + if job.started_at is None: + job.started_at = job.created_at + if result is not None: + job.result = result + if error is not None: + job.error = error + _update_parallel_child_log(job) + + +async def _remove_context(context_id: str | None) -> None: + if not context_id: + return + from agent import AgentContext + from helpers import persist_chat + + context = AgentContext.get(context_id) + if context: + try: + context.reset() + except Exception: + pass + AgentContext.remove(context_id) + persist_chat.remove_chat(context_id) + + +def _log_parallel_child_started(agent: "Agent", job: ParallelJob) -> None: + if job.kind == "subordinate": + job.log_item = agent.context.log.log( + type="subagent", + heading=f"icon://communication {agent.agent_name}: Calling Subordinate Agent", + content="", + kvps=job.tool_args, + id=job.log_id, + ) + 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", + heading=heading, + content="", + kvps=job.tool_args, + id=job.log_id, + _tool_name=job.tool_name, + ) + + +def _update_parallel_child_log(job: ParallelJob) -> None: + if not job.log_item: + return + if job.state == "success": + content = job.result if job.result else "(completed without textual output)" + else: + content = f"Error: {job.error or job.state}" + try: + job.log_item.update(content=content) + except Exception: + pass + + +def _job_snapshot(job: ParallelJob, *, include_result: bool) -> dict[str, Any]: + data: dict[str, Any] = { + "job_id": job.id, + "tool_name": job.tool_name, + "state": job.state, + "duration_seconds": round(job.elapsed(), 3), + } + if job.worker_context_id: + data["context_id"] = job.worker_context_id + if include_result: + if job.result is not None: + data["result"] = job.result + if job.error is not None: + data["error"] = job.error + return data + + +def _clone_config(config: "AgentConfig", *, profile: str = "") -> "AgentConfig": + try: + cloned = replace( + config, + knowledge_subdirs=list(config.knowledge_subdirs), + additional=dict(config.additional), + ) + if profile: + cloned.profile = profile + return cloned + except Exception: + return config + + +def _copy_project(parent_context: "AgentContext", worker_context: "AgentContext") -> None: + try: + from helpers import projects + + project_name = projects.get_context_project_name(parent_context) + if project_name: + projects.activate_project(worker_context.id, project_name, mark_dirty=False) + except Exception: + pass + + +def _subordinate_worker_system_prompt(profile: str) -> str: + lines = [ + "You are running as an isolated parallel worker for a parent Agent Zero chat.", + "Return a concise final textual summary for the parent. Artifacts and files are supplementary, not a substitute for the textual result.", + ] + if profile: + lines.append(f"Act with the `{profile}` profile's expertise and priorities.") + return "\n".join(lines) + + +def _subordinate_context_name(job: ParallelJob) -> str: + name = str(job.tool_args.get("name") or "").strip() + if name: + return name + message = str(job.tool_args.get("message") or "").strip() + label = _short_label(message) + return label or f"Parallel subordinate {job.index + 1}" + + +def _short_label(text: str, limit: int = 80) -> str: + compact = " ".join(text.split()) + return compact[:limit].rstrip() diff --git a/helpers/parallel_tools.py.dox.md b/helpers/parallel_tools.py.dox.md new file mode 100644 index 000000000..d2456f2f2 --- /dev/null +++ b/helpers/parallel_tools.py.dox.md @@ -0,0 +1,59 @@ +# parallel_tools.py DOX + +## Purpose + +- Own the shared runtime for parallel tool-call jobs. +- Normalize wrapped tool-call payloads, start background jobs, await or cancel jobs, and render prompt extras for active parallel work. +- Keep this file-level DOX profile synchronized with `parallel_tools.py` because this directory is intentionally flat. + +## Ownership + +- `parallel_tools.py` owns the runtime implementation. +- `parallel_tools.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Public concepts: +- `NormalizedToolCall` +- `ParallelJob` +- `start_parallel_jobs(...)` +- `await_parallel_jobs(...)` +- `cancel_parallel_jobs(...)` +- `build_parallel_jobs_extras(...)` +- `format_parallel_results(...)` + +## Runtime Contracts + +- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together. +- Wrapped tool-call items must use the same shape as normal tool calls: a tool name plus arguments. +- Normalization accepts full agent-reply-shaped objects when `tool_name` and `tool_args` are present; non-contract planning fields such as `thoughts` or `headline` are ignored. +- `tool_calls` should be an array, but normalization also accepts a valid JSON string encoding of that array to recover provider/model stringification. +- Normalization rejects `document_query` inside `parallel` because document parsing and Q&A fan out into heavier worker/model paths that must run sequentially. +- `call_subordinate` jobs run in isolated child chat contexts tagged with parent-chat metadata; they must not be added to the scheduler task list and may use normal child-chat tools, including `parallel`. +- Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`. +- 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. + +## Key Concepts + +- The parent context stores in-flight jobs under a private data key; collected terminal jobs are removed from that registry. +- `wait=True` starts jobs and awaits them before returning until all requested jobs finish or the wait timeout is reached; the timeout stops waiting but does not cancel running jobs. +- `collect` returns already-finished job results without waiting; `await` waits for requested job IDs. +- Canceled jobs should be marked terminal and should stop their background `DeferredTask` when cancellation is possible. + +## Work Guidance + +- Keep normalization compatible with provider tool-call envelopes and direct JSON objects. +- Avoid importing heavy runtime modules at import time unless startup behavior is verified. +- Coordinate argument, output, or status changes with `tools/parallel.py`, prompt instructions, and tests. + +## Verification + +- Run targeted tests for normalization, recursion guard, prompt extras, and tool result formatting. +- Run a live Agent Zero chat when changing parallel execution, child chat metadata, or subordinate task behavior. + +## Child DOX Index + +No child DOX files. diff --git a/helpers/performance.py.dox.md b/helpers/performance.py.dox.md new file mode 100644 index 000000000..36181986d --- /dev/null +++ b/helpers/performance.py.dox.md @@ -0,0 +1,42 @@ +# performance.py DOX + +## Purpose + +- Own the `performance.py` helper module. +- This module provides lightweight performance tracing helpers. +- Keep this file-level DOX profile synchronized with `performance.py` because this directory is intentionally flat. + +## Ownership + +- `performance.py` owns the runtime implementation. +- `performance.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `trace_performance(show_all=..., color=..., unicode=...)`: Decorator that profiles a function and prints a call tree when it finishes. + +## 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: `functools`, `inspect`, `pyinstrument`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `inspect.iscoroutinefunction`, `functools.wraps`, `Profiler`, `profiler.start`, `profiler.stop`, `func`, `profiler.output_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_extensions_stress.py` + - `tests/test_ws_manager.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/perplexity_search.py.dox.md b/helpers/perplexity_search.py.dox.md new file mode 100644 index 000000000..1144ec0e9 --- /dev/null +++ b/helpers/perplexity_search.py.dox.md @@ -0,0 +1,41 @@ +# perplexity_search.py DOX + +## Purpose + +- Own the `perplexity_search.py` helper module. +- This module queries Perplexity search using configured model/provider credentials. +- Keep this file-level DOX profile synchronized with `perplexity_search.py` because this directory is intentionally flat. + +## Ownership + +- `perplexity_search.py` owns the runtime implementation. +- `perplexity_search.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `perplexity_search(query: str, model_name=..., api_key=..., base_url=...)` + +## 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: `models`, `openai`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `OpenAI`, `client.chat.completions.create`, `models.get_api_key`. +- 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/persist_chat.py b/helpers/persist_chat.py index 086a68fa7..199fa3ef5 100644 --- a/helpers/persist_chat.py +++ b/helpers/persist_chat.py @@ -4,6 +4,7 @@ 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 @@ -13,6 +14,7 @@ from helpers.log import Log, LogItem CHATS_FOLDER = "usr/chats" LOG_SIZE = 1000 CHAT_FILE_NAME = "chat.json" +SAVED_CHAT_CONTEXT_DATA_KEY = "_persist_chat_saved" def _fallback_datetime_iso() -> str: @@ -53,6 +55,7 @@ def save_tmp_chat(context: AgentContext): data = _serialize_context(context) js = _safe_json_serialize(data, ensure_ascii=False) files.write_file(path, js) + mark_chat_saved(context) def save_tmp_chats(): @@ -70,7 +73,9 @@ def load_tmp_chats(): folders = files.list_files(CHATS_FOLDER, "*") json_files = [] for folder_name in folders: - json_files.append(_get_chat_file_path(folder_name)) + chat_file = _get_chat_file_path(folder_name) + if files.exists(chat_file): + json_files.append(chat_file) ctxids = [] for file in json_files: @@ -78,6 +83,7 @@ def load_tmp_chats(): js = files.read_file(file) data = json.loads(js) ctx = _deserialize_context(data) + mark_chat_saved(ctx) ctxids.append(ctx.id) except Exception as e: print(f"Error loading chat {file}: {e}") @@ -88,6 +94,19 @@ def _get_chat_file_path(ctxid: str): return files.get_abs_path(CHATS_FOLDER, ctxid, CHAT_FILE_NAME) +def mark_chat_saved(context: AgentContext) -> None: + context.data[SAVED_CHAT_CONTEXT_DATA_KEY] = True + + +def saved_chat_ids() -> set[str]: + return { + files.basename(files.dirname(path)) + for path in files.find_existing_paths_by_pattern( + files.get_abs_path(CHATS_FOLDER, "*", CHAT_FILE_NAME) + ) + } + + def _convert_v080_chats(): json_files = files.list_files(CHATS_FOLDER, "*.json") for file in json_files: @@ -118,6 +137,7 @@ def export_json_chat(context: AgentContext): def remove_chat(ctxid): """Remove a chat or task context""" + _delete_provider_responses_for_chat(ctxid) path = get_chat_folder_path(ctxid) files.delete_dir(path) @@ -130,8 +150,8 @@ def remove_msg_files(ctxid): def _serialize_context(context: AgentContext): profile = str( - getattr(context.config, "profile", None) - or getattr(context.agent0.config, "profile", None) + getattr(context.agent0.config, "profile", None) + or getattr(context.config, "profile", None) or "" ) @@ -178,6 +198,7 @@ def _serialize_agent(agent: Agent): return { "number": agent.number, + "agent_profile": str(getattr(agent.config, "profile", "") or ""), "data": data, "history": history, } @@ -230,11 +251,23 @@ def _deserialize_context(data): streaming_agent = streaming_agent.data.get(Agent.DATA_NAME_SUBORDINATE, None) context.agent0 = agent0 + context.config = agent0.config context.streaming_agent = streaming_agent return context +def _deserialize_agent_config( + agent_data: dict[str, Any], fallback_config: AgentConfig +) -> AgentConfig: + fallback_profile = str(getattr(fallback_config, "profile", "") or "") + profile = str(agent_data.get("agent_profile") or fallback_profile).strip() + if profile == fallback_profile: + return fallback_config + override_settings = {"agent_profile": profile} if profile else None + return initialize_agent(override_settings=override_settings) + + def _deserialize_agents( agents: list[dict[str, Any]], config: AgentConfig, context: AgentContext ) -> Agent: @@ -244,7 +277,7 @@ def _deserialize_agents( for ag in agents: current = Agent( number=ag["number"], - config=config, + config=_deserialize_agent_config(ag, config), context=context, ) current.data = ag.get("data", {}) @@ -324,3 +357,70 @@ def _safe_json_serialize(obj, **kwargs): return False return json.dumps(obj, default=serializer, **kwargs) + + +def _delete_provider_responses_for_chat(ctxid: str) -> None: + try: + data = json.loads(files.read_file(_get_chat_file_path(ctxid))) + except Exception: + return + if _responses_delete_disabled(data): + return + response_ids = _collect_response_ids(data) + if not response_ids: + return + delete_stored_response_ids(response_ids) + + +def _responses_delete_disabled(data: dict[str, Any]) -> bool: + if data.get("responses_delete_on_chat_delete") is False: + return True + context_data = data.get("data") + if isinstance(context_data, dict) and context_data.get("responses_delete_on_chat_delete") is False: + return True + for agent_data in data.get("agents", []) or []: + if not isinstance(agent_data, dict): + continue + state = agent_data.get("data") + if isinstance(state, dict) and state.get("responses_delete_on_chat_delete") is False: + return True + return False + + +def _collect_response_ids(data: Any) -> list[str]: + found: list[str] = [] + seen: set[str] = set() + + def add(value: Any) -> None: + response_id = str(value or "").strip() + if response_id and response_id not in seen: + seen.add(response_id) + found.append(response_id) + + def walk(obj: Any) -> None: + if isinstance(obj, dict): + state = obj.get(Agent.DATA_NAME_RESPONSES_STATE) + if isinstance(state, dict): + add(state.get("response_id")) + for response_id in state.get("response_ids") or []: + add(response_id) + + metadata = obj.get("metadata") + if isinstance(metadata, dict): + responses = metadata.get("responses") + if isinstance(responses, dict): + add(responses.get("response_id")) + + for value in obj.values(): + walk(value) + elif isinstance(obj, list): + for value in obj: + walk(value) + elif isinstance(obj, str) and '"response_id"' in obj: + try: + walk(json.loads(obj)) + except Exception: + return + + walk(data) + return found diff --git a/helpers/persist_chat.py.dox.md b/helpers/persist_chat.py.dox.md new file mode 100644 index 000000000..e75fe80d5 --- /dev/null +++ b/helpers/persist_chat.py.dox.md @@ -0,0 +1,73 @@ +# persist_chat.py DOX + +## Purpose + +- Own the `persist_chat.py` helper module. +- This module persists and reloads chat contexts and message artifacts. +- Keep this file-level DOX profile synchronized with `persist_chat.py` because this directory is intentionally flat. + +## Ownership + +- `persist_chat.py` owns the runtime implementation. +- `persist_chat.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `_fallback_datetime_iso() -> str` +- `_parse_persisted_datetime(value: str | None) -> datetime` +- `get_chat_folder_path(ctxid: str)`: Get the folder path for any context (chat or task). +- `get_chat_msg_files_folder(ctxid: str)` +- `save_tmp_chat(context: AgentContext)`: Save context to the chats folder +- `save_tmp_chats()`: Save all contexts to the chats folder +- `load_tmp_chats()`: Load all contexts from the chats folder +- `_get_chat_file_path(ctxid: str)` +- `_convert_v080_chats()` +- `load_json_chats(jsons: list[str])`: Load contexts from JSON strings +- `export_json_chat(context: AgentContext)`: Export context as JSON string +- `remove_chat(ctxid)`: Remove a chat or task context +- `remove_msg_files(ctxid)`: Remove all message files for a chat or task context +- `mark_chat_saved(context: AgentContext) -> None` +- `saved_chat_ids() -> set[str]` +- `_serialize_context(context: AgentContext)` +- `_serialize_agent(agent: Agent)` +- `_serialize_log(log: Log)` +- `_deserialize_context(data)` +- `_deserialize_agent_config(agent_data: dict[str, Any], fallback_config: AgentConfig) -> AgentConfig` +- `_deserialize_agents(agents: list[dict[str, Any]], config: AgentConfig, context: AgentContext) -> Agent` +- `_deserialize_log(data: dict[str, Any]) -> 'Log'` +- `_safe_json_serialize(obj, **kwargs)` +- Notable constants/configuration names: `CHATS_FOLDER`, `LOG_SIZE`, `CHAT_FILE_NAME`, `SAVED_CHAT_CONTEXT_DATA_KEY`. + +## 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, scheduler state. +- Imported dependency areas include: `agent`, `collections`, `datetime`, `helpers`, `helpers.localization`, `helpers.log`, `initialize`, `json`, `typing`, `uuid`. +- 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. +- 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 + +- Important called helpers/classes observed in the source: `datetime.fromtimestamp.isoformat`, `datetime.fromisoformat`, `files.get_abs_path`, `_get_chat_file_path`, `files.make_dirs`, `_serialize_context`, `_safe_json_serialize`, `files.write_file`, `_convert_v080_chats`, `files.list_files`, `get_chat_folder_path`, `files.delete_dir`, `get_chat_msg_files_folder`, `agent.history.serialize`, `initialize_agent`, `_deserialize_log`, `AgentContext`, `_deserialize_agent_config`, `_deserialize_agents`, `Log`, `log.set_initial_progress`. +- 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_persist_chat_log_ids.py` + - `tests/test_subagent_profiles.py` + - `tests/test_tool_action_contracts.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/plugins.py.dox.md b/helpers/plugins.py.dox.md new file mode 100644 index 000000000..4447d8850 --- /dev/null +++ b/helpers/plugins.py.dox.md @@ -0,0 +1,81 @@ +# plugins.py DOX + +## Purpose + +- Own the `plugins.py` helper module. +- This module discovers plugins, resolves plugin config/toggles, runs hooks, and tracks plugin updates. +- Keep this file-level DOX profile synchronized with `plugins.py` because this directory is intentionally flat. + +## Ownership + +- `plugins.py` owns the runtime implementation. +- `plugins.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `PluginAssetFile` (`TypedDict`) +- `PluginMetadata` (`BaseModel`) +- `PluginListItem` (`BaseModel`) +- `PluginUpdateInfo` (`BaseModel`) +- Top-level functions: +- `register_watchdogs()` +- `after_plugin_change(plugin_names: list[str] | None=..., python_change: bool=...)` +- `refresh_plugin_modules(plugin_names: list[str] | None=...)` +- `clear_plugin_cache(plugin_names: list[str] | None=...)` +- `get_plugin_roots(plugin_name: str=...) -> List[str]`: Plugin root directories, ordered by priority (user first). +- `get_plugins_list()` +- `get_enhanced_plugins_list(custom: bool=..., builtin: bool=..., plugin_names: list[str] | None=...) -> List[PluginListItem]`: Discover plugins by directory convention. First root wins on ID conflict. +- `get_custom_plugins_updates(plugin_names: list[str] | None=...) -> List[PluginUpdateInfo]` +- `get_plugin_meta(plugin_name: str)` +- `find_plugin_dir(plugin_name: str)` +- `uninstall_plugin(plugin_name)` +- `delete_plugin(plugin_name: str)` +- `get_plugin_paths(*subpaths) -> List[str]` +- `get_enabled_plugin_paths(agent: Agent | None, *subpaths) -> List[str]` +- `get_enabled_plugins(agent: Agent | None)` +- `determined_toggle_from_paths(default: bool, paths: Iterator[str])` +- `get_toggle_state(plugin_name: str) -> ToggleState` +- `toggle_plugin(plugin_name: str, enabled: bool, project_name: str=..., agent_profile: str=..., clear_overrides: bool=...)` +- `get_plugin_config(plugin_name: str, agent: Agent | None=..., project_name: str | None=..., agent_profile: str | None=...)` +- `get_default_plugin_config(plugin_name: str)` +- `save_plugin_config(plugin_name: str, project_name: str, agent_profile: str, settings: dict)` +- `find_plugin_asset(plugin_name: str, *subpaths, project_name=..., agent_profile=...)` +- `find_plugin_assets(*subpaths, plugin_name: str=..., project_name: str=..., agent_profile: str=..., only_first: bool=...) -> list[PluginAssetFile]` +- `determine_plugin_asset_path(plugin_name: str, project_name: str, agent_profile: str, *subpaths)` +- `send_frontend_reload_notification(plugin_names: list[str] | None=...)`: If the plugin changed has webui extensions, notify frontend to reload the page +- `call_plugin_hook(plugin_name: str, hook_name: str, default: Any=..., *args, **kwargs)` +- `_apply_defaults_from_env(plugin_name: str, config: dict[str, Any])` +- Notable constants/configuration names: `_META_TARGET_RE`, `META_FILE_NAME`, `CONFIG_FILE_NAME`, `CONFIG_DEFAULT_FILE_NAME`, `DISABLED_FILE_NAME`, `ENABLED_FILE_NAME`, `TOGGLE_FILE_PATTERN`, `HOOKS_SCRIPT`, `HOOKS_CACHE_AREA`, `PLUGINS_LIST_CACHE_AREA`, `ENABLED_PLUGINS_LIST_CACHE_AREA`, `ENABLED_PLUGINS_PATHS_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. +- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, plugin state, settings/state persistence, secret handling. +- Imported dependency areas include: `__future__`, `asyncio`, `glob`, `helpers`, `helpers.defer`, `helpers.watchdog`, `json`, `pathlib`, `pydantic`, `re`, `regex`, `time`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `re.compile`, `Field`, `watchdog.add_watchdog`, `clear_plugin_cache`, `send_frontend_reload_notification`, `DeferredTask.start_task`, `get_plugin_roots`, `result.sort`, `cache.add`, `get_enhanced_plugins_list`, `find_plugin_dir`, `files.get_abs_path`, `files.exists`, `call_plugin_hook`, `delete_plugin`, `files.delete_dir`, `after_plugin_change`, `get_enabled_plugins`, `get_plugins_list`, `get_plugin_meta`. +- 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_computer_use_metadata.py` + - `tests/test_a0_connector_prompt_gating.py` + - `tests/test_browser_agent_regressions.py` + - `tests/test_chat_compaction.py` + - `tests/test_default_prompt_budget.py` + - `tests/test_document_query_plugin.py` + - `tests/test_error_retry_plugin.py` + - `tests/test_host_browser_connector.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/print_catch.py.dox.md b/helpers/print_catch.py.dox.md new file mode 100644 index 000000000..16c630d70 --- /dev/null +++ b/helpers/print_catch.py.dox.md @@ -0,0 +1,40 @@ +# print_catch.py DOX + +## Purpose + +- Own the `print_catch.py` helper module. +- This module captures printed output from async callables. +- Keep this file-level DOX profile synchronized with `print_catch.py` because this directory is intentionally flat. + +## Ownership + +- `print_catch.py` owns the runtime implementation. +- `print_catch.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `capture_prints_async(func: Callable[..., Awaitable[Any]], *args, **kwargs) -> Tuple[Awaitable[Any], Callable[[], 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: `asyncio`, `io`, `sys`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `io.StringIO`, `captured_output.getvalue`, `wrapped_func`, `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. +- 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/print_style.py.dox.md b/helpers/print_style.py.dox.md new file mode 100644 index 000000000..193a9bb26 --- /dev/null +++ b/helpers/print_style.py.dox.md @@ -0,0 +1,54 @@ +# print_style.py DOX + +## Purpose + +- Own the `print_style.py` helper module. +- This module formats console/debug output consistently. +- Keep this file-level DOX profile synchronized with `print_style.py` because this directory is intentionally flat. + +## Ownership + +- `print_style.py` owns the runtime implementation. +- `print_style.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `PrintStyle` (no explicit base class) + - `get(self, *args, sep=..., **kwargs)` + - `print(self, *args, sep=..., end=..., flush=...)` + - `stream(self, *args, sep=..., flush=...)` + - `is_last_line_empty(self)` + - `standard(*args, sep=..., end=..., flush=...)` + - `hint(*args, sep=..., end=..., flush=...)` + - `info(*args, sep=..., end=..., flush=...)` + - `success(*args, sep=..., end=..., flush=...)` +- Top-level functions: +- `_get_runtime()` + +## 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, WebSocket state, secret handling. +- Imported dependency areas include: `atexit`, `collections.abc`, `datetime`, `html`, `os`, `strings`, `sys`, `webcolors`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `atexit.register`, `self._get_rgb_color_code`, `join`, `html.escape.replace`, `sep.join`, `self._format_args`, `sanitize_string`, `self._add_padding_if_needed`, `end.endswith`, `self._log_html`, `sys.stdin.readlines`, `PrintStyle._prefixed_args`, `files.get_abs_path`, `os.makedirs`, `datetime.now.strftime`, `os.path.join`, `f.write`, `self.secrets_mgr.mask_values`, `self._get_styled_text`, `self._get_html_styled_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_print_style.py` + - `tests/test_tool_action_contracts.py` + - `tests/test_ws_manager.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/process.py.dox.md b/helpers/process.py.dox.md new file mode 100644 index 000000000..217a7b3aa --- /dev/null +++ b/helpers/process.py.dox.md @@ -0,0 +1,54 @@ +# process.py DOX + +## Purpose + +- Own the `process.py` helper module. +- This module manages process reload, restart, exit, and server references. +- Keep this file-level DOX profile synchronized with `process.py` because this directory is intentionally flat. + +## Ownership + +- `process.py` owns the runtime implementation. +- `process.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `set_server(server)` +- `get_server(server)` +- `stop_server()` +- `reload()` +- `restart_process()` +- `exit_process()` + +## 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: subprocess/runtime control. +- Imported dependency areas include: `helpers`, `helpers.print_style`, `os`, `sys`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `stop_server`, `runtime.is_dockerized`, `PrintStyle.standard`, `os.execv`, `sys.exit`, `_server.shutdown`, `exit_process`, `restart_process`. +- 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_docker_release_plan.py` + - `tests/test_document_query_plugin.py` + - `tests/test_download_toast_regressions.py` + - `tests/test_git_version_label.py` + - `tests/test_image_get_security.py` + - `tests/test_model_config_project_presets.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/projects.py b/helpers/projects.py index e103e5f87..41bbf9d44 100644 --- a/helpers/projects.py +++ b/helpers/projects.py @@ -1,7 +1,7 @@ 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 +from helpers import files, dirty_json, persist_chat, file_tree, extension from helpers.print_style import PrintStyle @@ -12,8 +12,17 @@ PROJECTS_PARENT_DIR = "usr/projects" PROJECT_META_DIR = ".a0proj" PROJECT_INSTRUCTIONS_DIR = "instructions" PROJECT_KNOWLEDGE_DIR = "knowledge" +PROJECT_SKILLS_DIR = "skills" PROJECT_HEADER_FILE = "project.json" -PROJECT_AGENTS_MD_FILES = ("AGENTS.md", "Agents.md", "agents.md") +PROJECT_MCP_SERVERS_FILE = "mcp_servers.json" +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" @@ -34,6 +43,7 @@ class BasicProjectData(TypedDict): description: str instructions: str include_agents_md: NotRequired[bool] + mcp_servers: NotRequired[str] color: str git_url: str file_structure: FileStructureInjectionSettings @@ -53,10 +63,17 @@ class EditProjectData(BasicProjectData): knowledge_files_count: int variables: str secrets: str + mcp_servers: str subagents: dict[str, SubAgentSettings] git_status: GitStatusData +ProjectExtendedData = dict[str, object] +_PROJECT_CORE_EDIT_KEYS = frozenset(BasicProjectData.__annotations__) | frozenset( + EditProjectData.__annotations__ +) +_PROJECT_TRANSIENT_INPUT_KEYS = frozenset({"git_token"}) + def get_projects_parent_folder(): return files.get_abs_path(PROJECTS_PARENT_DIR) @@ -70,6 +87,17 @@ def get_project_meta(name: str, *sub_dirs: str): return files.get_abs_path(get_project_folder(name), PROJECT_META_DIR, *sub_dirs) +def validate_project_name(name: str | None) -> str: + candidate = str(name or "").strip() + if ( + not candidate + or candidate in {".", ".."} + or os.path.basename(candidate) != candidate + ): + raise ValueError("Invalid project name") + return candidate + + def delete_project(name: str): abs_path = files.get_abs_path(PROJECTS_PARENT_DIR, name) files.delete_dir(abs_path) @@ -78,14 +106,16 @@ def delete_project(name: str): def create_project(name: str, data: BasicProjectData): - llm_data = data.get("llm") if isinstance(data, dict) else None + extended_data = _project_extended_data_for_save(data) + mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None abs_path = files.create_dir_safe( files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}" ) create_project_meta_folders(name) data = _normalizeBasicData(data) save_project_header(name, data) - save_project_llm_settings(name, llm_data) + save_project_mcp_servers(name, mcp_servers or DEFAULT_MCP_SERVERS_CONFIG) + save_project_extended_data(name, extended_data) return name @@ -93,7 +123,8 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec """Clone a git repository as a new A0 project. Token is used only for cloning via http header.""" from helpers import git - llm_data = data.get("llm") if isinstance(data, dict) else None + extended_data = _project_extended_data_for_save(data) + mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None abs_path = files.create_dir_safe( files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}" @@ -121,7 +152,9 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec data["git_url"] = clean_url save_project_header(actual_name, data) - save_project_llm_settings(actual_name, llm_data) + if mcp_servers: + save_project_mcp_servers(actual_name, mcp_servers) + save_project_extended_data(actual_name, extended_data) return actual_name except Exception as e: @@ -183,6 +216,7 @@ def _normalizeEditData(data: EditProjectData) -> EditProjectData: data.get("include_agents_md", True) ), "variables": data.get("variables", ""), + "mcp_servers": data.get("mcp_servers", DEFAULT_MCP_SERVERS_CONFIG), "color": data.get("color", ""), "git_url": data.get("git_url", ""), "git_status": data.get("git_status", {"is_git_repo": False}), @@ -220,7 +254,7 @@ def _basic_data_to_edit_data(data: BasicProjectData) -> EditProjectData: def update_project(name: str, data: EditProjectData): - llm_data = data.get("llm") if isinstance(data, dict) else None + extended_data = _project_extended_data_for_save(data) # merge with current state current = load_edit_project_data(name) @@ -234,8 +268,9 @@ def update_project(name: str, data: EditProjectData): # save secrets save_project_variables(name, current["variables"]) save_project_secrets(name, current["secrets"]) + save_project_mcp_servers(name, current["mcp_servers"]) save_project_subagents(name, current["subagents"]) - save_project_llm_settings(name, llm_data) + save_project_extended_data(name, extended_data) reactivate_project_in_chats(name) return name @@ -251,8 +286,10 @@ def load_edit_project_data(name: str) -> EditProjectData: from helpers import git data = load_basic_project_data(name) + create_project_meta_folders(name) additional_instructions = get_additional_instructions_files(name) variables = load_project_variables(name) + mcp_servers = load_project_mcp_servers(name) secrets = load_project_secrets_masked(name) subagents = load_project_subagents(name) knowledge_files_count = get_knowledge_files_count(name) @@ -266,13 +303,14 @@ def load_edit_project_data(name: str) -> EditProjectData: "instruction_files_count": len(additional_instructions), "knowledge_files_count": knowledge_files_count, "variables": variables, + "mcp_servers": mcp_servers, "secrets": secrets, "subagents": subagents, "git_status": git_status, }, ) data = _normalizeEditData(data) - data["llm"] = load_project_llm_data(name) # type: ignore[typeddict-unknown-key] + _merge_project_extended_data(data, load_project_extended_data(name)) return data @@ -286,53 +324,56 @@ def save_project_header(name: str, data: BasicProjectData): files.write_file(abs_path, header) -def load_project_llm_data(name: str) -> dict: - from plugins._model_config.helpers import model_config +@extension.extensible +def load_project_extended_data(name: str) -> ProjectExtendedData: + return {} - config = model_config.normalize_config_for_save( - model_config.get_config(project_name=name) or {} - ) + +@extension.extensible +def save_project_extended_data(name: str, project_data: ProjectExtendedData): + return None + + +def _project_extended_data_for_save(data: object) -> ProjectExtendedData: + if not isinstance(data, dict): + return {} return { - "config": config, - "selected_preset": { - "scope": "current", - "project_name": name, - "name": "Current config", - }, - "presets": model_config.get_combined_presets(name), - "global_presets": model_config.get_presets(), - "project_presets": model_config.get_project_presets(name), + str(key): value + for key, value in data.items() + if str(key) not in _PROJECT_CORE_EDIT_KEYS + and str(key) not in _PROJECT_TRANSIENT_INPUT_KEYS } -def save_project_llm_settings(name: str, llm_data: object): - if not isinstance(llm_data, dict): +def _merge_project_extended_data( + data: EditProjectData, + extended_data: object, +) -> None: + if not isinstance(extended_data, dict): return - from helpers import plugins - from plugins._model_config.helpers import model_config + conflicts = sorted(str(key) for key in extended_data if key in _PROJECT_CORE_EDIT_KEYS) + if conflicts: + raise ValueError( + "Project extension data cannot overwrite core project fields: " + + ", ".join(conflicts) + ) - project_presets = llm_data.get("project_presets") - if isinstance(project_presets, list): - model_config.save_presets(project_presets, project_name=name) + data.update(extended_data) # type: ignore[typeddict-item] - config_to_save = None - selected_preset = llm_data.get("selected_preset") - if isinstance(selected_preset, dict) and selected_preset.get("scope") in { - "global", - "project", - }: - preset = model_config.resolve_preset_selection(selected_preset, project_name=name) - if preset: - base_config = model_config.get_config(project_name=name) or {} - config_to_save = model_config.build_config_from_preset(preset, base_config) - config = llm_data.get("config") - if config_to_save is None and isinstance(config, dict): - config_to_save = model_config.normalize_config_for_save(config) +def load_project_mcp_servers(name: str) -> str: + project_name = validate_project_name(name) + try: + return files.read_file(get_project_meta(project_name, PROJECT_MCP_SERVERS_FILE)) + except Exception: + return DEFAULT_MCP_SERVERS_CONFIG - if config_to_save is not None: - plugins.save_plugin_config("_model_config", name, "", config_to_save) + +def save_project_mcp_servers(name: str, mcp_servers: str): + project_name = validate_project_name(name) + content = mcp_servers if isinstance(mcp_servers, str) else DEFAULT_MCP_SERVERS_CONFIG + files.write_file(get_project_meta(project_name, PROJECT_MCP_SERVERS_FILE), content) def get_active_projects_list(): @@ -431,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, @@ -446,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 @@ -487,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 @@ -601,6 +704,9 @@ def create_project_meta_folders(name: str): # create knowledge folders (plugins create their own subdirs lazily) files.create_dir(get_project_meta(name, PROJECT_KNOWLEDGE_DIR)) + # create project skills folder for Project Settings > Skills > Open Folder + files.create_dir(get_project_meta(name, PROJECT_SKILLS_DIR)) + def get_knowledge_files_count(name: str): knowledge_folder = files.get_abs_path( diff --git a/helpers/projects.py.dox.md b/helpers/projects.py.dox.md new file mode 100644 index 000000000..e1e1b592e --- /dev/null +++ b/helpers/projects.py.dox.md @@ -0,0 +1,101 @@ +# projects.py DOX + +## Purpose + +- Own the `projects.py` helper module. +- This module owns project metadata, workspace creation, Git status, and project-scoped settings including per-project MCP server config. +- Keep this file-level DOX profile synchronized with `projects.py` because this directory is intentionally flat. + +## Ownership + +- `projects.py` owns the runtime implementation. +- `projects.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `FileStructureInjectionSettings` (`TypedDict`) +- `SubAgentSettings` (`TypedDict`) +- `BasicProjectData` (`TypedDict`) +- `GitStatusData` (`TypedDict`) +- `EditProjectData` (`BasicProjectData`) +- Top-level functions: +- `get_projects_parent_folder()` +- `get_project_folder(name: str)` +- `get_project_meta(name: str, *sub_dirs)` +- `validate_project_name(name: str | None) -> str` +- `delete_project(name: str)` +- `create_project(name: str, data: BasicProjectData)` +- `clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjectData)`: Clone a git repository as a new A0 project. Token is used only for cloning via http header. +- `load_project_header(name: str)` +- `_default_file_structure_settings()` +- `_normalizeBasicData(data: BasicProjectData) -> BasicProjectData` +- `_normalizeEditData(data: EditProjectData) -> EditProjectData` +- `_edit_data_to_basic_data(data: EditProjectData)` +- `_basic_data_to_edit_data(data: BasicProjectData) -> EditProjectData` +- `update_project(name: str, data: EditProjectData)` +- `load_basic_project_data(name: str) -> BasicProjectData` +- `load_edit_project_data(name: str) -> EditProjectData` +- `save_project_header(name: str, data: BasicProjectData)` +- `load_project_extended_data(name: str) -> ProjectExtendedData` +- `save_project_extended_data(name: str, project_data: ProjectExtendedData)` +- `_project_extended_data_for_save(data: object) -> ProjectExtendedData` +- `_merge_project_extended_data(data: EditProjectData, extended_data: object) -> None` +- `load_project_mcp_servers(name: str) -> str` +- `save_project_mcp_servers(name: str, mcp_servers: str)` +- `get_active_projects_list()` +- `_get_projects_list(parent_dir)` +- `activate_project(context_id: str, name: str, mark_dirty: bool=...)` +- `deactivate_project(context_id: str, mark_dirty: bool=...)` +- `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` +- `_format_project_instruction_files(instruction_files: list[tuple[str, str]]) -> str` +- `_normalize_include_agents_md(value: object) -> bool` +- Notable constants/configuration names: `PROJECTS_PARENT_DIR`, `PROJECT_META_DIR`, `PROJECT_INSTRUCTIONS_DIR`, `PROJECT_KNOWLEDGE_DIR`, `PROJECT_SKILLS_DIR`, `PROJECT_HEADER_FILE`, `PROJECT_MCP_SERVERS_FILE`, `PROJECT_AGENTS_MD_FILES`, `DEFAULT_MCP_SERVERS_CONFIG`, `CONTEXT_DATA_KEY_PROJECT`. + +## 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. +- Per-project MCP server configuration is persisted as `.a0proj/mcp_servers.json`, exposed through `load_edit_project_data(...)`, and saved during project create/clone/update flows. +- Additional project-edit payload sections are delegated through extensible `load_project_extended_data(...)` and `save_project_extended_data(...)`; the project helper must stay storage-agnostic and plugin-specific config rules belong to the owning plugin. +- 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. +- Imported dependency areas include: `helpers`, `helpers.print_style`, `os`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `files.get_abs_path`, `files.delete_dir`, `deactivate_project_in_chats`, `files.create_dir_safe`, `create_project_meta_folders`, `_normalizeBasicData`, `save_project_header`, `save_project_mcp_servers`, `load_project_mcp_servers`, `save_project_extended_data`, `load_project_extended_data`, `_project_extended_data_for_save`, `_merge_project_extended_data`, `_PROJECT_CORE_EDIT_KEYS`, `_PROJECT_TRANSIENT_INPUT_KEYS`, `extension.extensible`, `files.basename`, `dirty_json.parse`, `FileStructureInjectionSettings`, `cast`, `_normalizeEditData`, `load_edit_project_data`, `_edit_data_to_basic_data`, `save_project_variables`, `save_project_secrets`, `save_project_subagents`, `reactivate_project_in_chats`, `load_basic_project_data`. +- 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_model_config_project_presets.py` + - `tests/test_office_document_store.py` + - `tests/test_plugin_activation_ui.py` + - `tests/test_projects.py` + - `tests/test_skills_runtime.py` + - `tests/test_task_scheduler_timezone.py` + - `tests/test_time_travel.py` + - `tests/test_tool_action_contracts.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/providers.py.dox.md b/helpers/providers.py.dox.md new file mode 100644 index 000000000..083ccecfc --- /dev/null +++ b/helpers/providers.py.dox.md @@ -0,0 +1,61 @@ +# providers.py DOX + +## Purpose + +- Own the `providers.py` helper module. +- This module loads model provider configuration and provider metadata. +- Keep this file-level DOX profile synchronized with `providers.py` because this directory is intentionally flat. + +## Ownership + +- `providers.py` owns the runtime implementation. +- `providers.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `FieldOption` (`TypedDict`) +- `ProviderManager` (no explicit base class) + - `get_instance(cls)` + - `reload(cls)` + - `get_providers(self, provider_type: ModelType) -> List[FieldOption]` + - `get_raw_providers(self, provider_type: ModelType) -> List[Dict[str, str]]` + - `get_provider_config(self, provider_type: ModelType, provider_id: str) -> Optional[Dict[str, str]]` +- Top-level functions: +- `get_providers(provider_type: ModelType) -> List[FieldOption]`: Convenience function to get providers of a specific type. +- `get_raw_providers(provider_type: ModelType) -> List[Dict[str, str]]`: Return full metadata for providers of a given type. +- `get_provider_config(provider_type: ModelType, provider_id: str) -> Optional[Dict[str, str]]`: Return metadata for a single provider (None if not found). +- `reload_providers()`: Re-merge base + plugin provider configs. Call after plugin changes. +- Notable constants/configuration names: `PROVIDER_MANAGER_CACHE_AREA`, `PROVIDER_MANAGER_CACHE_KEY`. + +## 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, plugin state, settings/state persistence. +- Imported dependency areas include: `helpers`, `typing`, `yaml`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `ProviderManager.get_instance.get_providers`, `ProviderManager.get_instance.get_raw_providers`, `ProviderManager.get_instance.get_provider_config`, `ProviderManager.reload`, `cache.remove`, `cls.get_instance`, `inst._load_providers`, `files.get_abs_path`, `self._normalise_yaml`, `get_enabled_plugin_paths`, `provider_id.lower`, `self.get_raw_providers`, `cls`, `cache.add`, `self._load_providers`, `self._load_yaml`, `items.sort`, `ProviderManager.get_instance`, `lower`, `yaml.safe_load`. +- 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_fastmcp_openapi_security.py` + - `tests/test_model_config_api_keys.py` + - `tests/test_oauth_codex.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_onboarding_static.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/rate_limiter.py.dox.md b/helpers/rate_limiter.py.dox.md new file mode 100644 index 000000000..22572ec2b --- /dev/null +++ b/helpers/rate_limiter.py.dox.md @@ -0,0 +1,45 @@ +# rate_limiter.py DOX + +## Purpose + +- Own the `rate_limiter.py` helper module. +- This module provides simple rate-limiting primitives. +- Keep this file-level DOX profile synchronized with `rate_limiter.py` because this directory is intentionally flat. + +## Ownership + +- `rate_limiter.py` owns the runtime implementation. +- `rate_limiter.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `RateLimiter` (no explicit base class) + - `add(self, **kwargs)` + - `async cleanup(self)` + - `async get_total(self, key: str) -> int` + - `async wait(self, callback: Callable[[str, str, int, int], Awaitable[bool]] | 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`, `time`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `asyncio.Lock`, `time.time`, `self.cleanup`, `asyncio.sleep`, `self.get_total`, `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. +- Related tests observed by source search: + - `tests/test_stream_tool_early_stop.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/responses_tools.py b/helpers/responses_tools.py new file mode 100644 index 000000000..453e7d731 --- /dev/null +++ b/helpers/responses_tools.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +from typing import Any + +from helpers import files, subagents + + +FUNCTION_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$") +TOOL_NAME_EXAMPLE_PATTERN = re.compile( + r"""["']tool_name["']\s*:\s*["']([A-Za-z0-9_-]{1,64})["']""" +) +TOOL_HEADING_PATTERN = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*$", re.MULTILINE) +TOOL_PROMPT_PREFIX = "agent.system.tool." +TOOL_PROMPT_SUFFIX = ".md" +MAX_TOOL_DESCRIPTION_CHARS = 1024 + + +def build_responses_function_tools(agent: Any) -> tuple[list[dict[str, Any]], dict[str, str]]: + """Build permissive Responses function tools from A0 tool prompts and MCP schemas.""" + + tools: list[dict[str, Any]] = [] + name_map: dict[str, str] = {} + + for tool_name, prompt in _local_tool_prompts(agent): + native_name = _native_tool_name(tool_name) + name_map[native_name] = tool_name + tools.append( + { + "type": "function", + "name": native_name, + "description": _description_from_prompt(prompt, fallback=tool_name), + "parameters": _schema_from_prompt(prompt), + } + ) + + for tool_name, tool in _mcp_tools(agent): + native_name = _native_tool_name(tool_name) + name_map[native_name] = tool_name + tools.append( + { + "type": "function", + "name": native_name, + "description": _truncate(str(tool.get("description") or tool_name)), + "parameters": _schema_from_any(tool.get("input_schema")), + } + ) + + return _dedupe_tools(tools), name_map + + +def original_tool_name(native_name: str, name_map: dict[str, str] | None) -> str: + if not name_map: + return native_name + return name_map.get(native_name, native_name) + + +def _local_tool_prompts(agent: Any) -> list[tuple[str, str]]: + prompt_dirs = subagents.get_paths(agent, "prompts") + tool_files = files.get_unique_filenames_in_dirs( + prompt_dirs, f"{TOOL_PROMPT_PREFIX}*{TOOL_PROMPT_SUFFIX}" + ) + result: list[tuple[str, str]] = [] + for tool_file in tool_files: + basename = os.path.basename(tool_file) + fallback_name = _tool_name_from_prompt_basename(basename) + if not fallback_name: + continue + try: + prompt = agent.read_prompt(basename) + except Exception: + try: + prompt = files.read_file(tool_file) + except Exception: + prompt = "" + tool_name = _tool_name_from_prompt(prompt, fallback=fallback_name) + if not _include_local_tool_prompt(agent, tool_name): + continue + result.append((tool_name, prompt)) + return result + + +def _include_local_tool_prompt(agent: Any, tool_name: str) -> bool: + try: + from plugins._a0_connector.helpers.remote_tool_prompts import ( + should_include_remote_tool_prompt, + ) + except Exception: + return True + + return should_include_remote_tool_prompt(agent, tool_name) + + +def _mcp_tools(agent: Any) -> list[tuple[str, dict[str, Any]]]: + try: + import helpers.mcp_handler as mcp_helper + + raw_tools = mcp_helper.MCPConfig.get_instance().get_tools() + except Exception: + return [] + + result: list[tuple[str, dict[str, Any]]] = [] + for entry in raw_tools or []: + if not isinstance(entry, dict): + continue + for tool_name, tool in entry.items(): + if isinstance(tool, dict): + result.append((str(tool_name), tool)) + return result + + +def _tool_name_from_prompt_basename(basename: str) -> str: + if not basename.startswith(TOOL_PROMPT_PREFIX) or not basename.endswith(TOOL_PROMPT_SUFFIX): + return "" + name = basename[len(TOOL_PROMPT_PREFIX) : -len(TOOL_PROMPT_SUFFIX)] + if not name or name in {"tools", "tools_vision"}: + return "" + return name + + +def _tool_name_from_prompt(prompt: str, *, fallback: str) -> str: + for match in TOOL_NAME_EXAMPLE_PATTERN.finditer(prompt or ""): + name = match.group(1).strip() + if FUNCTION_NAME_PATTERN.fullmatch(name): + return name + + for match in TOOL_HEADING_PATTERN.finditer(prompt or ""): + name = _tool_name_from_heading(match.group(1)) + if name: + return name + + return fallback + + +def _tool_name_from_heading(heading: str) -> str: + token = (heading or "").strip().split(None, 1)[0] if heading else "" + name = token.strip("`'\" :") + if FUNCTION_NAME_PATTERN.fullmatch(name): + return name + return "" + + +def _native_tool_name(tool_name: str) -> str: + if FUNCTION_NAME_PATTERN.fullmatch(tool_name): + return tool_name + slug = re.sub(r"[^A-Za-z0-9_-]+", "_", tool_name).strip("_") + digest = hashlib.sha1(tool_name.encode("utf-8")).hexdigest()[:8] + native = f"{slug[:52]}_{digest}" if slug else f"a0_tool_{digest}" + return native[:64] + + +def _description_from_prompt(prompt: str, *, fallback: str) -> str: + lines: list[str] = [] + in_fence = False + for raw_line in (prompt or "").splitlines(): + line = raw_line.strip() + if line.startswith("```"): + in_fence = not in_fence + continue + if in_fence or not line: + continue + if line.startswith("#"): + line = line.lstrip("#").strip() + if line.lower() == fallback.lower(): + continue + lines.append(line) + if sum(len(part) for part in lines) >= MAX_TOOL_DESCRIPTION_CHARS: + break + description = " ".join(lines).strip() or fallback + return _truncate(description) + + +def _schema_from_prompt(prompt: str) -> dict[str, Any]: + schema = _schema_from_embedded_json(prompt) + if schema: + return schema + return _schema_from_args_line(prompt) + + +def _schema_from_embedded_json(prompt: str) -> dict[str, Any]: + marker = "Input schema for tool_args:" + index = (prompt or "").find(marker) + if index == -1: + return {} + tail = prompt[index + len(marker) :].strip() + match = re.search(r"\{(?:[^{}]|(?R))*\}", tail, flags=re.DOTALL) if hasattr(re, "VERSION1") else None + candidate = match.group(0) if match else _balanced_json_object(tail) + if not candidate: + return {} + try: + return _schema_from_any(json.loads(candidate)) + except Exception: + return {} + + +def _schema_from_args_line(prompt: str) -> dict[str, Any]: + properties: dict[str, Any] = {} + for line in (prompt or "").splitlines(): + normalized = line.strip() + if "args:" not in normalized.lower() and "argument:" not in normalized.lower(): + continue + for name in re.findall(r"`([A-Za-z_][A-Za-z0-9_-]*)`", normalized): + properties.setdefault(name, {"type": "string"}) + if properties: + return { + "type": "object", + "properties": properties, + "additionalProperties": True, + } + return _permissive_schema() + + +def _schema_from_any(schema: Any) -> dict[str, Any]: + if isinstance(schema, dict): + normalized = dict(schema) + normalized.setdefault("type", "object") + if normalized.get("type") == "object" and not isinstance( + normalized.get("properties"), dict + ): + normalized["properties"] = {} + normalized.setdefault("additionalProperties", True) + return normalized + return _permissive_schema() + + +def _permissive_schema() -> dict[str, Any]: + return {"type": "object", "properties": {}, "additionalProperties": True} + + +def _balanced_json_object(text: str) -> str: + start = text.find("{") + if start == -1: + return "" + depth = 0 + in_string = False + escape = False + for index, char in enumerate(text[start:], start=start): + if in_string: + if escape: + escape = False + elif char == "\\": + escape = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return text[start : index + 1] + return "" + + +def _dedupe_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[str] = set() + result: list[dict[str, Any]] = [] + for tool in tools: + name = str(tool.get("name") or "") + if not name or name in seen: + continue + seen.add(name) + result.append(tool) + return result + + +def _truncate(text: str) -> str: + if len(text) <= MAX_TOOL_DESCRIPTION_CHARS: + return text + return text[: MAX_TOOL_DESCRIPTION_CHARS - 3].rstrip() + "..." diff --git a/helpers/responses_tools.py.dox.md b/helpers/responses_tools.py.dox.md new file mode 100644 index 000000000..e7ec0e760 --- /dev/null +++ b/helpers/responses_tools.py.dox.md @@ -0,0 +1,30 @@ +# responses_tools.py DOX + +## Purpose + +- Own conversion of Agent Zero tool prompt files and MCP tool metadata into OpenAI Responses API function tool definitions. +- Keep native Responses function availability synchronized with the text tool prompt surface. + +## Ownership + +- `responses_tools.py` owns runtime implementation. +- `responses_tools.py.dox.md` owns durable notes about responsibilities, prompt-derived contracts, and verification for this helper. + +## Local Contracts + +- Build local function tools from enabled `agent.system.tool.*.md` prompt files. +- Local prompt-derived function names prefer explicit `"tool_name"` examples, then the first prompt heading, and only fall back to the prompt filename when the prompt declares no callable name. +- Function parameter schemas are object schemas with an explicit `properties` object so OpenAI-compatible servers that validate chat-style tool payloads accept permissive tools. +- Preserve original Agent Zero tool names through the native Responses name map. +- Keep MCP tool schemas merged after local prompt-derived tools. +- Connector remote tools are advertised only when `_a0_connector` runtime metadata says the matching connected CLI capability is currently available. + +## Work Guidance + +- Keep prompt-derived descriptions bounded by `MAX_TOOL_DESCRIPTION_CHARS`. +- Treat plugin-specific tool gates as optional imports so core helper loading does not require a plugin that is absent or disabled. + +## Verification + +- Run targeted Responses/tool prompt tests after changing function-tool construction. +- Run connector prompt gating tests when changing remote tool availability. diff --git a/helpers/rfc.py.dox.md b/helpers/rfc.py.dox.md new file mode 100644 index 000000000..dbe29b918 --- /dev/null +++ b/helpers/rfc.py.dox.md @@ -0,0 +1,48 @@ +# rfc.py DOX + +## Purpose + +- Own the `rfc.py` helper module. +- This module implements remote function call dispatch and serialization. +- Keep this file-level DOX profile synchronized with `rfc.py` because this directory is intentionally flat. + +## Ownership + +- `rfc.py` owns the runtime implementation. +- `rfc.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `RFCInput` (`TypedDict`) +- `RFCCall` (`TypedDict`) +- Top-level functions: +- `async call_rfc(url: str, password: str, module: str, function_name: str, args: list, kwargs: dict)` +- `async handle_rfc(rfc_call: RFCCall, password: str)` +- `async _call_function(module: str, function_name: str, *args, **kwargs)` +- `_get_function(module: str, function_name: str)` +- `async _send_json_data(url: str, data)` + +## 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, settings/state persistence, secret handling. +- Imported dependency areas include: `aiohttp`, `helpers`, `importlib`, `inspect`, `json`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `RFCInput`, `RFCCall`, `json.loads`, `_get_function`, `inspect.iscoroutinefunction`, `importlib.import_module`, `_send_json_data`, `crypto.verify_data`, `Exception`, `_call_function`, `func`, `aiohttp.ClientSession`, `json.dumps`, `crypto.hash_data`, `session.post`, `response.json`, `response.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/rfc_exchange.py.dox.md b/helpers/rfc_exchange.py.dox.md new file mode 100644 index 000000000..c960dabd9 --- /dev/null +++ b/helpers/rfc_exchange.py.dox.md @@ -0,0 +1,42 @@ +# rfc_exchange.py DOX + +## Purpose + +- Own the `rfc_exchange.py` helper module. +- This module handles privileged RFC exchange data such as root password provisioning. +- Keep this file-level DOX profile synchronized with `rfc_exchange.py` because this directory is intentionally flat. + +## Ownership + +- `rfc_exchange.py` owns the runtime implementation. +- `rfc_exchange.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `async get_root_password()` +- `_provide_root_password(public_key_pem: str)` +- `_get_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. +- Imported dependency areas include: `helpers`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `runtime.is_dockerized`, `_get_root_password`, `crypto.encrypt_data`, `crypto._generate_private_key`, `crypto._generate_public_key`, `crypto.decrypt_data`, `dotenv.get_dotenv_value`, `runtime.call_development_function`. +- 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/rfc_files.py.dox.md b/helpers/rfc_files.py.dox.md new file mode 100644 index 000000000..d05d75828 --- /dev/null +++ b/helpers/rfc_files.py.dox.md @@ -0,0 +1,70 @@ +# rfc_files.py DOX + +## Purpose + +- Own the `rfc_files.py` helper module. +- This module exposes RFC-safe filesystem operations. +- Keep this file-level DOX profile synchronized with `rfc_files.py` because this directory is intentionally flat. + +## Ownership + +- `rfc_files.py` owns the runtime implementation. +- `rfc_files.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `get_abs_path(*relative_paths)`: Convert relative paths to absolute paths based on the base directory. +- `read_file_bin(relative_path: str, backup_dirs=...) -> bytes`: Read binary file content. +- `read_file_base64(relative_path: str, backup_dirs=...) -> str`: Read file content and return as base64 string. +- `write_file_binary(relative_path: str, content: bytes) -> bool`: Write binary content to a file. +- `write_file_base64(relative_path: str, content: str) -> bool`: Write base64 content to a file. +- `delete_file(relative_path: str) -> bool`: Delete a file. +- `delete_directory(relative_path: str) -> bool`: Delete a directory recursively. +- `list_directory(relative_path: str, include_hidden: bool=...) -> list`: List directory contents. +- `make_directories(relative_path: str) -> bool`: Create directories recursively. +- `path_exists(relative_path: str) -> bool`: Check if a path exists. +- `file_exists(relative_path: str) -> bool`: Check if a file exists. +- `folder_exists(relative_path: str) -> bool`: Check if a folder exists. +- `get_subdirectories(relative_path: str, include: str | list[str]=..., exclude: str | list[str] | None=...) -> list[str]`: Get subdirectories in a directory. +- `zip_directory(relative_path: str) -> str`: Create a zip archive of a directory. +- `move_file(source_path: str, destination_path: str) -> bool`: Move a file from source to destination. +- `read_directory_as_zip(relative_path: str) -> bytes`: Read entire directory contents as a zip file. +- `find_file_in_dirs(file_path: str, backup_dirs: list[str]) -> str`: Find a file in the main directory or backup directories. +- `_read_file_binary_impl(file_path: str) -> str`: Implementation function to read a file in binary mode. +- `_write_file_binary_impl(file_path: str, b64_content: str) -> bool`: Implementation function to write binary content to a file. +- `_delete_file_impl(file_path: str) -> bool`: Implementation function to delete a file. +- `_delete_folder_impl(folder_path: str) -> bool`: Implementation function to delete a folder recursively. +- `_list_folder_impl(folder_path: str, include_hidden: bool=...) -> list`: Implementation function to list folder contents. +- `_make_dirs_impl(folder_path: str) -> bool`: Implementation function to create directories. +- `_path_exists_impl(file_path: str) -> bool`: Implementation function to check if path exists. +- `_file_exists_impl(file_path: str) -> bool`: Implementation function to check if file exists. +- `_folder_exists_impl(folder_path: str) -> bool`: Implementation function to check if folder exists. +- `_get_subdirectories_impl(folder_path: str, include: str | list[str], exclude: str | list[str] | None) -> list[str]`: Implementation function to get subdirectories. +- `_zip_dir_impl(folder_path: str) -> str`: Implementation function to create a zip archive of a directory. +- `_move_file_impl(source_path: str, destination_path: str) -> bool`: Implementation function to move a file. +- `_read_directory_impl(dir_path: str) -> str`: Implementation function to zip a directory and return base64 encoded zip. + +## 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: `base64`, `fnmatch`, `helpers`, `os`, `shutil`, `tempfile`, `zipfile`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `os.path.abspath`, `os.path.join`, `find_file_in_dirs`, `runtime.call_development_function_sync`, `base64.b64decode`, `get_abs_path`, `base64.b64encode.decode`, `FileNotFoundError`, `os.path.exists`, `os.path.basename`, `os.path.isfile`, `Exception`, `os.makedirs`, `os.remove`, `os.path.isdir`, `shutil.rmtree`, `os.listdir`, `items.sort`, `tempfile.NamedTemporaryFile`, `zipfile.ZipFile`. +- 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/runtime.py.dox.md b/helpers/runtime.py.dox.md new file mode 100644 index 000000000..e9e5ae922 --- /dev/null +++ b/helpers/runtime.py.dox.md @@ -0,0 +1,69 @@ +# runtime.py DOX + +## Purpose + +- Own the `runtime.py` helper module. +- This module owns runtime identity, environment flags, and startup arguments. +- Keep this file-level DOX profile synchronized with `runtime.py` because this directory is intentionally flat. + +## Ownership + +- `runtime.py` owns the runtime implementation. +- `runtime.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `initialize()` +- `get_arg(name: str)` +- `has_arg(name: str)` +- `is_dockerized() -> bool` +- `is_development() -> bool` +- `get_local_url()` +- `get_runtime_id() -> str` +- `get_persistent_id() -> str` +- `async call_development_function(func: Callable[..., Awaitable[T]], *args, **kwargs) -> T` +- `async call_development_function(func: Callable[..., T], *args, **kwargs) -> T` +- `async call_development_function(func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs) -> T` +- `async handle_rfc(rfc_call: rfc.RFCCall)` +- `_get_rfc_password() -> str` +- `_get_rfc_url() -> str` +- `call_development_function_sync(func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs) -> T` +- `get_web_ui_port()` +- `get_tunnel_api_port()` +- `get_platform()` +- `is_windows()` +- `get_terminal_executable()` +- Notable constants/configuration names: `T`, `R`. + +## 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, subprocess/runtime control, settings/state persistence, secret handling, tunnel state. +- Imported dependency areas include: `argparse`, `asyncio`, `helpers`, `inspect`, `nest_asyncio`, `pathlib`, `queue`, `secrets`, `sys`, `threading`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `nest_asyncio.apply`, `TypeVar`, `argparse.ArgumentParser`, `parser.add_argument`, `parser.parse_known_args`, `vars`, `is_dockerized`, `dotenv.get_dotenv_value`, `is_development`, `settings.get_settings`, `url.endswith`, `queue.Queue`, `threading.Thread`, `thread.start`, `thread.join`, `thread.is_alive`, `result_queue.get_nowait`, `cast`, `is_windows`, `get_arg`. +- 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_computer_use_metadata.py` + - `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_host_browser_connector.py` + - `tests/test_http_auth_csrf.py` + - `tests/test_image_get_security.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/searxng.py.dox.md b/helpers/searxng.py.dox.md new file mode 100644 index 000000000..889e62df1 --- /dev/null +++ b/helpers/searxng.py.dox.md @@ -0,0 +1,43 @@ +# searxng.py DOX + +## Purpose + +- Own the `searxng.py` helper module. +- This module queries SearXNG search. +- Keep this file-level DOX profile synchronized with `searxng.py` because this directory is intentionally flat. + +## Ownership + +- `searxng.py` owns the runtime implementation. +- `searxng.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `async search(query: str)` +- `async _search(query: str)` +- Notable constants/configuration names: `URL`. + +## 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: network calls, settings/state persistence. +- Imported dependency areas include: `aiohttp`, `helpers`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `runtime.call_development_function`, `aiohttp.ClientSession`, `session.post`, `response.json`. +- 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/secrets.py.dox.md b/helpers/secrets.py.dox.md new file mode 100644 index 000000000..2824362aa --- /dev/null +++ b/helpers/secrets.py.dox.md @@ -0,0 +1,62 @@ +# secrets.py DOX + +## Purpose + +- Own the `secrets.py` helper module. +- This module loads, aliases, masks, and streams secret values safely. +- Keep this file-level DOX profile synchronized with `secrets.py` because this directory is intentionally flat. + +## Ownership + +- `secrets.py` owns the runtime implementation. +- `secrets.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `EnvLine` (no explicit base class) +- `StreamingSecretsFilter` (no explicit base class) + - `process_chunk(self, chunk: str) -> str` + - `finalize(self) -> str` +- `SecretsManager` (no explicit base class) + - `get_instance(cls, *secrets_files) -> 'SecretsManager'` + - `read_secrets_raw(self) -> str` + - `load_secrets(self) -> Dict[str, str]` + - `save_secrets(self, secrets_content: str)` + - `save_secrets_with_merge(self, submitted_content: str)` + - `get_keys(self) -> List[str]` + - `get_secrets_for_prompt(self) -> str` + - `create_streaming_filter(self) -> 'StreamingSecretsFilter'` +- Top-level functions: +- `alias_for_key(key: str, placeholder: str=...) -> str` +- `get_secrets_manager(context: 'AgentContext|None'=...) -> SecretsManager` +- `get_project_secrets_manager(project_name: str, merge_with_global: bool=...) -> SecretsManager` +- `get_default_secrets_manager() -> SecretsManager` +- Notable constants/configuration names: `ALIAS_PATTERN`, `DEFAULT_SECRETS_FILE`. + +## 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. +- Imported dependency areas include: `dataclasses`, `dotenv.parser`, `helpers`, `helpers.errors`, `helpers.extension`, `io`, `os`, `re`, `threading`, `time`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `key.upper`, `placeholder.format`, `SecretsManager.get_instance`, `self._replace_full_values`, `self._longest_suffix_prefix`, `threading.RLock`, `join`, `files.write_file`, `self._invalidate_all_caches`, `self.load_secrets`, `self.read_secrets_raw`, `self.parse_env_lines`, `self._serialize_env_lines`, `StreamingSecretsFilter`, `re.sub`, `self.parse_env_content`, `parse_stream`, `AgentContext.current`, `projects.get_context_project_name`, `files.get_abs_path`. +- 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_plugin_scan_prompt.py` + - `tests/test_print_style.py` + - `tests/test_time_travel.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/security.py.dox.md b/helpers/security.py.dox.md new file mode 100644 index 000000000..882286b52 --- /dev/null +++ b/helpers/security.py.dox.md @@ -0,0 +1,45 @@ +# security.py DOX + +## Purpose + +- Own the `security.py` helper module. +- This module contains small security helpers such as filename sanitization. +- Keep this file-level DOX profile synchronized with `security.py` because this directory is intentionally flat. + +## Ownership + +- `security.py` owns the runtime implementation. +- `security.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `safe_filename(filename: str) -> Optional[str]` +- Notable constants/configuration names: `FORBIDDEN_CHARS_RE`, `WINDOWS_RESERVED`, `FILENAME_MAX_LENGTH`. + +## 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. +- Imported dependency areas include: `pathlib`, `re`, `typing`, `unicodedata`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `re.compile`, `frozenset`, `unicodedata.normalize`, `FORBIDDEN_CHARS_RE.sub`, `filename.lstrip.rstrip`, `Path`, `join`, `stem.upper`, `filename.lstrip`. +- 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_fastmcp_openapi_security.py` + - `tests/test_image_get_security.py` + - `tests/test_ws_security.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/self_update.py.dox.md b/helpers/self_update.py.dox.md new file mode 100644 index 000000000..b1e552539 --- /dev/null +++ b/helpers/self_update.py.dox.md @@ -0,0 +1,77 @@ +# self_update.py DOX + +## Purpose + +- Own the `self_update.py` helper module. +- This module manages self-update status, scheduling, tag selection, and durable scripts. +- Keep this file-level DOX profile synchronized with `self_update.py` because this directory is intentionally flat. + +## Ownership + +- `self_update.py` owns the runtime implementation. +- `self_update.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `PendingUpdateConfig` (`TypedDict`) +- `UpdateStatus` (`TypedDict`) +- `SelectorTagOption` (`TypedDict`) +- Top-level functions: +- `_now_iso() -> str` +- `get_update_file_path() -> Path` +- `get_status_file_path() -> Path` +- `get_log_file_path() -> Path` +- `get_durable_exe_dir() -> Path` +- `get_durable_self_update_manager_path() -> Path` +- `_load_yaml(path: Path) -> dict[str, Any] | None` +- `_write_yaml(path: Path, payload: dict[str, Any]) -> None` +- `load_pending_update() -> PendingUpdateConfig | None` +- `load_last_status() -> UpdateStatus | None` +- `get_log_text() -> str` +- `get_default_backup_dir(repo_dir: str | Path | None=...) -> Path` +- `get_repo_dir(repo_dir: str | Path | None=...) -> Path` +- `get_repo_self_update_manager_path(repo_dir: str | Path | None=...) -> Path` +- `_get_official_remote_url() -> str` +- `_run_git_raw(*args) -> str` +- `_run_git(repo_dir: str | Path, *args) -> str` +- `_normalize_describe_to_version(describe: str) -> str` +- `_split_describe_version(describe: str) -> tuple[str, int]` +- `_is_latest_selector_tag(tag: str) -> bool` +- `_get_tag_release_time_in_repo(repo_dir: str | Path, tag: str) -> str` +- `get_repo_version_info(repo_dir: str | Path | None=...) -> dict[str, str]` +- `_sanitize_filename(name: str, default_name: str) -> str` +- `_slugify_version(text: str) -> str` +- `build_default_backup_name(current_version: str, target_tag: str | None=...) -> str` +- `_resolve_backup_path(backup_path: str, repo_dir: str | Path | None=...) -> Path` +- `_is_excluded_self_update_branch(branch: str) -> bool` +- `_sort_branch_names(branches: list[str]) -> list[str]` +- `_get_remote_branch_names() -> list[str]` +- `_get_local_origin_branch_names(repo_dir: str | Path | None=...) -> list[str]` +- Notable constants/configuration names: `OFFICIAL_REPO_AUTHOR`, `OFFICIAL_REPO_NAME`, `BRANCH_OPTIONS`, `SUPPORTED_BRANCHES`, `BACKUP_CONFLICT_POLICIES`, `MIN_SELECTOR_VERSION`, `REMOTE_BRANCH_TAG_CACHE_TTL_SECONDS`, `REMOTE_BRANCH_LIST_CACHE_TTL_SECONDS`, `UPDATE_FILE_PATH`, `STATUS_FILE_PATH`, `LOG_FILE_PATH`, `DURABLE_EXE_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, subprocess/runtime control, settings/state persistence. +- Imported dependency areas include: `__future__`, `datetime`, `helpers`, `helpers.localization`, `os`, `pathlib`, `re`, `subprocess`, `tempfile`, `time`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `Path`, `Localization.get.now_iso`, `yaml.loads`, `path.parent.mkdir`, `path.write_text`, `_load_yaml`, `get_log_file_path`, `path.read_text`, `subprocess.run`, `completed.stdout.strip`, `re.fullmatch`, `describe.strip`, `tag.strip`, `get_repo_dir`, `_run_git`, `_normalize_describe_to_version`, `strip`, `re.sub.strip`, `Localization.get.now.strftime`, `path.resolve`. +- 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` + - `tests/test_self_update_tag_filter.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/serveo_tunnel.py.dox.md b/helpers/serveo_tunnel.py.dox.md new file mode 100644 index 000000000..c209237f1 --- /dev/null +++ b/helpers/serveo_tunnel.py.dox.md @@ -0,0 +1,43 @@ +# serveo_tunnel.py DOX + +## Purpose + +- Own the `serveo_tunnel.py` helper module. +- This module implements Serveo tunnel provider behavior. +- Keep this file-level DOX profile synchronized with `serveo_tunnel.py` because this directory is intentionally flat. + +## Ownership + +- `serveo_tunnel.py` owns the runtime implementation. +- `serveo_tunnel.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ServeoTunnelHelper` (`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: `ServeoConfig`, `ServeoTunnel`. +- 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/server_startup.py.dox.md b/helpers/server_startup.py.dox.md new file mode 100644 index 000000000..a8417341c --- /dev/null +++ b/helpers/server_startup.py.dox.md @@ -0,0 +1,62 @@ +# server_startup.py DOX + +## Purpose + +- Own the `server_startup.py` helper module. +- This module runs Uvicorn startup with health checks and retry tracking. +- Keep this file-level DOX profile synchronized with `server_startup.py` because this directory is intentionally flat. + +## Ownership + +- `server_startup.py` owns the runtime implementation. +- `server_startup.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `StartupConfig` (no explicit base class) + - `from_env(cls) -> 'StartupConfig'` +- `StartupStageRecord` (no explicit base class) +- `StartupMonitor` (no explicit base class) + - `mark(self, stage: str, detail: str | None=...) -> None` + - `stage(self, stage: str, detail: str | None=...) -> Iterator[None]` + - `lifespan(self)` + - `attach_server(self, server: uvicorn.Server) -> None` + - `start_watchdog(self) -> None` + - `mark_ready(self, source: str=...) -> None` + - `is_ready(self) -> bool` + - `close(self) -> None` +- `_UvicornServerWrapper` (no explicit base class) + - `shutdown(self) -> None` +- Top-level functions: +- `_env_int(name: str, default: int, minimum: int=...) -> int` +- `_env_float(name: str, default: float, minimum: float=...) -> float` +- `get_health_probe_host(bind_host: str) -> str` +- `run_uvicorn_with_retries(host: str, port: int, build_asgi_app: Callable[[StartupMonitor], object], flush_callback: Callable[[str], None], access_log: bool=..., log_level: str=..., ws: str=..., startup_config: StartupConfig | None=...) -> None` +- `_run_server_attempt(host: str, health_host: str, port: int, startup_monitor: StartupMonitor, build_asgi_app: Callable[[StartupMonitor], object], flush_callback: Callable[[str], None], access_log: bool, log_level: str, ws: str) -> bool` +- `wait_for_health(host: str, port: int, startup_monitor: StartupMonitor) -> None` +- `_serve_uvicorn(server: uvicorn.Server) -> 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 writes, network calls, subprocess/runtime control, settings/state persistence. +- Imported dependency areas include: `asyncio`, `collections`, `contextlib`, `dataclasses`, `faulthandler`, `helpers`, `helpers.print_style`, `os`, `sys`, `threading`, `time`, `typing`, `urllib.request`, `uvicorn`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `dataclass`, `get_health_probe_host`, `PrintStyle.debug`, `RuntimeError`, `startup_monitor.start_watchdog`, `server.config.get_loop_factory`, `cls`, `time.monotonic`, `deque`, `threading.Event`, `threading.RLock`, `self.mark`, `threading.Thread`, `self._watchdog_thread.start`, `self._ready.is_set`, `self.snapshot`, `PrintStyle.error`, `join`, `StartupConfig.from_env`, `StartupMonitor`. +- 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/settings.py b/helpers/settings.py index 88eea749e..35b3f7beb 100644 --- a/helpers/settings.py +++ b/helpers/settings.py @@ -572,18 +572,27 @@ def _apply_settings(previous: Settings | None, browser_timezone: str | None = No if _settings: _apply_timezone_setting(previous, browser_timezone) - from agent import AgentContext + from agent import Agent, AgentContext from initialize import initialize_agent for ctx in AgentContext.all(): - profile = str(getattr(ctx.config, "profile", "") or _settings["agent_profile"]) - config = initialize_agent(override_settings={"agent_profile": profile}) - ctx.config = config # reinitialize context config with new settings - # apply config to agents + profile = str( + getattr(ctx.config, "profile", "") or _settings["agent_profile"] + ) + ctx.config = initialize_agent(override_settings={"agent_profile": profile}) agent = ctx.agent0 while agent: - agent.config = ctx.config - agent = agent.get_data(agent.DATA_NAME_SUBORDINATE) + agent_profile = str( + getattr(getattr(agent, "config", None), "profile", "") or profile + ) + agent.config = ( + ctx.config + if agent is ctx.agent0 and agent_profile == profile + else initialize_agent( + override_settings={"agent_profile": agent_profile} + ) + ) + agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE) # update mcp settings if necessary if not previous or _settings["mcp_servers"] != previous["mcp_servers"]: @@ -639,7 +648,7 @@ def _apply_settings(previous: Settings | None, browser_timezone: str | None = No ) task2 = defer.DeferredTask().start_task( - update_mcp_settings, config.mcp_servers + update_mcp_settings, _settings["mcp_servers"] ) # TODO overkill, replace with background task # update token in mcp server diff --git a/helpers/settings.py.dox.md b/helpers/settings.py.dox.md new file mode 100644 index 000000000..d5b61ce92 --- /dev/null +++ b/helpers/settings.py.dox.md @@ -0,0 +1,91 @@ +# settings.py DOX + +## Purpose + +- Own the `settings.py` helper module. +- This module defines settings models, defaults, validation, and serialization. +- Keep this file-level DOX profile synchronized with `settings.py` because this directory is intentionally flat. + +## Ownership + +- `settings.py` owns the runtime implementation. +- `settings.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Settings` (`TypedDict`) +- `PartialSettings` (`Settings`) +- `FieldOption` (`TypedDict`) +- `SettingsField` (`TypedDict`) +- `SettingsSection` (`TypedDict`) +- `ModelProvider` (`ProvidersFO`) +- `SettingsOutputAdditional` (`TypedDict`) +- `SettingsOutput` (`TypedDict`) +- Top-level functions: +- `get_default_value(name: str, value: T) -> T`: Load setting value from .env with A0_SET_ prefix, falling back to default. +- `_ensure_option_present(options: list[OptionT] | None, current_value: str | None) -> list[OptionT]`: Ensure the currently selected value exists in a dropdown options list. +- `_is_valid_timezone(value: str) -> bool` +- `_normalize_timezone_setting(value: Any, default: str=...) -> str` +- `_normalize_time_format(value: Any, default: str=...) -> str` +- `_resolve_runtime_timezone(setting_value: str, browser_timezone: str | None=...) -> str` +- `_timezone_options() -> list[FieldOption]` +- `convert_out(settings: Settings) -> SettingsOutput` +- `_get_api_key_field(settings: Settings, provider: str, title: str) -> SettingsField` +- `convert_in(settings: Settings) -> Settings` +- `get_settings() -> Settings` +- `reload_settings() -> Settings` +- `set_runtime_settings_snapshot(settings: Settings) -> None` +- `set_settings(settings: Settings, apply: bool=..., browser_timezone: str | None=...)` +- `set_settings_delta(delta: dict, apply: bool=...)` +- `merge_settings(original: Settings, delta: dict) -> Settings` +- `normalize_settings(settings: Settings) -> Settings` +- `_adjust_to_version(settings: Settings, default: Settings)` +- `_load_sensitive_settings(settings: Settings)` +- `_read_settings_file() -> Settings | None` +- `_write_settings_file(settings: Settings)` +- `_remove_sensitive_settings(settings: Settings)` +- `_write_sensitive_settings(settings: Settings)` +- `get_default_settings() -> Settings` +- `_apply_timezone_setting(previous: Settings | None, browser_timezone: str | None=...) -> None` +- `_apply_settings(previous: Settings | None, browser_timezone: str | None=...)` +- `_env_to_dict(data: str)` +- `_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`. + +## 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, model calls, WebSocket state, plugin state, settings/state persistence, secret handling, scheduler state. +- Imported dependency areas include: `base64`, `hashlib`, `helpers`, `helpers.notification`, `helpers.print_style`, `helpers.providers`, `helpers.secrets`, `json`, `models`, `os`, `pytz`, `re`, `subprocess`, `typing`. + +## Key Concepts + +- 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. +- 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_document_query_plugin.py` + - `tests/test_download_toast_regressions.py` + - `tests/test_fasta2a_client.py` + - `tests/test_mcp_handler_multimodal.py` + - `tests/test_model_config_api_keys.py` + - `tests/test_model_config_project_presets.py` + - `tests/test_oauth_static.py` + - `tests/test_subagent_profiles.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/skills.py b/helpers/skills.py index 8a042fe58..1eeaa8504 100644 --- a/helpers/skills.py +++ b/helpers/skills.py @@ -21,9 +21,11 @@ except Exception: # pragma: no cover MAX_ACTIVE_SKILLS = 20 ACTIVE_SKILLS_PLUGIN_NAME = "_skills" AGENT_DATA_NAME_LOADED_SKILLS = "loaded_skills" +CONTEXT_DATA_NAME_LOADED_SKILLS = AGENT_DATA_NAME_LOADED_SKILLS CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS = "skills_chat_active" CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS = "skills_chat_disabled" CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS = "skills_chat_visible" +_WARNED_SKILL_PARSE_PATHS: set[Path] = set() class ActiveSkillEntry(TypedDict, total=False): @@ -253,6 +255,47 @@ def parse_frontmatter(frontmatter_text: str) -> Tuple[Dict[str, Any], List[str]] return parsed, errors +def _emit_skill_scan_warning(message: str) -> None: + try: + from helpers.print_style import PrintStyle + + PrintStyle.warning(message) + except Exception: + print(f"Warning: {message}") + + +def _frontmatter_error_line(lines: List[str], error: str) -> int | None: + if not lines: + return 1 + + if error.startswith("Frontmatter must start"): + for index, line in enumerate(lines, start=1): + if line.strip(): + return index + return 1 + if error.startswith("Missing YAML frontmatter"): + return 1 + if error.startswith("Unterminated YAML frontmatter"): + return max(len(lines), 1) + return None + + +def _warn_skill_skipped(skill_md_path: Path, markdown: str, errors: List[str]) -> None: + if not errors: + return + if skill_md_path in _WARNED_SKILL_PARSE_PATHS: + return + _WARNED_SKILL_PARSE_PATHS.add(skill_md_path) + + error = str(errors[0] or "invalid frontmatter").strip() + line = _frontmatter_error_line((markdown or "").splitlines(), error) + skill_label = skill_md_path.parent.name or str(skill_md_path) + location = f" at line {line}" if line is not None else "" + _emit_skill_scan_warning( + f"skill {skill_label} skipped: invalid frontmatter{location}: {error}" + ) + + def skill_from_markdown( skill_md_path: Path, *, @@ -266,6 +309,7 @@ def skill_from_markdown( fm, body, fm_errors = split_frontmatter(text) if fm_errors: + _warn_skill_skipped(skill_md_path, text, fm_errors) return None skill_dir = Path(files.normalize_a0_path(str(skill_md_path.parent))) @@ -451,6 +495,20 @@ def load_skill_for_agent( return "\n".join(lines) +def skill_instruction_name(message: Any) -> str: + match message: + case { + "content": { + "skill_instructions": { + "content_included": included, + "name": name, + } + } + } if included: + return str(name or "").strip() + return "" + + def _get_skill_files(skill_dir: Path) -> str: """Get file tree for skill directory.""" if not skill_dir.exists(): @@ -485,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]] = [] @@ -510,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): @@ -830,19 +891,77 @@ def get_active_skills(agent: Agent | None) -> list[ActiveSkillEntry]: return _build_active_skills(agent, limit=get_max_active_skills(agent=agent)) -def get_loaded_skill_entries(agent: Agent | None) -> list[ActiveSkillEntry]: +def _normalize_loaded_skill_names(raw: Any) -> list[str]: + if not isinstance(raw, list): + return [] + + names: list[str] = [] + for value in raw: + name = str(value or "").strip() + if name and name not in names: + names.append(name) + return names + + +def get_loaded_skill_names(agent: Agent | None) -> list[str]: if not agent: return [] - loaded = getattr(agent, "data", {}).get(AGENT_DATA_NAME_LOADED_SKILLS) - if not isinstance(loaded, list): - return [] + context = getattr(agent, "context", None) + if context and hasattr(context, "get_data"): + names = _normalize_loaded_skill_names( + context.get_data(CONTEXT_DATA_NAME_LOADED_SKILLS) + ) + if names: + data = getattr(agent, "data", None) + if isinstance(data, dict): + data.pop(AGENT_DATA_NAME_LOADED_SKILLS, None) + return names - return [ - {"name": str(skill_name).strip()} - for skill_name in loaded - if str(skill_name).strip() - ] + legacy_names = _normalize_loaded_skill_names( + getattr(agent, "data", {}).get(AGENT_DATA_NAME_LOADED_SKILLS) + ) + if legacy_names: + set_loaded_skill_names(agent, legacy_names) + return legacy_names + + +def set_loaded_skill_names(agent: Agent | None, skill_names: Any) -> list[str]: + names = _normalize_loaded_skill_names(skill_names)[-MAX_ACTIVE_SKILLS:] + if not agent: + return names + + context = getattr(agent, "context", None) + if context and hasattr(context, "set_data"): + context.set_data(CONTEXT_DATA_NAME_LOADED_SKILLS, names or None) + data = getattr(agent, "data", None) + if isinstance(data, dict): + data.pop(AGENT_DATA_NAME_LOADED_SKILLS, None) + return names + + data = getattr(agent, "data", None) + if isinstance(data, dict): + data[AGENT_DATA_NAME_LOADED_SKILLS] = names + return names + + +def add_loaded_skill_name( + agent: Agent | None, + skill_name: str, + *, + limit: int | None = None, +) -> list[str]: + name = str(skill_name or "").strip() + if not name: + return get_loaded_skill_names(agent) + + names = [loaded for loaded in get_loaded_skill_names(agent) if loaded != name] + names.append(name) + return set_loaded_skill_names(agent, names[-(limit or MAX_ACTIVE_SKILLS):]) + + +def get_loaded_skill_entries(agent: Agent | None) -> list[ActiveSkillEntry]: + return [{"name": skill_name} for skill_name in get_loaded_skill_names(agent)] def unload_agent_skill(agent: Agent | None, entry: Any) -> bool: @@ -850,17 +969,9 @@ def unload_agent_skill(agent: Agent | None, entry: Any) -> bool: if not agent or not normalized: return False - data = getattr(agent, "data", None) - if not isinstance(data, dict): - return False - - loaded = data.get(AGENT_DATA_NAME_LOADED_SKILLS) - if not isinstance(loaded, list): - return False - next_loaded: list[str] = [] removed = False - for skill_name in loaded: + for skill_name in get_loaded_skill_names(agent): loaded_entry = _normalize_active_skill_entry(str(skill_name)) if loaded_entry and _entries_match(loaded_entry, normalized): removed = True @@ -868,7 +979,7 @@ def unload_agent_skill(agent: Agent | None, entry: Any) -> bool: next_loaded.append(skill_name) if removed: - data[AGENT_DATA_NAME_LOADED_SKILLS] = next_loaded + set_loaded_skill_names(agent, next_loaded) return removed @@ -1024,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) @@ -1075,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 new file mode 100644 index 000000000..902caf3fc --- /dev/null +++ b/helpers/skills.py.dox.md @@ -0,0 +1,92 @@ +# skills.py DOX + +## Purpose + +- Own the `skills.py` helper module. +- This module discovers, parses, filters, and resolves Agent Zero skills. +- Keep this file-level DOX profile synchronized with `skills.py` because this directory is intentionally flat. + +## Ownership + +- `skills.py` owns the runtime implementation. +- `skills.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ActiveSkillEntry` (`TypedDict`) +- `CatalogSkill` (`TypedDict`) +- `Skill` (no explicit base class) +- Top-level functions: +- `get_skills_base_dir() -> Path` +- `get_skill_roots(agent: Agent | None=...) -> List[str]` +- `_is_hidden_path(path: Path) -> bool` +- `discover_skill_md_files(root: Path) -> List[Path]`: Recursively discover SKILL.md files under a root directory. +- `_coerce_list(value: Any) -> List[str]` +- `_normalize_name(name: str) -> str` +- `_read_text(path: Path) -> str` +- `split_frontmatter(markdown: str) -> Tuple[Dict[str, Any], str, List[str]]`: Splits a SKILL.md into (frontmatter_dict, body_text, errors). +- `_parse_frontmatter_fallback(frontmatter_text: str) -> Dict[str, Any]` +- `parse_frontmatter(frontmatter_text: str) -> Tuple[Dict[str, Any], List[str]]`: Parse YAML frontmatter with PyYAML when available, +- `skill_from_markdown(skill_md_path: Path, include_content: bool=..., validate: bool=...) -> Optional[Skill]` +- `list_skills(agent: Agent | None=..., include_content: bool=..., include_hidden: bool=...) -> List[Skill]`: List skills, optionally filtered by agent scope. +- `delete_skill(skill_path: str) -> None`: Delete a skill directory. +- `find_skill(skill_name: str, agent: Agent | None=..., include_content: bool=..., include_hidden: bool=...) -> Optional[Skill]` +- `load_skill_for_agent(skill_name: str, agent: Agent | None=...) -> str`: Load skill and format it as a complete string for agent context. +- `skill_instruction_name(message: Any) -> str` +- `_get_skill_files(skill_dir: Path) -> str`: Get file tree for skill directory. +- `search_skills(query: str, limit: int=..., agent: Agent | None=..., include_hidden: bool=...) -> List[Skill]` +- `validate_skill(skill: Skill) -> List[str]` +- `validate_skill_md(skill_md_path: Path) -> List[str]` +- `_normalize_max_active_skills(value: Any) -> int` +- `get_max_active_skills(agent: Agent | None=..., project_name: str | None=...) -> int` +- `normalize_skills_config(config: dict[str, Any] | None) -> dict[str, Any]` +- `normalize_active_skills(raw: Any, limit: int | None=...) -> list[ActiveSkillEntry]` +- `normalize_hidden_skills(raw: Any) -> list[ActiveSkillEntry]` +- `normalize_skill_entries(raw: Any, limit: int | None=...) -> list[ActiveSkillEntry]` +- `list_skill_catalog(project_name: str=..., agent: Agent | None=...) -> list[CatalogSkill]` +- `get_scope_active_skills(agent: Agent | None) -> list[ActiveSkillEntry]` +- `get_scope_hidden_skills(agent: Agent | None) -> list[ActiveSkillEntry]` +- `get_chat_active_skills(context: Any | None) -> list[ActiveSkillEntry]` +- `get_chat_disabled_skills(context: Any | None) -> list[ActiveSkillEntry]` +- `get_loaded_skill_names(agent: Agent | None) -> list[str]` +- `set_loaded_skill_names(agent: Agent | None, skill_names: Any) -> list[str]` +- `add_loaded_skill_name(agent: Agent | None, skill_name: str, limit: int | None=...) -> list[str]` +- Notable constants/configuration names: `MAX_ACTIVE_SKILLS`, `ACTIVE_SKILLS_PLUGIN_NAME`, `AGENT_DATA_NAME_LOADED_SKILLS`, `CONTEXT_DATA_NAME_LOADED_SKILLS`, `CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS`, `CONTEXT_DATA_NAME_CHAT_DISABLED_SKILLS`, `CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS`, `_NAME_RE`. + +## 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. +- 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`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `dataclass`, `re.compile`, `field`, `Path`, `root.rglob`, `results.sort`, `re.sub`, `path.read_text`, `text.splitlines`, `join.strip`, `parse_frontmatter`, `frontmatter_text.splitlines`, `_parse_frontmatter_fallback`, `split_frontmatter`, `str.strip`, `_coerce_list`, `Skill`, `get_skill_roots`, `_filter_hidden_skills`, `files.get_abs_path`. +- 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_document_query_plugin.py` + - `tests/test_fasta2a_client.py` + - `tests/test_office_canvas_setup.py` + - `tests/test_office_document_store.py` + - `tests/test_skills_runtime.py` + - `tests/test_time_travel.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/skills_cli.py.dox.md b/helpers/skills_cli.py.dox.md new file mode 100644 index 000000000..02aa7922d --- /dev/null +++ b/helpers/skills_cli.py.dox.md @@ -0,0 +1,51 @@ +# skills_cli.py DOX + +## Purpose + +- Own the `skills_cli.py` helper module. +- This module provides command-line skill listing, search, validation, and creation helpers. +- Keep this file-level DOX profile synchronized with `skills_cli.py` because this directory is intentionally flat. + +## Ownership + +- `skills_cli.py` owns the runtime implementation. +- `skills_cli.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Skill` (no explicit base class) +- Top-level functions: +- `get_skills_dirs() -> List[Path]`: Get all skill directories +- `parse_skill_file(skill_path: Path) -> Optional[Skill]`: Parse a SKILL.md file and return a Skill object +- `list_skills() -> List[Skill]`: List all available skills +- `find_skill(name: str) -> Optional[Skill]`: Find a skill by name +- `search_skills(query: str) -> List[Skill]`: Search skills by name, description, or tags +- `validate_skill(skill: Skill) -> List[str]`: Validate a skill and return list of issues +- `create_skill(name: str, description: str=..., author: str=...) -> Path`: Create a new skill from template +- `print_skill_table(skills: List[Skill])`: Print skills in a formatted table +- `main()` + +## 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, secret handling. +- Imported dependency areas include: `argparse`, `dataclasses`, `datetime`, `helpers`, `os`, `pathlib`, `re`, `sys`, `typing`, `yaml`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `sys.path.insert`, `field`, `Path`, `get_skills_dirs`, `list_skills`, `query.lower`, `exists`, `custom_dir.mkdir`, `skill_dir.exists`, `skill_dir.mkdir`, `mkdir`, `skill_file.write_text`, `readme.write_text`, `argparse.ArgumentParser`, `parser.add_subparsers`, `subparsers.add_parser`, `list_parser.add_argument`, `create_parser.add_argument`, `show_parser.add_argument`, `validate_parser.add_argument`. +- 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/skills_import.py b/helpers/skills_import.py index c73ffa55d..a043dcf3f 100644 --- a/helpers/skills_import.py +++ b/helpers/skills_import.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import shutil +import stat import tempfile import time import zipfile @@ -82,25 +83,60 @@ def _candidate_skill_roots(source_dir: Path) -> List[Path]: return unique or [source_dir] +def _safe_extract_zip(zip_path: Path, target: Path) -> None: + with zipfile.ZipFile(zip_path, "r") as archive: + for member in archive.infolist(): + name = member.filename + if not name or name.startswith(("/", "\\")) or "\\" in name: + raise ValueError(f"Unsafe zip entry path: {name!r}") + + mode = member.external_attr >> 16 + if stat.S_ISLNK(mode): + raise ValueError(f"Refusing to extract symlink from zip: {name!r}") + + destination = target / name + if not _is_within(destination, target): + raise ValueError(f"Unsafe zip entry path: {name!r}") + + archive.extractall(target) + + +def extract_skills_zip( + zip_path: Path, + *, + tmp_subdir: str = "skill_imports", + prefix: str = "import", +) -> tuple[Path, Path]: + """ + Extract a zip into a temp folder inside Agent Zero's tmp directory. + Returns (source_root, cleanup_root). + """ + base_tmp = Path(files.get_abs_path("tmp", tmp_subdir)) + base_tmp.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d_%H%M%S") + target = base_tmp / f"{prefix}_{zip_path.stem}_{stamp}" + target.mkdir(parents=True, exist_ok=True) + + try: + _safe_extract_zip(zip_path, target) + except Exception: + shutil.rmtree(target, ignore_errors=True) + raise + + # If zip contains a single top-level folder, treat that as the root + children = [p for p in target.iterdir()] + if len(children) == 1 and children[0].is_dir(): + return children[0], target + return target, target + + def _unzip_to_temp_dir(zip_path: Path) -> Path: """ Extract a zip into a temp folder under tmp/skill_imports (inside Agent Zero base dir). Returns the extraction root folder. """ - base_tmp = Path(files.get_abs_path("tmp", "skill_imports")) - base_tmp.mkdir(parents=True, exist_ok=True) - stamp = time.strftime("%Y%m%d_%H%M%S") - target = base_tmp / f"import_{zip_path.stem}_{stamp}" - target.mkdir(parents=True, exist_ok=True) - - with zipfile.ZipFile(zip_path, "r") as z: - z.extractall(target) - - # If zip contains a single top-level folder, treat that as the root - children = [p for p in target.iterdir()] - if len(children) == 1 and children[0].is_dir(): - return children[0] - return target + source_root, _cleanup_root = extract_skills_zip(zip_path) + return source_root def build_import_plan( @@ -261,4 +297,3 @@ def import_skills( destination_root=dest_root, namespace=ns, ) - diff --git a/helpers/skills_import.py.dox.md b/helpers/skills_import.py.dox.md new file mode 100644 index 000000000..e6bc0493e --- /dev/null +++ b/helpers/skills_import.py.dox.md @@ -0,0 +1,58 @@ +# skills_import.py DOX + +## Purpose + +- Own the `skills_import.py` helper module. +- This module plans and imports skill bundles into user, project, or profile scopes. +- Keep this file-level DOX profile synchronized with `skills_import.py` because this directory is intentionally flat. + +## Ownership + +- `skills_import.py` owns the runtime implementation. +- `skills_import.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ImportPlanItem` (no explicit base class) +- `ImportResult` (no explicit base class) +- Top-level functions: +- `_is_within(child: Path, parent: Path) -> bool` +- `_derive_namespace(source: Path) -> str` +- `_candidate_skill_roots(source_dir: Path) -> List[Path]`: Heuristics to find likely skill roots inside a repo/pack: +- `_safe_extract_zip(zip_path: Path, target: Path) -> None` +- `extract_skills_zip(zip_path: Path, tmp_subdir: str=..., prefix: str=...) -> tuple[Path, Path]`: Extract a zip into a temp folder inside Agent Zero's tmp directory and return the scan/import root plus cleanup root. +- `_unzip_to_temp_dir(zip_path: Path) -> Path`: Extract a zip into a temp folder under tmp/skill_imports (inside Agent Zero base dir). +- `build_import_plan(source: Path, dest_root: Path, namespace: Optional[str]=...) -> Tuple[List[ImportPlanItem], Path]`: Build a copy plan for importing skills from a source folder. +- `_resolve_conflict(dest: Path, policy: ConflictPolicy) -> Tuple[Path, bool]`: Returns (final_dest_path, should_copy). +- `get_project_skills_folder(project_name: str) -> Path`: Get the skills folder path for a project. +- `get_agent_profile_skills_folder(profile_name: str) -> Path` +- `get_project_agent_profile_skills_folder(project_name: str, profile_name: str) -> Path` +- `resolve_skills_destination_root(project_name: Optional[str], agent_profile: Optional[str]) -> Path` +- `import_skills(source_path: str, namespace: Optional[str]=..., conflict: ConflictPolicy=..., dry_run: bool=..., project_name: Optional[str]=..., agent_profile: Optional[str]=...) -> ImportResult`: Import external Skills into usr/skills//... +- Notable constants/configuration names: `PROJECT_SKILLS_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, plugin state. +- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `helpers.skills`, `os`, `pathlib`, `shutil`, `stat`, `tempfile`, `time`, `typing`, `zipfile`. + +## Key Concepts + +- Zip extraction rejects absolute paths, backslash paths, path traversal, and symlink entries before extraction. +- Important called helpers/classes observed in the source: `dataclass`, `strip`, `plugins.is_dir`, `Path`, `base_tmp.mkdir`, `time.strftime`, `target.mkdir`, `_candidate_skill_roots`, `Path.expanduser`, `resolve_skills_destination_root`, `dest_root.mkdir`, `build_import_plan`, `ImportResult`, `child.resolve.relative_to`, `direct.is_dir`, `discover_skill_md_files`, `plugins.iterdir`, `files.get_abs_path`, `zipfile.ZipFile`, `archive.extractall`, `shutil.rmtree`. +- 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/state_migration.py.dox.md b/helpers/state_migration.py.dox.md new file mode 100644 index 000000000..9af4faa8c --- /dev/null +++ b/helpers/state_migration.py.dox.md @@ -0,0 +1,45 @@ +# state_migration.py DOX + +## Purpose + +- Own the `state_migration.py` helper module. +- This module moves retired state tree paths to current runtime locations. +- Keep this file-level DOX profile synchronized with `state_migration.py` because this directory is intentionally flat. + +## Ownership + +- `state_migration.py` owns the runtime implementation. +- `state_migration.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `migrate_retired_state_tree(source: Path, destination: Path, owner: str, migrated: list[str], warnings: list[str], errors: list[str]) -> None`: Move retired plugin state into its plugin-owned state directory. +- `_move_path(source: Path, target: Path, migrated: list[str]) -> None` +- `_next_conflict_path(path: Path) -> Path` +- `_remove_empty_dir(path: Path, owner: str, warnings: list[str]) -> None` +- `_same_path(left: Path, right: Path) -> 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: filesystem reads, filesystem writes, plugin state, settings/state persistence. +- Imported dependency areas include: `__future__`, `pathlib`, `shutil`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `_same_path`, `final_target.parent.mkdir`, `shutil.move`, `path.with_name`, `_move_path`, `source.is_dir`, `target.is_dir`, `source.rmdir`, `target.exists`, `target.is_symlink`, `_next_conflict_path`, `candidate.exists`, `candidate.is_symlink`, `path.rmdir`, `source.exists`, `source.is_symlink`, `destination.mkdir`, `_remove_empty_dir`, `source.iterdir`, `left.resolve`. +- 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/state_monitor.py.dox.md b/helpers/state_monitor.py.dox.md new file mode 100644 index 000000000..a84115652 --- /dev/null +++ b/helpers/state_monitor.py.dox.md @@ -0,0 +1,59 @@ +# state_monitor.py DOX + +## Purpose + +- Own the `state_monitor.py` helper module. +- This module tracks dirty state and connection projections for WebUI sync. +- Keep this file-level DOX profile synchronized with `state_monitor.py` because this directory is intentionally flat. + +## Ownership + +- `state_monitor.py` owns the runtime implementation. +- `state_monitor.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ConnectionProjection` (no explicit base class) +- `StateMonitor` (no explicit base class) + - `bind_manager(self, manager: 'WsManager', handler_id: str | None=...) -> None` + - `register_sid(self, namespace: str, sid: str) -> None` + - `unregister_sid(self, namespace: str, sid: str) -> None` + - `mark_dirty_all(self, reason: str | None=...) -> None` + - `mark_dirty_for_context(self, context_id: str, reason: str | None=...) -> None` + - `update_projection(self, namespace: str, sid: str, request: StateRequestV1, seq_base: int) -> None` + - `mark_dirty(self, namespace: str, sid: str, reason: str | None=..., wave_id: str | None=...) -> None` +- Top-level functions: +- `get_state_monitor() -> StateMonitor` +- `_reset_state_monitor_for_testing() -> None` +- Notable constants/configuration names: `_STATE_MONITOR_HOLDER`, `_STATE_MONITOR_LOCK`. + +## 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, WebSocket state, settings/state persistence, scheduler state. +- Imported dependency areas include: `__future__`, `asyncio`, `dataclasses`, `helpers`, `helpers.print_style`, `helpers.state_snapshot`, `helpers.ws`, `helpers.ws_manager`, `os`, `threading`, `time`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `threading.RLock`, `field`, `ws_debug`, `_ws_debug_enabled`, `context_id.strip`, `loop.call_soon_threadsafe`, `self._schedule_debounce_on_loop`, `asyncio.get_running_loop`, `asyncio.current_task`, `loop.is_closed`, `self._debounce_handles.pop`, `self._push_tasks.pop`, `self._projections.pop`, `self.mark_dirty`, `self._mark_dirty_on_loop`, `runtime.is_development`, `loop.call_later`, `StateMonitor`, `ConnectionProjection`, `handle.cancel`. +- 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_model_config_api_keys.py` + - `tests/test_multi_tab_isolation.py` + - `tests/test_state_monitor.py` + - `tests/test_state_sync_handler.py` + - `tests/test_state_sync_welcome_screen.py` + - `tests/test_ws_handlers.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/state_monitor_integration.py.dox.md b/helpers/state_monitor_integration.py.dox.md new file mode 100644 index 000000000..15c7e3530 --- /dev/null +++ b/helpers/state_monitor_integration.py.dox.md @@ -0,0 +1,43 @@ +# state_monitor_integration.py DOX + +## Purpose + +- Own the `state_monitor_integration.py` helper module. +- This module bridges dirty-state calls into the shared state monitor. +- Keep this file-level DOX profile synchronized with `state_monitor_integration.py` because this directory is intentionally flat. + +## Ownership + +- `state_monitor_integration.py` owns the runtime implementation. +- `state_monitor_integration.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `mark_dirty_all(reason: str | None=...) -> None` +- `mark_dirty_for_context(context_id: str, reason: 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: settings/state persistence. +- Imported dependency areas include: `__future__`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `get_state_monitor.mark_dirty_all`, `get_state_monitor.mark_dirty_for_context`, `get_state_monitor`. +- 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_model_config_api_keys.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/state_snapshot.py b/helpers/state_snapshot.py index a51f6239e..9a1db075d 100644 --- a/helpers/state_snapshot.py +++ b/helpers/state_snapshot.py @@ -140,6 +140,20 @@ def _apply_agent_profile_metadata( context_data["agent_profile_label"] = labels.get(profile, profile) if profile else "" +def _prune_missing_saved_contexts() -> None: + from helpers import persist_chat + + saved_ids = persist_chat.saved_chat_ids() + for ctx in AgentContext.all(): + if ctx.type == AgentContextType.BACKGROUND or ctx.is_running(): + continue + if ( + ctx.data.get(persist_chat.SAVED_CHAT_CONTEXT_DATA_KEY) + and ctx.id not in saved_ids + ): + AgentContext.remove(ctx.id) + + def parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1: context = payload.get("context") log_from = payload.get("log_from") @@ -255,6 +269,8 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1: from_no = _coerce_non_negative_int(request.log_from, default=0) notifications_from_no = _coerce_non_negative_int(request.notifications_from, default=0) + _prune_missing_saved_contexts() + active_context = AgentContext.get(ctxid) if ctxid else None if active_context: diff --git a/helpers/state_snapshot.py.dox.md b/helpers/state_snapshot.py.dox.md new file mode 100644 index 000000000..69fccccf2 --- /dev/null +++ b/helpers/state_snapshot.py.dox.md @@ -0,0 +1,65 @@ +# state_snapshot.py DOX + +## Purpose + +- Own the `state_snapshot.py` helper module. +- This module builds and validates typed WebUI state snapshots. +- Keep this file-level DOX profile synchronized with `state_snapshot.py` because this directory is intentionally flat. + +## Ownership + +- `state_snapshot.py` owns the runtime implementation. +- `state_snapshot.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SnapshotV1` (`TypedDict`) +- `StateRequestV1` (no explicit base class) +- `StateRequestValidationError` (`ValueError`) +- Top-level functions: +- `_annotation_to_isinstance_types(annotation: Any) -> tuple[type, ...]`: Convert type annotation to tuple suitable for isinstance(). +- `_build_schema_from_typeddict(td: type) -> dict[str, tuple[type, ...]]`: Extract field names and isinstance-compatible types from TypedDict. +- `validate_snapshot_schema_v1(snapshot: Mapping[str, Any]) -> None` +- `_coerce_non_negative_int(value: Any, default: int=...) -> int` +- `_get_agent_profile_labels() -> dict[str, str]` +- `_apply_agent_profile_metadata(context_data: dict[str, Any], ctx: AgentContext, labels: dict[str, str]) -> None` +- `_prune_missing_saved_contexts() -> None` +- `parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1` +- `_coerce_state_request_inputs(context: Any, log_from: Any, notifications_from: Any, timezone: Any) -> StateRequestV1` +- `advance_state_request_after_snapshot(request: StateRequestV1, snapshot: Mapping[str, Any]) -> StateRequestV1` +- `async build_snapshot_from_request(request: StateRequestV1) -> SnapshotV1`: Build a poll-shaped snapshot for both /poll and state_push. +- `_notify_timezone_changed(previous_timezone: str, current_timezone: str) -> None` +- `async build_snapshot(context: str | None, log_from: int, notifications_from: int, timezone: str | None) -> SnapshotV1` +- Notable constants/configuration names: `_SNAPSHOT_V1_SCHEMA`, `SNAPSHOT_SCHEMA_V1_KEYS`. + +## 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, plugin state, settings/state persistence, secret handling, scheduler state, in-memory context removal. +- Imported dependency areas include: `__future__`, `agent`, `dataclasses`, `helpers.dotenv`, `helpers.localization`, `helpers.persist_chat`, `helpers.task_scheduler`, `pytz`, `types`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `dataclass`, `_build_schema_from_typeddict`, `get_origin`, `timezone.strip`, `StateRequestV1`, `localization.get_timezone`, `localization.set_timezone`, `ctxid.strip`, `_coerce_non_negative_int`, `AgentContext.get_notification_manager`, `notification_manager.output`, `_get_agent_profile_labels`, `ctxs.sort`, `tasks.sort`, `validate_snapshot_schema_v1`, `_coerce_state_request_inputs`, `super.__init__`, `get_args`, `_annotation_to_isinstance_types`, `TypeError`. +- Snapshot building prunes non-running in-memory contexts that were previously saved but no longer have a `chat.json`, preventing stale sidebar rows after chat files are deleted outside `/chat_remove`. +- 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_multi_tab_isolation.py` + - `tests/test_snapshot_parity.py` + - `tests/test_snapshot_schema_v1.py` + - `tests/test_state_monitor.py` + - `tests/test_state_sync_handler.py` + - `tests/test_state_sync_welcome_screen.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/strings.py.dox.md b/helpers/strings.py.dox.md new file mode 100644 index 000000000..ccc055650 --- /dev/null +++ b/helpers/strings.py.dox.md @@ -0,0 +1,49 @@ +# strings.py DOX + +## Purpose + +- Own the `strings.py` helper module. +- This module sanitizes, formats, truncates, and expands framework string content. +- Keep this file-level DOX profile synchronized with `strings.py` because this directory is intentionally flat. + +## Ownership + +- `strings.py` owns the runtime implementation. +- `strings.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `sanitize_string(s: str, encoding: str=...) -> str` +- `calculate_valid_match_lengths(first: bytes | str, second: bytes | str, deviation_threshold: int=..., deviation_reset: int=..., ignore_patterns: list[bytes | str]=..., debug: bool=...) -> tuple[int, int]` +- `format_key(key: str) -> str`: Format a key string to be more readable. +- `dict_to_text(d: dict) -> str` +- `truncate_text(text: str, length: int, at_end: bool=..., replacement: str=...) -> str` +- `truncate_text_by_ratio(text: str, threshold: int, replacement: str=..., ratio: float=...) -> str`: Truncate text with replacement at a specified ratio position. +- `replace_file_includes(text: str, placeholder_pattern: str=...) -> 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 deletion. +- Imported dependency areas include: `helpers`, `re`, `sys`, `time`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `s.encode.decode`, `join`, `join.rstrip`, `re.sub`, `skip_ignored_patterns`, `match.group`, `s.encode`, `sys.stdout.write`, `sys.stdout.flush`, `time.sleep`, `c.isupper`, `result.islower`, `word.capitalize`, `files.fix_dev_path`, `files.read_file`, `re.match`, `formatted.split`, `c.isalnum`, `format_key`. +- 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_model_search.py` + - `tests/test_whatsapp_number_utils.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/subagents.py b/helpers/subagents.py index f3557a49e..df9d5ae20 100644 --- a/helpers/subagents.py +++ b/helpers/subagents.py @@ -433,4 +433,4 @@ def get_paths( # end-of-file imports to prevent circular imports -from helpers import plugins \ No newline at end of file +from helpers import plugins diff --git a/helpers/subagents.py.dox.md b/helpers/subagents.py.dox.md new file mode 100644 index 000000000..9b954ff80 --- /dev/null +++ b/helpers/subagents.py.dox.md @@ -0,0 +1,61 @@ +# subagents.py DOX + +## Purpose + +- Own the `subagents.py` helper module. +- This module discovers, merges, loads, and saves agent profile definitions. +- Keep this file-level DOX profile synchronized with `subagents.py` because this directory is intentionally flat. + +## Ownership + +- `subagents.py` owns the runtime implementation. +- `subagents.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `SubAgentListItem` (`BaseModel`) + - `post_validator(self)` +- `SubAgent` (`SubAgentListItem`) +- Top-level functions: +- `get_agents_list(project_name: str | None=...) -> list[SubAgentListItem]` +- `get_agents_dict(project_name: str | None=...) -> dict[str, SubAgentListItem]` +- `_get_agents_list_from_dir(dir: str, origin: Origin) -> dict[str, SubAgentListItem]` +- `load_agent_data(name: str, project_name: str | None=...) -> SubAgent` +- `save_agent_data(name: str, subagent: SubAgent) -> None` +- `delete_agent_data(name: str) -> None` +- `_load_agent_data_from_dir(dir: str, name: str, origin: Origin) -> SubAgent | None` +- `_merge_agents(base: SubAgent | None, override: SubAgent | None) -> SubAgent | None` +- `_merge_agent_list_items(base: SubAgentListItem, override: SubAgentListItem) -> SubAgentListItem` +- `get_agents_roots() -> list[str]` +- `get_all_agents_list() -> list[dict[str, str]]` +- `_merge_origins(base: list[Origin], override: list[Origin]) -> list[Origin]` +- `get_default_promp_file_names() -> list[str]` +- `get_available_agents_dict(project_name: str | None) -> dict[str, SubAgentListItem]` +- `get_paths(agent: 'Agent|None', *subpaths, must_exist_completely: bool=..., include_project: bool=..., include_user: bool=..., include_default: bool=..., include_plugins: bool=..., default_root: str=...) -> list[str]`: Returns list of file paths for the given agent and subpaths, searched in order of priority: +- Notable constants/configuration names: `GLOBAL_DIR`, `USER_DIR`, `DEFAULT_AGENTS_DIR`, `USER_AGENTS_DIR`, `PATHS_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. +- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence. +- Imported dependency areas include: `helpers`, `json`, `os`, `pydantic`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `cache.toggle_area`, `model_validator`, `_get_agents_list_from_dir`, `plugins.get_enabled_plugin_paths`, `_merge_agent_dicts`, `files.get_subdirectories`, `_load_agent_data_from_dir`, `_merge_agent`, `files.write_file`, `files.delete_dir`, `SubAgent`, `SubAgentListItem`, `files.find_existing_paths_by_pattern`, `get_agents_roots`, `files.list_files`, `get_agents_dict`, `cache.determine_cache_key`, `cache.add`, `projects.get_project_meta`, `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_skills_runtime.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/system_packages.py.dox.md b/helpers/system_packages.py.dox.md new file mode 100644 index 000000000..f1078d3b7 --- /dev/null +++ b/helpers/system_packages.py.dox.md @@ -0,0 +1,44 @@ +# system_packages.py DOX + +## Purpose + +- Own the `system_packages.py` helper module. +- This module runs apt operations with retry behavior. +- Keep this file-level DOX profile synchronized with `system_packages.py` because this directory is intentionally flat. + +## Ownership + +- `system_packages.py` owns the runtime implementation. +- `system_packages.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `run_apt_with_retries(runner: Callable[[], subprocess.CompletedProcess[str]], lock_timeout_seconds: int=..., retry_seconds: int=...) -> subprocess.CompletedProcess[str]`: Run an apt/dpkg command, serializing in-process callers and waiting out apt locks. +- `is_apt_lock_error(result: subprocess.CompletedProcess[str]) -> bool` +- Notable constants/configuration names: `APT_LOCK_TIMEOUT_SECONDS`, `APT_LOCK_RETRY_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: subprocess/runtime control. +- Imported dependency areas include: `__future__`, `subprocess`, `threading`, `time`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `threading.RLock`, `lower`, `time.monotonic`, `runner`, `time.sleep`, `is_apt_lock_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_office_document_store.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/tailscale_tunnel.py.dox.md b/helpers/tailscale_tunnel.py.dox.md new file mode 100644 index 000000000..90afd506e --- /dev/null +++ b/helpers/tailscale_tunnel.py.dox.md @@ -0,0 +1,64 @@ +# tailscale_tunnel.py DOX + +## Purpose + +- Own the `tailscale_tunnel.py` helper module. +- This module implements Tailscale tunnel install and provider lifecycle behavior. +- Keep this file-level DOX profile synchronized with `tailscale_tunnel.py` because this directory is intentionally flat. + +## Ownership + +- `tailscale_tunnel.py` owns the runtime implementation. +- `tailscale_tunnel.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `TailscaleTunnel` (`CliTunnelHelper`) + - `stop(self)` +- Top-level functions: +- `tailscale_arch()` +- `tailscale_archive_url()` +- `install_tailscale(notify=...)` +- `resolve_tailscaled_binary(binary_path)` +- `notify_info(notify, message, data=...)` +- `tailscale_socket_args(socket_path=...)` +- `tailscale_command(binary_path, args, socket_path=...)` +- `tailscale_status(binary_path, socket_path=...)` +- `tailscale_funnel_help(binary_path, socket_path=...)` +- `compact_output(lines)` +- `tailscale_daemon_hint(output)` +- `tailscale_daemon_ready(binary_path, socket_path)` +- `read_recent_tailscaled_log()` +- `start_tailscaled(binary_path, notify=...)` +- `stop_managed_tailscaled()` +- `tailscale_up_failure_message(output, timed_out=...)` +- `run_tailscale_up(binary_path, notify=..., timeout=..., socket_path=...)` +- `ensure_tailscale_funnel_command(binary_path, socket_path=...)` +- `ensure_tailscale_ready(binary_path, notify=...)` +- Notable constants/configuration names: `TAILSCALE_URL_RE`, `TAILSCALE_LOGIN_URL_RE`, `TAILSCALE_STABLE_PACKAGES_URL`, `TAILSCALE_UP_TIMEOUT`, `TAILSCALE_FUNNEL_TIMEOUT`, `TAILSCALE_FUNNEL_HTTPS_PORT`, `TAILSCALE_DAEMON_START_TIMEOUT`, `TAILSCALE_RUNTIME_DIR`, `TAILSCALE_STATE_DIR`, `TAILSCALE_SOCKET_PATH`, `TAILSCALE_DAEMON_LOG_PATH`, `TAILSCALE_DAEMON_PID_PATH`. + +## 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, WebSocket state, settings/state persistence, tunnel state. +- Imported dependency areas include: `collections`, `flaredantic`, `helpers`, `helpers.cli_tunnel`, `json`, `os`, `pathlib`, `queue`, `re`, `shutil`, `subprocess`, `threading`, `time`, `urllib.parse`, `urllib.request`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `re.compile`, `Path`, `files.get_abs_path`, `cli_tunnel.platform_parts`, `tailscale_arch`, `pattern.search`, `urllib.parse.urljoin`, `shutil.which`, `tailscale_archive_url`, `cli_tunnel.download_file`, `Path.with_name`, `sibling.exists`, `callable`, `subprocess.run`, `join`, `output.lower`, `tailscale_status`, `compact_output`, `resolve_tailscaled_binary`, `tailscale_daemon_ready`. +- 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/task_scheduler.py.dox.md b/helpers/task_scheduler.py.dox.md new file mode 100644 index 000000000..7292b4faf --- /dev/null +++ b/helpers/task_scheduler.py.dox.md @@ -0,0 +1,112 @@ +# task_scheduler.py DOX + +## Purpose + +- Own the `task_scheduler.py` helper module. +- This module models, serializes, schedules, runs, and persists scheduled tasks. +- Keep this file-level DOX profile synchronized with `task_scheduler.py` because this directory is intentionally flat. + +## Ownership + +- `task_scheduler.py` owns the runtime implementation. +- `task_scheduler.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `TaskState` (`str`, `Enum`) +- `TaskType` (`str`, `Enum`) +- `TaskSchedule` (`BaseModel`) + - `to_crontab(self) -> str` +- `TaskPlan` (`BaseModel`) + - `create(cls, todo: list[datetime] | None=..., in_progress: datetime | None=..., done: list[datetime] | None=...)` + - `add_todo(self, launch_time: datetime)` + - `set_in_progress(self, launch_time: datetime)` + - `set_done(self, launch_time: datetime)` + - `get_next_launch_time(self) -> datetime | None` + - `should_launch(self) -> datetime | None` +- `BaseTask` (`BaseModel`) + - `update(self, name: str | None=..., state: TaskState | None=..., system_prompt: str | None=..., prompt: str | None=..., attachments: list[str] | None=..., last_run: datetime | None=..., last_result: str | None=..., context_id: str | None=..., **kwargs)` + - `check_schedule(self, frequency_seconds: float=...) -> bool` + - `get_next_run(self) -> datetime | None` + - `is_dedicated(self) -> bool` + - `get_next_run_minutes(self) -> int | None` + - `async on_run(self)` + - `async on_finish(self)` + - `async on_error(self, error: str)` +- `AdHocTask` (`BaseTask`) + - `create(cls, name: str, system_prompt: str, prompt: str, token: str, attachments: list[str] | None=..., context_id: str | None=..., project_name: str | None=..., project_color: str | None=...)` + - `update(self, name: str | None=..., state: TaskState | None=..., system_prompt: str | None=..., prompt: str | None=..., attachments: list[str] | None=..., last_run: datetime | None=..., last_result: str | None=..., context_id: str | None=..., token: str | None=..., **kwargs)` +- `ScheduledTask` (`BaseTask`) + - `create(cls, name: str, system_prompt: str, prompt: str, schedule: TaskSchedule, attachments: list[str] | None=..., context_id: str | None=..., timezone: str | None=..., project_name: str | None=..., project_color: str | None=...)` + - `update(self, name: str | None=..., state: TaskState | None=..., system_prompt: str | None=..., prompt: str | None=..., attachments: list[str] | None=..., last_run: datetime | None=..., last_result: str | None=..., context_id: str | None=..., schedule: TaskSchedule | None=..., **kwargs)` + - `check_schedule(self, frequency_seconds: float=...) -> bool` + - `get_next_run(self) -> datetime | None` +- `PlannedTask` (`BaseTask`) + - `create(cls, name: str, system_prompt: str, prompt: str, plan: TaskPlan, attachments: list[str] | None=..., context_id: str | None=..., project_name: str | None=..., project_color: str | None=...)` + - `update(self, name: str | None=..., state: TaskState | None=..., system_prompt: str | None=..., prompt: str | None=..., attachments: list[str] | None=..., last_run: datetime | None=..., last_result: str | None=..., context_id: str | None=..., plan: TaskPlan | None=..., **kwargs)` + - `check_schedule(self, frequency_seconds: float=...) -> bool` + - `get_next_run(self) -> datetime | None` + - `async on_run(self)` + - `async on_finish(self)` + - `async on_success(self, result: str)` + - `async on_error(self, error: str)` +- `SchedulerTaskList` (`BaseModel`) + - `get(cls) -> 'SchedulerTaskList'` + - `async reload(self) -> 'SchedulerTaskList'` + - `async add_task(self, task: Union[ScheduledTask, AdHocTask, PlannedTask]) -> 'SchedulerTaskList'` + - `async save(self) -> 'SchedulerTaskList'` + - `async update_task_by_uuid(self, task_uuid: str, updater_func: Callable[[Union[ScheduledTask, AdHocTask, PlannedTask]], None], verify_func: Callable[[Union[ScheduledTask, AdHocTask, PlannedTask]], bool]=...) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None` + - `get_tasks(self) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]` + - `get_tasks_by_context_id(self, context_id: str, only_running: bool=...) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]` + - `async get_due_tasks(self) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]` +- `TaskScheduler` (no explicit base class) + - `get(cls) -> 'TaskScheduler'` + - `cancel_running_task(self, task_uuid: str, terminate_thread: bool=...) -> bool` + - `cancel_tasks_by_context(self, context_id: str, terminate_thread: bool=...) -> bool` + - `async reload(self)` + - `get_tasks(self) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]` + - `get_tasks_by_context_id(self, context_id: str, only_running: bool=...) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]` + - `async add_task(self, task: Union[ScheduledTask, AdHocTask, PlannedTask]) -> 'TaskScheduler'` + - `async remove_task_by_uuid(self, task_uuid: str) -> 'TaskScheduler'` +- Top-level functions: +- `normalize_schedule_timezone(timezone_name: str | None) -> str` +- `_now() -> datetime` +- `_localize_task_datetime(dt: datetime) -> datetime` +- `serialize_datetime(dt: Optional[datetime]) -> Optional[str]`: Serialize a datetime object to ISO format string in the user's timezone. +- `parse_datetime(dt_str: Optional[str]) -> Optional[datetime]`: Parse ISO format datetime string with timezone awareness. +- `serialize_task_schedule(schedule: TaskSchedule) -> Dict[str, str]`: Convert TaskSchedule to a standardized dictionary format. +- `parse_task_schedule(schedule_data: Dict[str, str]) -> TaskSchedule`: Parse dictionary into TaskSchedule with validation. +- `serialize_task_plan(plan: TaskPlan) -> Dict[str, Any]`: Convert TaskPlan to a standardized dictionary format. +- `parse_task_plan(plan_data: Dict[str, Any]) -> TaskPlan`: Parse dictionary into TaskPlan with validation. +- `serialize_task(task: Union[ScheduledTask, AdHocTask, PlannedTask]) -> Dict[str, Any]`: Standardized serialization for task objects with proper handling of all complex types. +- `serialize_tasks(tasks: list[Union[ScheduledTask, AdHocTask, PlannedTask]]) -> list[Dict[str, Any]]`: Serialize a list of tasks to a list of dictionaries. +- `deserialize_task(task_data: Dict[str, Any], task_class: Optional[Type[T]]=...) -> T`: Deserialize dictionary into appropriate task object with validation. +- Notable constants/configuration names: `SCHEDULER_FOLDER`, `LOCAL_TIMEZONE_ALIASES`, `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 reads, filesystem writes, filesystem deletion, network calls, settings/state persistence, secret handling, scheduler state. +- Imported dependency areas include: `agent`, `asyncio`, `crontab`, `datetime`, `enum`, `helpers`, `helpers.defer`, `helpers.files`, `helpers.localization`, `helpers.persist_chat`, `helpers.print_style`, `initialize`, `nest_asyncio`, `os`, `os.path`, `pydantic`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `nest_asyncio.apply`, `TypeVar`, `str.strip`, `callable`, `datetime.now`, `tzinfo.localize`, `Field`, `PrivateAttr`, `Localization.get.serialize_datetime`, `normalize_schedule_timezone`, `Localization.get.get_timezone`, `pytz.timezone`, `now`, `localize`, `cls`, `_localize_task_datetime`, `self.todo.remove`, `self.get_next_launch_time`, `super.__init__`, `threading.RLock`. +- 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_task_scheduler_timezone.py` + - `tests/test_timezone_regressions.py` + - `tests/test_tool_action_contracts.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/timed_input.py.dox.md b/helpers/timed_input.py.dox.md new file mode 100644 index 000000000..5bcc3ce95 --- /dev/null +++ b/helpers/timed_input.py.dox.md @@ -0,0 +1,40 @@ +# timed_input.py DOX + +## Purpose + +- Own the `timed_input.py` helper module. +- This module reads terminal input with timeout handling. +- Keep this file-level DOX profile synchronized with `timed_input.py` because this directory is intentionally flat. + +## Ownership + +- `timed_input.py` owns the runtime implementation. +- `timed_input.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `timeout_input(prompt, timeout=...)` + +## 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: `inputimeout`, `sys`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `inputimeout`. +- 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/tokens.py.dox.md b/helpers/tokens.py.dox.md new file mode 100644 index 000000000..c42637a8e --- /dev/null +++ b/helpers/tokens.py.dox.md @@ -0,0 +1,54 @@ +# tokens.py DOX + +## Purpose + +- Own the `tokens.py` helper module. +- This module counts and approximates tokens and trims text to budgets. +- Keep this file-level DOX profile synchronized with `tokens.py` because this directory is intentionally flat. + +## Ownership + +- `tokens.py` owns the runtime implementation. +- `tokens.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `count_tokens(text: str, encoding_name=...) -> int` +- `approximate_tokens(text: str) -> int` +- `sanitize_embedded_image_data_urls(text: str) -> str` +- `approximate_prompt_tokens(text: str) -> int` +- `trim_to_tokens(text: str, max_tokens: int, direction: Literal['start', 'end'], ellipsis: str=...) -> str` +- Notable constants/configuration names: `APPROX_BUFFER`, `TRIM_BUFFER`, `EMBEDDED_IMAGE_DATA_PLACEHOLDER`, `_EMBEDDED_IMAGE_DATA_URL_PATTERN`. + +## 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: `re`, `tiktoken`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `re.compile`, `tiktoken.get_encoding`, `encoding.encode`, `_EMBEDDED_IMAGE_DATA_URL_PATTERN.sub`, `approximate_tokens`, `count_tokens`, `sanitize_embedded_image_data_urls`. +- 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_chat_compaction.py` + - `tests/test_default_prompt_budget.py` + - `tests/test_history_compression_wait.py` + - `tests/test_mcp_handler_multimodal.py` + - `tests/test_oauth_codex.py` + - `tests/test_oauth_gemini_api.py` + - `tests/test_oauth_xai_grok.py` + - `tests/test_ws_security.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/tool.py.dox.md b/helpers/tool.py.dox.md new file mode 100644 index 000000000..5577d7fbc --- /dev/null +++ b/helpers/tool.py.dox.md @@ -0,0 +1,58 @@ +# tool.py DOX + +## Purpose + +- Own the `tool.py` helper module. +- This module defines the base agent tool class and response contract. +- Keep this file-level DOX profile synchronized with `tool.py` because this directory is intentionally flat. + +## Ownership + +- `tool.py` owns the runtime implementation. +- `tool.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `Response` (no explicit base class) +- `Tool` (no explicit base class) + - `async execute(self, **kwargs) -> Response` + - `async set_progress(self, content: str | None)` + - `add_progress(self, content: str | None)` + - `async before_execution(self, **kwargs)` + - `async after_execution(self, response: Response, **kwargs)` + - `get_log_object(self)` + - `nice_key(self, key: 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. +- `Tool` defines `execute(...)`. +- Observed side-effect areas: settings/state persistence. +- Imported dependency areas include: `abc`, `agent`, `dataclasses`, `helpers.extension`, `helpers.print_style`, `helpers.strings`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.get_log_object`, `sanitize_string`, `self.agent.hist_add_tool_result`, `self.agent.context.log.log`, `key.split`, `join`, `call_extensions_async`, `response.message.strip`, `uuid.uuid4`, `PrintStyle`, `PrintStyle.stream`, `words.capitalize`, `word.lower`, `self.nice_key`. +- 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_dirty_json.py` + - `tests/test_document_query_plugin.py` + - `tests/test_fastmcp_openapi_security.py` + - `tests/test_host_browser_connector.py` + - `tests/test_mcp_handler_multimodal.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/tunnel_common.py.dox.md b/helpers/tunnel_common.py.dox.md new file mode 100644 index 000000000..2aa35d8ca --- /dev/null +++ b/helpers/tunnel_common.py.dox.md @@ -0,0 +1,51 @@ +# tunnel_common.py DOX + +## Purpose + +- Own the `tunnel_common.py` helper module. +- This module contains shared tunnel provider helper classes and event parsing. +- Keep this file-level DOX profile synchronized with `tunnel_common.py` because this directory is intentionally flat. + +## Ownership + +- `tunnel_common.py` owns the runtime implementation. +- `tunnel_common.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `TunnelHelper` (no explicit base class) + - `notify_starting(self, label)` + - `notify_url_ready(self, label, url)` + - `notify_stopped(self, label)` +- `FlaredanticTunnelHelper` (`TunnelHelper`) + - `build_tunnel(self)` + - `start(self)` + - `stop(self)` +- Top-level functions: +- `event_value(event)` + +## 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: tunnel state. +- Imported dependency areas include: `flaredantic`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `callable`, `self._notify`, `self.notify_starting`, `self.build_tunnel`, `self.tunnel.start`, `self.notify_stopped`, `self.notify_callback`, `self.notify_url_ready`, `self.tunnel.stop`. +- 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/tunnel_manager.py.dox.md b/helpers/tunnel_manager.py.dox.md new file mode 100644 index 000000000..c90749a20 --- /dev/null +++ b/helpers/tunnel_manager.py.dox.md @@ -0,0 +1,51 @@ +# tunnel_manager.py DOX + +## Purpose + +- Own the `tunnel_manager.py` helper module. +- This module selects and coordinates configured tunnel providers. +- Keep this file-level DOX profile synchronized with `tunnel_manager.py` because this directory is intentionally flat. + +## Ownership + +- `tunnel_manager.py` owns the runtime implementation. +- `tunnel_manager.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `TunnelManager` (no explicit base class) + - `get_instance(cls)` + - `get_notifications(self)` + - `get_last_error(self)` + - `start_tunnel(self, port=..., provider=...)` + - `stop_tunnel(self)` + - `get_tunnel_url(self)` +- Top-level functions: +- `normalize_provider(provider)` +- Notable constants/configuration names: `SUPPORTED_TUNNEL_PROVIDERS`, `TUNNEL_PROVIDER_ALIASES`. + +## 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: tunnel state. +- Imported dependency areas include: `collections`, `flaredantic`, `helpers.cloudflare_tunnel`, `helpers.microsoft_tunnel`, `helpers.print_style`, `helpers.serveo_tunnel`, `helpers.tailscale_tunnel`, `helpers.tunnel_common`, `threading`, `time`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `strip.lower`, `threading.Lock`, `join`, `ValueError`, `deque`, `self.notifications.clear`, `ServeoTunnelHelper`, `self._ensure_subscribed`, `strip`, `notifier.subscribe`, `CloudflareTunnel`, `MicrosoftDevTunnel`, `TailscaleTunnel`, `normalize_provider`, `threading.Thread`, `tunnel_thread.start`, `cls`, `event_value`, `PrintStyle.error`, `self._append_notification`. +- 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/tunnel_origins.py.dox.md b/helpers/tunnel_origins.py.dox.md new file mode 100644 index 000000000..83c829d8b --- /dev/null +++ b/helpers/tunnel_origins.py.dox.md @@ -0,0 +1,37 @@ +# tunnel_origins.py DOX + +## Purpose + +- Own origin normalization for Remote Control tunnel URLs and CSRF/WebSocket same-origin checks. +- Provide a small helper boundary between tunnel discovery and security enforcement. + +## Ownership + +- `tunnel_origins.py` owns the runtime implementation. +- `tunnel_origins.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `origin_from_url(value)` +- `origin_key(value)` +- `get_active_tunnel_origins()` + +## Runtime Contracts + +- Normalize URL and Origin header values to `scheme://host[:port]`, omitting default ports. +- Return comparable origin keys with default ports restored for same-origin checks. +- Treat invalid, missing, or malformed origins as `None`. +- Discover active tunnel origins from `TunnelManager` and the Docker tunnel API without raising if either source is unavailable. +- Keep tunnel service lookups short-timeout and local-only. + +## Work Guidance + +- Keep parsing based on `urllib.parse` rather than hand-rolled string checks. +- Preserve defensive exception handling because tunnel services are optional and may not be running. +- Coordinate security-sensitive changes with CSRF and WebSocket tests. + +## Verification + +- Run `pytest tests/test_csrf_tunnel_origins.py tests/test_ws_csrf.py -q` after changing tunnel origin behavior. + +## Child DOX Index + +No child DOX files. diff --git a/helpers/ui_server.py b/helpers/ui_server.py index 6cf9b7bb9..cbf14a054 100644 --- a/helpers/ui_server.py +++ b/helpers/ui_server.py @@ -37,6 +37,19 @@ from helpers.ws_manager import WsManager, set_shared_ws_manager UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 * 1024 +SOCKETIO_PING_INTERVAL_SECONDS = 45 +SOCKETIO_PING_TIMEOUT_SECONDS = 120 + + +def _positive_int_env(name: str, default: int) -> int: + raw_value = os.getenv(name) + if raw_value is None: + return default + try: + value = int(raw_value) + except (TypeError, ValueError): + return default + return value if value > 0 else default def configure_process_environment() -> None: @@ -85,8 +98,14 @@ class UiServerRuntime: cors_allowed_origins=lambda _origin, environ: validate_ws_origin(environ)[0], logger=False, engineio_logger=False, - ping_interval=25, - ping_timeout=20, + ping_interval=_positive_int_env( + "A0_SOCKETIO_PING_INTERVAL_SECONDS", + SOCKETIO_PING_INTERVAL_SECONDS, + ), + ping_timeout=_positive_int_env( + "A0_SOCKETIO_PING_TIMEOUT_SECONDS", + SOCKETIO_PING_TIMEOUT_SECONDS, + ), max_http_buffer_size=50 * 1024 * 1024, ) diff --git a/helpers/ui_server.py.dox.md b/helpers/ui_server.py.dox.md new file mode 100644 index 000000000..4c993c84d --- /dev/null +++ b/helpers/ui_server.py.dox.md @@ -0,0 +1,59 @@ +# ui_server.py DOX + +## Purpose + +- Own the `ui_server.py` helper module. +- This module configures and owns Flask/WebUI runtime route handlers. +- Keep this file-level DOX profile synchronized with `ui_server.py` because this directory is intentionally flat. + +## Ownership + +- `ui_server.py` owns the runtime implementation. +- `ui_server.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `UiServerRuntime` (no explicit base class) + - `create(cls) -> 'UiServerRuntime'` + - `refresh_runtime_settings(self) -> None` + - `register_http_routes(self) -> None` + - `register_transport_handlers(self) -> None` + - `build_asgi_app(self, startup_monitor: StartupMonitor)` + - `access_log_enabled(self) -> bool` +- `UiRouteHandlers` (no explicit base class) + - `async login_handler(self)` + - `async logout_handler(self)` + - `async serve_index(self)` + - `async serve_builtin_plugin_asset(self, plugin_name, asset_path)` + - `async serve_plugin_asset(self, plugin_name, asset_path)` + - `async serve_extension_asset(self, asset_path)` +- Top-level functions: +- `_positive_int_env(name: str, default: int) -> int` +- `configure_process_environment() -> None` +- Notable constants/configuration names: `UPLOAD_LIMIT_BYTES`, `SOCKETIO_PING_INTERVAL_SECONDS`, `SOCKETIO_PING_TIMEOUT_SECONDS`, `A0_SOCKETIO_PING_INTERVAL_SECONDS`, `A0_SOCKETIO_PING_TIMEOUT_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. +- Socket.IO heartbeat defaults are intentionally longer than Engine.IO's short defaults so CLI sessions survive long prompt/context work; environment overrides must remain positive integers and fall back to source defaults when invalid. +- Observed side-effect areas: filesystem reads, network calls, subprocess/runtime control, WebSocket state, plugin state, settings/state persistence, secret handling. +- Imported dependency areas include: `asyncio`, `dataclasses`, `datetime`, `flask`, `helpers`, `helpers.api`, `helpers.extension`, `helpers.files`, `helpers.print_style`, `helpers.server_startup`, `helpers.ws`, `helpers.ws_manager`, `logging`, `os`, `secrets`, `socketio`. + +## 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`. +- 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/update_check.py.dox.md b/helpers/update_check.py.dox.md new file mode 100644 index 000000000..f25ef598a --- /dev/null +++ b/helpers/update_check.py.dox.md @@ -0,0 +1,41 @@ +# update_check.py DOX + +## Purpose + +- Own the `update_check.py` helper module. +- This module checks available Agent Zero updates. +- Keep this file-level DOX profile synchronized with `update_check.py` because this directory is intentionally flat. + +## Ownership + +- `update_check.py` owns the runtime implementation. +- `update_check.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `async check_version()` + +## 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: network calls, settings/state persistence. +- Imported dependency areas include: `hashlib`, `helpers`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `git.get_version`, `git.is_official_agent_zero_repo`, `hashlib.sha256.hexdigest`, `httpx.AsyncClient`, `response.json`, `client.post`, `hashlib.sha256`, `runtime.get_persistent_id.encode`, `runtime.get_persistent_id`. +- 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/vector_db.py.dox.md b/helpers/vector_db.py.dox.md new file mode 100644 index 000000000..bc88f84a1 --- /dev/null +++ b/helpers/vector_db.py.dox.md @@ -0,0 +1,53 @@ +# vector_db.py DOX + +## Purpose + +- Own the `vector_db.py` helper module. +- This module wraps FAISS vector storage, retrieval, and comparator behavior. +- Keep this file-level DOX profile synchronized with `vector_db.py` because this directory is intentionally flat. + +## Ownership + +- `vector_db.py` owns the runtime implementation. +- `vector_db.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `MyFaiss` (`FAISS`) + - `get_by_ids(self, ids: Sequence[str]) -> List[Document]` + - `async aget_by_ids(self, ids: Sequence[str]) -> List[Document]` + - `get_all_docs(self) -> dict[str, Document]` +- `VectorDB` (no explicit base class) + - `async search_by_similarity_threshold(self, query: str, limit: int, threshold: float, filter: str=...)` + - `async search_by_metadata(self, filter: str, limit: int=...) -> list[Document]` + - `async insert_documents(self, docs: list[Document])` + - `async delete_documents_by_ids(self, ids: list[str])` +- Top-level functions: +- `format_docs_plain(docs: list[Document]) -> list[str]` +- `cosine_normalizer(val: float) -> float` +- `get_comparator(condition: 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 deletion. +- Imported dependency areas include: `agent`, `faiss`, `helpers`, `langchain.embeddings`, `langchain.storage`, `langchain_community.docstore.in_memory`, `langchain_community.vectorstores`, `langchain_community.vectorstores.utils`, `langchain_core.documents`, `simpleeval`, `typing`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.get_by_ids`, `agent.get_embedding_model`, `self._get_embeddings`, `faiss.IndexFlatIP`, `MyFaiss`, `get_comparator`, `self.db.get_all_docs`, `InMemoryByteStore`, `CacheBackedEmbeddings.from_bytes_store`, `self.db.asearch`, `comparator`, `guids.generate_id`, `zip`, `self.db.add_documents`, `self.db.aget_by_ids`, `simple_eval`, `self.embeddings.embed_query`, `InMemoryDocstore`, `self.db.adelete`. +- 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/virtual_desktop.py.dox.md b/helpers/virtual_desktop.py.dox.md new file mode 100644 index 000000000..34e7b2510 --- /dev/null +++ b/helpers/virtual_desktop.py.dox.md @@ -0,0 +1,74 @@ +# virtual_desktop.py DOX + +## Purpose + +- Own the `virtual_desktop.py` helper module. +- This module registers and proxies virtual desktop sessions. +- Keep this file-level DOX profile synchronized with `virtual_desktop.py` because this directory is intentionally flat. + +## Ownership + +- `virtual_desktop.py` owns the runtime implementation. +- `virtual_desktop.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `VirtualDesktopEndpoint` (no explicit base class) +- `VirtualDesktopRegistry` (no explicit base class) + - `register(self, endpoint: VirtualDesktopEndpoint) -> None` + - `unregister(self, token: str) -> None` + - `proxy_for_token(self, token: str) -> VirtualDesktopEndpoint | None` + - `resize(self, token: str, width: int, height: int) -> dict[str, Any]` +- Top-level functions: +- `register_session(token: str, host: str, port: int, owner: str=..., title: str=..., resize: ResizeCallback | None=...) -> None` +- `unregister_session(token: str) -> None` +- `proxy_for_token(token: str) -> VirtualDesktopEndpoint | None` +- `resize_session(token: str, width: int, height: int) -> dict[str, Any]` +- `get_registry() -> VirtualDesktopRegistry` +- `session_url(token: str, title: str=...) -> str` +- `collect_status() -> dict[str, Any]` +- `find_xpra_html_root() -> Path | None` +- `_package_installed(package: str) -> bool` +- `normalize_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=...) -> tuple[int, int]` +- `normalize_desktop_display_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=..., min_aspect_ratio: float=...) -> tuple[int, int]` +- `resize_display(display: int, width: int, height: int, max_width: int=..., max_height: int=..., window_class: str=..., keys: tuple[str, ...]=..., xauthority: str=..., home: str=...) -> dict[str, Any]` +- `_ensure_xrandr_mode(env: dict[str, str], width: int, height: int) -> None` +- `_select_xrandr_mode(env: dict[str, str], width: int, height: int) -> subprocess.CompletedProcess[str]` +- `_xrandr_output_modes(env: dict[str, str]) -> tuple[str, set[str]]` +- `current_display_size(display: int, xauthority: str=..., home: str=...) -> tuple[int, int] | None` +- `fit_window_until(display: int, width: int, height: int, window_class: str=..., keys: tuple[str, ...]=..., settle_seconds: float=..., timeout_seconds: float=..., process: subprocess.Popen[Any] | None=..., xauthority: str=..., home: str=...) -> None` +- `fit_window(display: int, width: int, height: int, window_class: str=..., keys: tuple[str, ...]=..., xauthority: str=..., home: str=...) -> bool` +- `has_window(display: int, window_class: str=..., name: str=..., xauthority: str=..., home: str=...) -> bool` +- `find_window(display: int, window_class: str=..., name: str=..., xauthority: str=..., home: str=...) -> str` +- `close_windows(display: int, names: tuple[str, ...]=..., window_class: str=..., xauthority: str=..., home: str=...) -> int` +- `_find_window(display: int, window_class: str=..., name: str=..., xauthority: str=..., home: str=...) -> str` +- `_display_env(display: int, xauthority: str=..., home: str=...) -> dict[str, str]` +- Notable constants/configuration names: `STATE_DIR`, `DEFAULT_WIDTH`, `DEFAULT_HEIGHT`, `MAX_WIDTH`, `MAX_HEIGHT`, `MIN_WIDTH`, `MIN_HEIGHT`, `MIN_DESKTOP_ASPECT_RATIO`, `SESSION_PATH`, `XPRA_HTML_ROOT_CANDIDATES`. + +## 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, network calls, subprocess/runtime control, plugin state, settings/state persistence, secret handling. +- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `helpers.localization`, `math`, `os`, `pathlib`, `re`, `shutil`, `subprocess`, `threading`, `time`, `typing`, `urllib.parse`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `Path`, `files.get_abs_path`, `get_registry.register`, `get_registry.unregister`, `get_registry.proxy_for_token`, `get_registry.resize`, `quote`, `urlencode`, `find_xpra_html_root`, `subprocess.run`, `normalize_size`, `shutil.which`, `_display_env`, `current_display_size`, `_ensure_xrandr_mode`, `_select_xrandr_mode`, `time.sleep`, `strip`, `_xrandr_output_modes`, `result.stdout.splitlines`. +- 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_canvas_setup.py` + - `tests/test_office_desktop_state.py` + - `tests/test_office_document_store.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/virtual_desktop_routes.py.dox.md b/helpers/virtual_desktop_routes.py.dox.md new file mode 100644 index 000000000..b784fca80 --- /dev/null +++ b/helpers/virtual_desktop_routes.py.dox.md @@ -0,0 +1,55 @@ +# virtual_desktop_routes.py DOX + +## Purpose + +- Own the `virtual_desktop_routes.py` helper module. +- This module installs virtual desktop gateway route hooks. +- Keep this file-level DOX profile synchronized with `virtual_desktop_routes.py` because this directory is intentionally flat. + +## Ownership + +- `virtual_desktop_routes.py` owns the runtime implementation. +- `virtual_desktop_routes.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `VirtualDesktopGateway` (no explicit base class) + - `async http(self, scope: Scope, receive: Receive, send: Send) -> None` + - `async resize(self, scope: Scope, receive: Receive, send: Send) -> None` + - `async proxy_http(self, scope: Scope, receive: Receive, send: Send, token: str, upstream_path: str) -> None` + - `async websocket(self, scope: Scope, receive: Receive, send: Send) -> None` + - `async open_websocket(self, endpoint: virtual_desktop.VirtualDesktopEndpoint, target: str, subprotocols: tuple[str, ...]) -> tuple[asyncio.StreamReader, asyncio.StreamWriter, WSConnection, str | None]` + - `async browser_to_xpra(self, websocket: WebSocket, upstream: WSConnection, writer: asyncio.StreamWriter) -> None` + - `async xpra_to_browser(self, websocket: WebSocket, upstream: WSConnection, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None` + - `session_request(self, path: str) -> tuple[str, str] | None` +- Top-level functions: +- `install_route_hooks() -> None` +- `is_installed() -> bool` +- Notable constants/configuration names: `HOP_BY_HOP_HEADERS`, `XPRA_MENU_CUSTOM_PATCH`, `XPRA_WINDOW_OFFSET_WARNING`, `XPRA_WINDOW_OFFSET_WARNING_PATCH`, `XPRA_WINDOW_SCRIPT`, `XPRA_WINDOW_SCRIPT_PATCH`. + +## 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, network calls, subprocess/runtime control, WebSocket state, settings/state persistence, secret handling. +- Imported dependency areas include: `__future__`, `asyncio`, `flask.sessions`, `helpers`, `http.client`, `http.cookies`, `starlette.requests`, `starlette.responses`, `starlette.types`, `starlette.websockets`, `urllib.parse`, `wsproto`, `wsproto.events`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `self.relative_path`, `self.session_request`, `self.query`, `virtual_desktop.proxy_for_token`, `WebSocket`, `self.upstream_target`, `WSConnection`, `writer.write`, `rest.partition`, `unquote`, `quote`, `http.client.HTTPConnection`, `query_string.decode`, `urlsplit`, `location.startswith`, `path.startswith`, `parse_qs`, `login.get_credentials_hash`, `SecureCookieSessionInterface.get_signing_serializer`, `dict.get.decode`. +- 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_canvas_setup.py` + - `tests/test_office_document_store.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/wait.py.dox.md b/helpers/wait.py.dox.md new file mode 100644 index 000000000..a647c28ef --- /dev/null +++ b/helpers/wait.py.dox.md @@ -0,0 +1,49 @@ +# wait.py DOX + +## Purpose + +- Own the `wait.py` helper module. +- This module formats and runs managed waits with progress updates. +- Keep this file-level DOX profile synchronized with `wait.py` because this directory is intentionally flat. + +## Ownership + +- `wait.py` owns the runtime implementation. +- `wait.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `format_remaining_time(total_seconds: float) -> str` +- `async managed_wait(agent, target_time, is_duration_wait, log, get_heading_callback)` + +## 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`, `helpers.localization`, `helpers.print_style`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `divmod`, `join`, `Localization.get.now`, `total_seconds`, `agent.handle_intervention`, `asyncio.sleep`, `pause_duration.total_seconds`, `PrintStyle.info`, `get_heading_callback`, `format_remaining_time`, `Localization.get.serialize_datetime`. +- 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/rate_limiter_test.py` + - `tests/test_api_chat_lifetime.py` + - `tests/test_browser_agent_regressions.py` + - `tests/test_chat_compaction.py` + - `tests/test_default_prompt_budget.py` + - `tests/test_document_query_plugin.py` + - `tests/test_download_toast_regressions.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/watchdog.py.dox.md b/helpers/watchdog.py.dox.md new file mode 100644 index 000000000..920b4703c --- /dev/null +++ b/helpers/watchdog.py.dox.md @@ -0,0 +1,72 @@ +# watchdog.py DOX + +## Purpose + +- Own the `watchdog.py` helper module. +- This module registers filesystem watchdogs with debouncing and path filtering. +- Keep this file-level DOX profile synchronized with `watchdog.py` because this directory is intentionally flat. + +## Ownership + +- `watchdog.py` owns the runtime implementation. +- `watchdog.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `_DispatchHandler` (no explicit base class) + - `dispatch(self, event: Any)` +- `_Watch` (no explicit base class) +- `_PendingBatch` (no explicit base class) +- `_WatchRegistry` (no explicit base class) + - `add(self, id: str, roots: list[str], patterns: list[str] | None, ignore_patterns: list[str] | None, events: WatchEvents, debounce: float, handler: WatchHandler) -> None` + - `remove(self, id: str) -> bool` + - `clear(self) -> None` + - `start(self) -> None` + - `stop(self) -> None` + - `dispatch(self, scheduled_root: str, event: Any) -> None` +- Top-level functions: +- `_normalize_root(root: str) -> str` +- `_normalize_roots(roots: list[str]) -> list[str]` +- `_normalize_patterns(patterns: list[str] | None, default: list[str] | None=...) -> list[str]` +- `_normalize_events(events: WatchEvents) -> frozenset[WatchEvent]` +- `_map_event_type(event_type: str) -> WatchEvent | None` +- `_normalize_debounce(debounce: float) -> float` +- `_covering_roots(roots: Iterable[str]) -> set[str]` +- `_is_same_or_nested(path: str, root: str) -> bool` +- `_is_under_watch(path: str, watch: _Watch) -> bool` +- `_compile_matcher(root: str, patterns: list[str], ignore_patterns: list[str]) -> PatternMatcher` +- `_compile_single_matcher(root: str, patterns: list[str]) -> PatternMatcher` +- `add_watchdog(id: str, roots: list[str], patterns: list[str] | None=..., ignore_patterns: list[str] | None=..., events: WatchEvents=..., debounce: float=..., handler: WatchHandler | None=...) -> None` +- `remove_watchdog(id: str) -> bool` +- `clear_watchdogs() -> None` +- `start_watchdog_daemon() -> None` +- `stop_watchdog_daemon() -> None` +- Notable constants/configuration names: `_DEFAULT_PATTERNS`, `_DEFAULT_IGNORE_PATTERNS`, `_VALID_EVENTS`, `_EVENT_ALIASES`. + +## 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`, `os`, `pathlib`, `threading`, `typing`, `watchdog.observers`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `frozenset`, `dataclass`, `_WatchRegistry`, `_registry.start`, `os.path.abspath`, `_compile_single_matcher`, `_registry.add`, `_registry.remove`, `_registry.clear`, `_registry.stop`, `self.registry.dispatch`, `threading.RLock`, `self._ensure_watchdog_available`, `_normalize_roots`, `_normalize_patterns`, `_normalize_events`, `_normalize_debounce`, `self._stop_observer`, `_map_event_type`, `_covering_roots`. +- 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_model_config_api_keys.py` + - `tests/test_model_config_project_presets.py` + - `tests/test_time_travel.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/ws.py.dox.md b/helpers/ws.py.dox.md new file mode 100644 index 000000000..5eb7279f6 --- /dev/null +++ b/helpers/ws.py.dox.md @@ -0,0 +1,75 @@ +# ws.py DOX + +## Purpose + +- Own the `ws.py` helper module. +- This module defines WebSocket handler registration, origin validation, and security checks. +- Keep this file-level DOX profile synchronized with `ws.py` because this directory is intentionally flat. + +## Ownership + +- `ws.py` owns the runtime implementation. +- `ws.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `ConnectionNotFoundError` (`RuntimeError`) +- `_SecurityContext` (no explicit base class) +- `WsHandler` (no explicit base class) + - `namespace(self) -> str` + - `manager(self) -> 'WsManager'` + - `identifier(self) -> str` + - `bind_manager(self, manager: 'WsManager', namespace: str | None=...) -> None` + - `requires_loopback(cls) -> bool` + - `requires_api_key(cls) -> bool` + - `requires_auth(cls) -> bool` + - `requires_csrf(cls) -> bool` +- Top-level functions: +- `_ws_debug_enabled() -> bool`: Check A0_WS_DEBUG env var - lightweight, no heavy imports. +- `ws_debug(message: str) -> None`: Log *message* via :class:`PrintStyle` when ``A0_WS_DEBUG`` is active. +- `_default_port_for_scheme(scheme: str) -> int | None` +- `normalize_origin(value: Any) -> str | None`: Normalize an Origin/Referer header value to scheme://host[:port]. +- `_parse_host_header(value: Any) -> tuple[str | None, int | None]` +- `validate_ws_origin(environ: dict[str, Any]) -> tuple[bool, str | None]`: Validate the browser Origin during the Socket.IO handshake. +- `_check_security(handler_cls: type[WsHandler], ctx: _SecurityContext) -> dict[str, Any] | None`: Return an error payload dict if the check fails, or ``None`` on success. +- `register_ws_namespace(socketio_server: socketio.AsyncServer, webapp: Flask, lock: ThreadLockType, manager: 'WsManager | None'=...) -> None` +- `_error_response(code: str, message: str, correlation_id: str) -> dict[str, Any]` +- Notable constants/configuration names: `NAMESPACE`, `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. +- `WsHandler` defines `process(...)`. +- `WsHandler` defines `requires_auth(...)`. +- `WsHandler` defines `requires_csrf(...)`. +- `WsHandler` defines `requires_api_key(...)`. +- `WsHandler` defines `requires_loopback(...)`. +- Observed side-effect areas: filesystem reads, filesystem deletion, network calls, WebSocket state, plugin state, settings/state persistence, secret handling. +- Imported dependency areas include: `abc`, `dataclasses`, `flask`, `helpers`, `helpers.errors`, `helpers.network`, `helpers.print_style`, `os`, `pathlib`, `socketio`, `threading`, `typing`, `urllib.parse`, `uuid`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `threading.Lock`, `os.getenv.strip.lower`, `_ws_debug_enabled`, `urlparse`, `normalize_origin`, `_parse_host_header`, `handler_cls.requires_loopback`, `handler_cls.requires_auth`, `handler_cls.requires_csrf`, `handler_cls.requires_api_key`, `socketio_server.on`, `PrintStyle.debug`, `value.strip`, `origin_parsed.hostname.lower`, `_default_port_for_scheme`, `req_host.lower`, `forwarded_host_raw.strip`, `forwarded_host_raw.split.strip`, `forwarded_proto_raw.strip`, `forwarded_proto_raw.split.strip.lower`. +- 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_computer_use_metadata.py` + - `tests/test_a0_connector_prompt_gating.py` + - `tests/test_browser_agent_regressions.py` + - `tests/test_docker_release_plan.py` + - `tests/test_download_toast_regressions.py` + - `tests/test_git_version_label.py` + - `tests/test_host_browser_connector.py` + - `tests/test_multi_tab_isolation.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/ws_manager.py.dox.md b/helpers/ws_manager.py.dox.md new file mode 100644 index 000000000..3d8f6da61 --- /dev/null +++ b/helpers/ws_manager.py.dox.md @@ -0,0 +1,70 @@ +# ws_manager.py DOX + +## Purpose + +- Own the `ws_manager.py` helper module. +- This module manages WebSocket connections, event validation, buffering, and dispatch. +- Keep this file-level DOX profile synchronized with `ws_manager.py` because this directory is intentionally flat. + +## Ownership + +- `ws_manager.py` owns the runtime implementation. +- `ws_manager.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `WsResult` (no explicit base class) + - `ok(cls, data: dict[str, Any] | None=..., correlation_id: str | None=..., duration_ms: float | None=...) -> 'WsResult'` + - `error(cls, code: str, message: str, details: Any | None=..., correlation_id: str | None=..., duration_ms: float | None=...) -> 'WsResult'` + - `as_result(self, handler_id: str, fallback_correlation_id: str | None, duration_ms: float | None=...) -> dict[str, Any]` +- `BufferedEvent` (no explicit base class) +- `ConnectionInfo` (no explicit base class) +- `_HandlerExecution` (no explicit base class) +- `WsManager` (no explicit base class) + - `register_diagnostic_watcher(self, namespace: str, sid: str) -> bool` + - `unregister_diagnostic_watcher(self, namespace: str, sid: str) -> None` + - `register_handlers(self, handlers_by_namespace: dict[str, Iterable[WsHandler]]) -> None` + - `iter_namespaces(self) -> list[str]` + - `async process_client_event(self, namespace: str, event_type: str, data: dict[str, Any], sid: str, handlers: list[WsHandler]) -> dict[str, Any]` + - `async handle_connect(self, namespace: str, sid: str, user_id: str | None=...) -> None` + - `async handle_disconnect(self, namespace: str, sid: str) -> None` + - `async route_event(self, namespace: str, event_type: str, data: dict[str, Any], sid: str, ack: Optional[Callable[[Any], None]]=..., include_handlers: Set[str] | None=..., exclude_handlers: Set[str] | None=..., allow_exclude: bool=..., handler_id: str | None=...) -> dict[str, Any]` +- Top-level functions: +- `validate_event_type(event_type: str) -> str`: Validate an event name: must be lowercase_snake_case and not reserved. +- `async send_data(event_type: str, data: dict[str, Any], endpoint_name: str=..., connection_id: str | None=...) -> None`: Convenience wrapper around :pymeth:`WsManager.send_data`. +- `_utcnow() -> datetime` +- `set_shared_ws_manager(manager: 'WsManager') -> None` +- `get_shared_ws_manager() -> 'WsManager'` +- Notable constants/configuration names: `_EVENT_NAME_PATTERN`, `_RESERVED_EVENT_NAMES`, `BUFFER_MAX_SIZE`, `BUFFER_TTL`, `DIAGNOSTIC_EVENT`, `LIFECYCLE_CONNECT_EVENT`, `LIFECYCLE_DISCONNECT_EVENT`, `STATE_PUSH_EVENT`, `SERVER_RESTART_EVENT`, `ERR_NO_HANDLERS`, `ERR_HANDLER_ERROR`, `ERR_INVALID_FILTER`, `ERR_INVALID_EVENT`, `ERR_CONNECTION_NOT_FOUND`, `ERR_TIMEOUT`. + +## 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, network calls, WebSocket state, settings/state persistence, scheduler state. +- Imported dependency areas include: `__future__`, `asyncio`, `collections`, `dataclasses`, `datetime`, `helpers`, `helpers.defer`, `helpers.print_style`, `helpers.ws`, `os`, `re`, `socketio`, `threading`, `time`, `typing`, `uuid`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `re.compile`, `timedelta`, `get_shared_ws_manager`, `datetime.now`, `field`, `cls`, `TypeError`, `_EVENT_NAME_PATTERN.fullmatch`, `ValueError`, `manager.send_data`, `RuntimeError`, `defaultdict`, `runtime.is_development`, `ws_debug`, `self._ensure_dispatcher_loop`, `dispatcher_loop.is_closed`, `asyncio.run_coroutine_threadsafe`, `_utcnow.isoformat.replace`, `self._copy_diagnostic_watchers`, `self._lifecycle_tasks.add`. +- 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_state_sync_handler.py` + - `tests/test_state_sync_welcome_screen.py` + - `tests/test_tool_action_contracts.py` + - `tests/test_ws_handlers.py` + - `tests/test_ws_manager.py` + +## Child DOX Index + +No child DOX files. diff --git a/helpers/yaml.py.dox.md b/helpers/yaml.py.dox.md new file mode 100644 index 000000000..41f1da445 --- /dev/null +++ b/helpers/yaml.py.dox.md @@ -0,0 +1,52 @@ +# yaml.py DOX + +## Purpose + +- Own the `yaml.py` helper module. +- This module wraps YAML loading/dumping and JSON conversion helpers. +- Keep this file-level DOX profile synchronized with `yaml.py` because this directory is intentionally flat. + +## Ownership + +- `yaml.py` owns the runtime implementation. +- `yaml.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Top-level functions: +- `loads(text: str)` +- `dumps(obj, **kwargs) -> str` +- `from_json(text: str, **yaml_dump_kwargs) -> str` +- `to_json(text: str, **json_dump_kwargs) -> 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 writes, settings/state persistence. +- Imported dependency areas include: `json`, `yaml`. + +## Key Concepts + +- Important called helpers/classes observed in the source: `yaml.safe_load`, `yaml.safe_dump`, `dumps`, `loads`, `json.dumps`, `json.loads`. +- 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_document_query_plugin.py` + - `tests/test_model_config_api_keys.py` + - `tests/test_model_config_project_presets.py` + - `tests/test_oauth_codex.py` + - `tests/test_oauth_providers.py` + - `tests/test_office_canvas_setup.py` + +## Child DOX Index + +No child DOX files. diff --git a/knowledge/AGENTS.md b/knowledge/AGENTS.md index 8075b4686..4ee093f65 100644 --- a/knowledge/AGENTS.md +++ b/knowledge/AGENTS.md @@ -13,7 +13,7 @@ ## Local Contracts -- Do not place secrets, chat transcripts, private user memory, or local deployment details in repository knowledge. +- Do not place secrets, chat transcripts, private user data, or local deployment details in repository knowledge. - Keep self-knowledge consistent with current architecture, configuration, capabilities, and setup docs. - Markdown here is for agent runtime context, not general marketing copy. diff --git a/knowledge/main/about/setup-and-deployment.md b/knowledge/main/about/setup-and-deployment.md index 90fd33f86..6ed3456b7 100644 --- a/knowledge/main/about/setup-and-deployment.md +++ b/knowledge/main/about/setup-and-deployment.md @@ -21,7 +21,6 @@ For local development: python -m venv .venv source .venv/bin/activate pip install -r requirements.txt -pip install -r requirements2.txt python run_ui.py ``` diff --git a/models.py b/models.py index 8518f0d1e..811fa31e0 100644 --- a/models.py +++ b/models.py @@ -14,19 +14,19 @@ from typing import ( TypedDict, ) -from litellm import completion, acompletion, embedding +from litellm import embedding import litellm import openai -from litellm.types.utils import ModelResponse from helpers import dotenv -from helpers import settings, dirty_json, images +from helpers import settings, images from helpers.dotenv import load_dotenv from helpers.providers import ModelType as ProviderModelType, get_provider_config from helpers.rate_limiter import RateLimiter from helpers.tokens import approximate_tokens -from helpers import dirty_json from helpers.extension import extensible # extensible: allows plugins to intercept get_api_key() +from helpers.litellm_transport import LiteLLMTransport, ResponsesTransport +from helpers.llm_result import LLMResult from langchain_core.language_models.chat_models import SimpleChatModel from langchain_core.outputs.chat_generation import ChatGenerationChunk @@ -45,6 +45,53 @@ from sentence_transformers import SentenceTransformer from pydantic import ConfigDict +DEFAULT_LITELLM_GLOBAL_KWARGS: dict[str, Any] = { + "drop_params": True, +} + +# LiteLLM documents drop_params as both a module-level switch and per-call kwarg. +# Other entries in litellm_global_kwargs, such as timeout or additional_drop_params, +# are kept as per-call kwargs instead of becoming arbitrary module attributes. +LITELLM_MODULE_GLOBAL_KEYS = frozenset({"drop_params"}) + + +def _normalize_litellm_kwargs(values: dict[str, Any]) -> dict[str, Any]: + # Normalize .env/UI-style scalar strings into native types for LiteLLM. + result: dict[str, Any] = {} + for k, v in values.items(): + if isinstance(v, str): + stripped = v.strip() + lowered = stripped.lower() + if lowered == "true": + result[k] = True + elif lowered == "false": + result[k] = False + elif lowered in ("none", "null"): + result[k] = None + else: + try: + result[k] = int(stripped) + except ValueError: + try: + result[k] = float(stripped) + except ValueError: + result[k] = v + else: + result[k] = v + return result + + +def get_litellm_global_kwargs() -> dict[str, Any]: + kwargs = _normalize_litellm_kwargs(DEFAULT_LITELLM_GLOBAL_KWARGS) + try: + configured = settings.get_settings().get("litellm_global_kwargs", {}) # type: ignore[union-attr] + except Exception: + configured = {} + if isinstance(configured, dict): + kwargs.update(_normalize_litellm_kwargs(configured)) + return kwargs + + # keep provider logging quiet in normal operation def turn_off_logging(): os.environ["LITELLM_LOG"] = "ERROR" # only errors @@ -55,9 +102,32 @@ def turn_off_logging(): logging.getLogger(name).setLevel(logging.ERROR) +def set_litellm_params(): + global_kwargs = get_litellm_global_kwargs() + for key, value in global_kwargs.items(): + if key not in LITELLM_MODULE_GLOBAL_KEYS: + continue + setattr(litellm, key, value) + return global_kwargs + + +def configure_litellm(): + turn_off_logging() + set_litellm_params() + + +def _merge_litellm_call_kwargs(*overrides: dict[str, Any] | None) -> dict[str, Any]: + kwargs = get_litellm_global_kwargs() + for override in overrides: + if isinstance(override, dict): + kwargs.update(override) + return kwargs + + # init load_dotenv() -turn_off_logging() +configure_litellm() + class ModelType(Enum): CHAT = "Chat" @@ -92,6 +162,7 @@ class ChatChunk(TypedDict): response_delta: str reasoning_delta: str + class ChatGenerationResult: """Chat generation result object""" def __init__(self, chunk: ChatChunk|None = None): @@ -371,14 +442,6 @@ class LiteLLMChatWrapper(SimpleChatModel): result.append(message_dict) - if explicit_caching and result: - if result[0]["role"] == "system": - result[0]["cache_control"] = {"type": "ephemeral"} - for i in range(len(result) - 1, -1, -1): - if result[i]["role"] == "assistant": - result[i]["cache_control"] = {"type": "ephemeral"} - break - return result def _call( @@ -388,21 +451,20 @@ class LiteLLMChatWrapper(SimpleChatModel): run_manager: Optional[CallbackManagerForLLMRun] = None, **kwargs: Any, ) -> str: - import asyncio - + configure_litellm() msgs = self._convert_messages(messages) # Apply rate limiting if configured apply_rate_limiter_sync(self.a0_model_conf, str(msgs)) - # Call the model - call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs}) - resp = completion( - model=self.model_name, messages=msgs, stop=stop, **call_kwargs + call_kwargs = _merge_litellm_call_kwargs(self.kwargs, kwargs) + transport = LiteLLMTransport( + model=self.model_name, + messages=msgs, + kwargs=call_kwargs, + stop=stop, ) - - # Parse output - parsed = _parse_chunk(resp) + parsed = transport.complete() output = ChatGenerationResult(parsed).output() return output["response_delta"] @@ -413,28 +475,22 @@ class LiteLLMChatWrapper(SimpleChatModel): run_manager: Optional[CallbackManagerForLLMRun] = None, **kwargs: Any, ) -> Iterator[ChatGenerationChunk]: - import asyncio - + configure_litellm() msgs = self._convert_messages(messages) # Apply rate limiting if configured apply_rate_limiter_sync(self.a0_model_conf, str(msgs)) result = ChatGenerationResult() - call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs}) - - for chunk in completion( + call_kwargs = _merge_litellm_call_kwargs(self.kwargs, kwargs) + transport = LiteLLMTransport( model=self.model_name, messages=msgs, - stream=True, + kwargs=call_kwargs, stop=stop, - **call_kwargs, - ): - # parse chunk - parsed = _parse_chunk(chunk) # chunk parsing - output = result.add_chunk(parsed) # chunk processing - - # Only yield chunks with non-None content + ) + for parsed in transport.stream(): + output = result.add_chunk(parsed) if output["response_delta"]: yield ChatGenerationChunk( message=AIMessageChunk(content=output["response_delta"]) @@ -447,27 +503,22 @@ class LiteLLMChatWrapper(SimpleChatModel): run_manager: Optional[AsyncCallbackManagerForLLMRun] = None, **kwargs: Any, ) -> AsyncIterator[ChatGenerationChunk]: + configure_litellm() msgs = self._convert_messages(messages) # Apply rate limiting if configured await apply_rate_limiter(self.a0_model_conf, str(msgs)) result = ChatGenerationResult() - call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs}) - - response = await acompletion( + call_kwargs = _merge_litellm_call_kwargs(self.kwargs, kwargs) + transport = LiteLLMTransport( model=self.model_name, messages=msgs, - stream=True, + kwargs=call_kwargs, stop=stop, - **call_kwargs, ) - async for chunk in response: # type: ignore - # parse chunk - parsed = _parse_chunk(chunk) # chunk parsing - output = result.add_chunk(parsed) # chunk processing - - # Only yield chunks with non-None content + async for parsed in transport.astream(): + output = result.add_chunk(parsed) if output["response_delta"]: yield ChatGenerationChunk( message=AIMessageChunk(content=output["response_delta"]) @@ -488,7 +539,7 @@ class LiteLLMChatWrapper(SimpleChatModel): **kwargs: Any, ) -> Tuple[str, str]: - turn_off_logging() + configure_litellm() if not messages: messages = [] @@ -507,10 +558,19 @@ class LiteLLMChatWrapper(SimpleChatModel): ) # Prepare call kwargs and retry config (strip A0-only params before calling LiteLLM) - call_kwargs: dict[str, Any] = _without_stream_kwarg({**self.kwargs, **kwargs}) + call_kwargs: dict[str, Any] = _merge_litellm_call_kwargs( + self.kwargs, kwargs + ) + if explicit_caching: + call_kwargs["a0_explicit_prompt_caching"] = True max_retries: int = int(call_kwargs.pop("a0_retry_attempts", 2)) retry_delay_s: float = float(call_kwargs.pop("a0_retry_delay_seconds", 1.5)) stream = reasoning_callback is not None or response_callback is not None or tokens_callback is not None + transport = LiteLLMTransport( + model=self.model_name, + messages=msgs_conv, + kwargs=call_kwargs, + ) # results result = ChatGenerationResult() @@ -519,60 +579,45 @@ class LiteLLMChatWrapper(SimpleChatModel): while True: got_any_chunk = False try: - # call model - _completion = await acompletion( - model=self.model_name, - messages=msgs_conv, - stream=stream, - **call_kwargs, - ) - if stream: - # iterate over chunks stop_response: str | None = None - try: - async for chunk in _completion: # type: ignore - got_any_chunk = True - # parse chunk - parsed = _parse_chunk(chunk) - output = result.add_chunk(parsed) + async for parsed in transport.astream(): + got_any_chunk = True + output = result.add_chunk(parsed) - # collect reasoning delta and call callbacks - if output["reasoning_delta"]: - if reasoning_callback: - await reasoning_callback(output["reasoning_delta"], result.reasoning) - if tokens_callback: - await tokens_callback( - output["reasoning_delta"], - approximate_tokens(output["reasoning_delta"]), - ) - # Add output tokens to rate limiter if configured - if limiter: - limiter.add(output=approximate_tokens(output["reasoning_delta"])) - # collect response delta and call callbacks - if output["response_delta"]: - if response_callback: - stop_response = await response_callback( - output["response_delta"], result.response - ) - if tokens_callback: - await tokens_callback( - output["response_delta"], - approximate_tokens(output["response_delta"]), - ) - # Add output tokens to rate limiter if configured - if limiter: - limiter.add(output=approximate_tokens(output["response_delta"])) - if stop_response is not None: - result.response = stop_response - break - finally: - if stop_response is not None and hasattr(_completion, "aclose"): - await _completion.aclose() # type: ignore[attr-defined] + # collect reasoning delta and call callbacks + if output["reasoning_delta"]: + if reasoning_callback: + await reasoning_callback(output["reasoning_delta"], result.reasoning) + if tokens_callback: + await tokens_callback( + output["reasoning_delta"], + approximate_tokens(output["reasoning_delta"]), + ) + # Add output tokens to rate limiter if configured + if limiter: + limiter.add(output=approximate_tokens(output["reasoning_delta"])) + # collect response delta and call callbacks + if output["response_delta"]: + if response_callback: + stop_response = await response_callback( + output["response_delta"], result.response + ) + if tokens_callback: + await tokens_callback( + output["response_delta"], + approximate_tokens(output["response_delta"]), + ) + # Add output tokens to rate limiter if configured + if limiter: + limiter.add(output=approximate_tokens(output["response_delta"])) + if stop_response is not None: + result.response = stop_response + break # non-stream response else: - parsed = _parse_chunk(_completion) + parsed = await transport.acomplete() output = result.add_chunk(parsed) if limiter: if output["response_delta"]: @@ -592,6 +637,151 @@ class LiteLLMChatWrapper(SimpleChatModel): attempt += 1 await asyncio.sleep(retry_delay_s) + async def unified_turn( + self, + system_message="", + user_message="", + messages: List[BaseMessage] | None = None, + response_callback: Callable[[str, str], Awaitable[str | None]] | None = None, + reasoning_callback: Callable[[str, str], Awaitable[None]] | None = None, + tokens_callback: Callable[[str, int], Awaitable[None]] | None = None, + rate_limiter_callback: ( + Callable[[str, str, int, int], Awaitable[bool]] | None + ) = None, + explicit_caching: bool = False, + **kwargs: Any, + ) -> LLMResult: + """Canonical internal LLM turn with Responses metadata. + + Public plugin-facing callers should keep using ``unified_call``. Core + orchestration uses this method when it needs response ids, native output + items, and state/capability metadata. + """ + + configure_litellm() + + if not messages: + messages = [] + if system_message: + messages.insert(0, SystemMessage(content=system_message)) + if user_message: + messages.append(HumanMessage(content=user_message)) + + msgs_conv = self._convert_messages(messages, explicit_caching=explicit_caching) + + limiter = await apply_rate_limiter( + self.a0_model_conf, str(msgs_conv), rate_limiter_callback + ) + + call_kwargs: dict[str, Any] = _merge_litellm_call_kwargs( + self.kwargs, kwargs + ) + if explicit_caching: + call_kwargs["a0_explicit_prompt_caching"] = True + max_retries: int = int(call_kwargs.pop("a0_retry_attempts", 2)) + retry_delay_s: float = float(call_kwargs.pop("a0_retry_delay_seconds", 1.5)) + stream = ( + reasoning_callback is not None + or response_callback is not None + or tokens_callback is not None + ) + transport = LiteLLMTransport( + model=self.model_name, + messages=msgs_conv, + kwargs=call_kwargs, + ) + + result = ChatGenerationResult() + + attempt = 0 + while True: + got_any_chunk = False + try: + if stream: + stop_response: str | None = None + async for parsed in transport.astream(): + got_any_chunk = True + output = result.add_chunk(parsed) + + if output["reasoning_delta"]: + if reasoning_callback: + await reasoning_callback( + output["reasoning_delta"], result.reasoning + ) + if tokens_callback: + await tokens_callback( + output["reasoning_delta"], + approximate_tokens(output["reasoning_delta"]), + ) + if limiter: + limiter.add( + output=approximate_tokens( + output["reasoning_delta"] + ) + ) + + if output["response_delta"]: + if response_callback: + stop_response = await response_callback( + output["response_delta"], result.response + ) + if tokens_callback: + await tokens_callback( + output["response_delta"], + approximate_tokens(output["response_delta"]), + ) + if limiter: + limiter.add( + output=approximate_tokens( + output["response_delta"] + ) + ) + if ( + stop_response is not None + and not transport.policy.using_responses + ): + result.response = stop_response + break + if stop_response is not None: + result.response = stop_response + else: + parsed = await transport.acomplete() + output = result.add_chunk(parsed) + if limiter: + if output["response_delta"]: + limiter.add( + output=approximate_tokens(output["response_delta"]) + ) + if output["reasoning_delta"]: + limiter.add( + output=approximate_tokens(output["reasoning_delta"]) + ) + + llm_result = transport.last_result or LLMResult.from_chat( + response=result.output()["response_delta"], + reasoning=result.output()["reasoning_delta"], + input_items=ResponsesTransport.input_from_messages(msgs_conv), + provider_model_key=self.model_name, + capability=transport._capability_metadata(), + ) + if result.output()["response_delta"]: + llm_result.response = result.output()["response_delta"] + if result.output()["reasoning_delta"]: + llm_result.reasoning = result.output()["reasoning_delta"] + return llm_result + + except Exception as e: + import asyncio + + if ( + got_any_chunk + or not _is_transient_litellm_error(e) + or attempt >= max_retries + ): + raise + attempt += 1 + await asyncio.sleep(retry_delay_s) + class LiteLLMEmbeddingWrapper(Embeddings): model_name: str @@ -610,20 +800,30 @@ class LiteLLMEmbeddingWrapper(Embeddings): self.a0_model_conf = model_config def embed_documents(self, texts: List[str]) -> List[List[float]]: + configure_litellm() # Apply rate limiting if configured apply_rate_limiter_sync(self.a0_model_conf, " ".join(texts)) - resp = embedding(model=self.model_name, input=texts, **self.kwargs) + resp = embedding( + model=self.model_name, + input=texts, + **_merge_litellm_call_kwargs(self.kwargs), + ) return [ item.get("embedding") if isinstance(item, dict) else item.embedding # type: ignore for item in resp.data # type: ignore ] def embed_query(self, text: str) -> List[float]: + configure_litellm() # Apply rate limiting if configured apply_rate_limiter_sync(self.a0_model_conf, text) - resp = embedding(model=self.model_name, input=[text], **self.kwargs) + resp = embedding( + model=self.model_name, + input=[text], + **_merge_litellm_call_kwargs(self.kwargs), + ) item = resp.data[0] # type: ignore return item.get("embedding") if isinstance(item, dict) else item.embedding # type: ignore @@ -736,38 +936,6 @@ def _get_litellm_embedding( ) -def _parse_chunk(chunk: Any) -> ChatChunk: - delta = chunk["choices"][0].get("delta", {}) - message = chunk["choices"][0].get("message", {}) or chunk["choices"][0].get( - "model_extra", {} - ).get("message", {}) - response_delta = ( - delta.get("content", "") - if isinstance(delta, dict) - else getattr(delta, "content", "") - ) or ( - message.get("content", "") - if isinstance(message, dict) - else getattr(message, "content", "") - ) or "" - reasoning_delta = ( - delta.get("reasoning_content", "") - if isinstance(delta, dict) - else getattr(delta, "reasoning_content", "") - ) or ( - message.get("reasoning_content", "") - if isinstance(message, dict) - else getattr(message, "reasoning_content", "") - ) or "" - - return ChatChunk(reasoning_delta=reasoning_delta, response_delta=response_delta) - - -def _without_stream_kwarg(kwargs: dict[str, Any]) -> dict[str, Any]: - kwargs.pop("stream", None) - return kwargs - - def _adjust_call_args(provider_name: str, model_name: str, kwargs: dict): @@ -781,22 +949,6 @@ def _adjust_call_args(provider_name: str, model_name: str, kwargs: dict): def _merge_provider_defaults( provider_type: ProviderModelType, original_provider: str, kwargs: dict ) -> tuple[str, dict]: - # Normalize .env-style numeric strings (e.g., "timeout=30") into ints/floats for LiteLLM - def _normalize_values(values: dict) -> dict: - result: dict[str, Any] = {} - for k, v in values.items(): - if isinstance(v, str): - try: - result[k] = int(v) - except ValueError: - try: - result[k] = float(v) - except ValueError: - result[k] = v - else: - result[k] = v - return result - provider_name = original_provider # default: unchanged cfg = get_provider_config(provider_type, original_provider) if cfg: @@ -814,15 +966,6 @@ def _merge_provider_defaults( if key and key not in ("None", "NA"): kwargs["api_key"] = key - # Merge LiteLLM global kwargs (timeouts, stream_timeout, etc.) - try: - global_kwargs = settings.get_settings().get("litellm_global_kwargs", {}) # type: ignore[union-attr] - except Exception: - global_kwargs = {} - if isinstance(global_kwargs, dict): - for k, v in _normalize_values(global_kwargs).items(): - kwargs.setdefault(k, v) - return provider_name, kwargs diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index a104fce06..0d42f9124 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -62,4 +62,39 @@ ## Child DOX Index -No child DOX files. +Direct child DOX files: + +| Child | Scope | +| --- | --- | +| [_a0_connector/AGENTS.md](_a0_connector/AGENTS.md) | HTTP and WebSocket connector integration with remote tools and runtime bridges. | +| [_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. | +| [_document_query/AGENTS.md](_document_query/AGENTS.md) | Document parsing, indexing, and Q&A tools. | +| [_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. | +| [_model_config/AGENTS.md](_model_config/AGENTS.md) | Model selection, presets, API-key checks, and scoped overrides. | +| [_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. | +| [_promptinclude/AGENTS.md](_promptinclude/AGENTS.md) | Promptinclude scanning and prompt injection. | +| [_skills/AGENTS.md](_skills/AGENTS.md) | Active and hidden skill configuration and prompt injection. | +| [_telegram_integration/AGENTS.md](_telegram_integration/AGENTS.md) | Telegram bot integration and per-user chat sessions. | +| [_text_editor/AGENTS.md](_text_editor/AGENTS.md) | Native text read, write, and patch tool. | +| [_time_travel/AGENTS.md](_time_travel/AGENTS.md) | Workspace history, diff, travel, snapshot, and revert flows. | +| [_whatsapp_integration/AGENTS.md](_whatsapp_integration/AGENTS.md) | WhatsApp Baileys bridge integration. | +| [_whats_new/AGENTS.md](_whats_new/AGENTS.md) | Version-gated What's New showcase modal, card list, and startup trigger. | +| [_whisper_stt/AGENTS.md](_whisper_stt/AGENTS.md) | Whisper speech-to-text integration. | diff --git a/plugins/_a0_connector/AGENTS.md b/plugins/_a0_connector/AGENTS.md new file mode 100644 index 000000000..b81567a24 --- /dev/null +++ b/plugins/_a0_connector/AGENTS.md @@ -0,0 +1,39 @@ +# A0 Connector Plugin DOX + +## Purpose + +- Own the current Agent Zero connector plugin for HTTP and WebSocket integration. +- Provide remote execution, text-editing freshness, and connector runtime bridges. + +## Ownership + +- `plugin.yaml` owns plugin metadata and settings scope. +- `api/` owns connector WebSocket and API entry points. +- `helpers/` owns chat context, event bridge, execution config, freshness, version, and WebSocket runtime helpers. +- `tools/`, `prompts/`, `skills/`, `extensions/`, and `webui/` own connector-facing agent and UI contributions. + +## Local Contracts + +- Preserve session-auth and `auth.handlers` activation assumptions. +- Keep remote tool prompts synchronized with remote tool behavior and disclose + them only from connected CLI metadata: no connected CLI hides all remote tool + prompts, remote file metadata enables `text_editor_remote`, F4-enabled remote + execution metadata enables `code_execution_remote`, and supported enabled + Computer Use that does not need re-arming enables `computer_use_remote`. +- Do not bypass WebSocket authentication or leak connector session data. +- 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 + +- Coordinate connector runtime changes with API, tools, prompts, and WebUI viewer behavior together. + +## Verification + +- Run connector-specific tests or smoke-test HTTP and `/ws` integration when changing runtime behavior. + +## Child DOX Index + +No child DOX files. 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/extensions/python/_functions/_11_tools_prompt/build_prompt/end/_70_include_remote_tool_stubs.py b/plugins/_a0_connector/extensions/python/_functions/_11_tools_prompt/build_prompt/end/_70_include_remote_tool_stubs.py new file mode 100644 index 000000000..5f8fa398f --- /dev/null +++ b/plugins/_a0_connector/extensions/python/_functions/_11_tools_prompt/build_prompt/end/_70_include_remote_tool_stubs.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import re +from typing import Any + +from helpers.extension import Extension +from plugins._a0_connector.helpers.remote_tool_prompts import ( + REMOTE_TOOL_PROMPTS, + remote_tool_prompt_availability, +) + + +_TOOL_MARKERS = { + tool_name: f'"tool_name": "{tool_name}"' for tool_name in REMOTE_TOOL_PROMPTS +} + + +class IncludeRemoteToolStubs(Extension): + def execute(self, data: dict[str, Any] = {}, **kwargs: Any) -> None: + if self.agent is None: + return + if not isinstance(data, dict): + return + result = data.get("result") + if not isinstance(result, str): + return + + context_id = str( + getattr(getattr(self.agent, "context", None), "id", "") or "" + ).strip() + if not context_id: + return + + available = remote_tool_prompt_availability(context_id) + for tool_name, prompt_file in REMOTE_TOOL_PROMPTS.items(): + try: + prompt = self.agent.read_prompt(prompt_file).strip() + except Exception: + continue + if not prompt: + continue + + if available.get(tool_name): + marker = _TOOL_MARKERS[tool_name] + if marker not in result: + result = f"{result.rstrip()}\n\n{prompt}" + continue + + result = _remove_prompt(result, prompt) + + data["result"] = result + + +def _remove_prompt(result: str, prompt: str) -> str: + if prompt not in result: + return result + + for needle, replacement in ( + (f"\n\n{prompt}\n\n", "\n\n"), + (f"\n\n{prompt}", ""), + (f"{prompt}\n\n", ""), + (prompt, ""), + ): + result = result.replace(needle, replacement) + + return re.sub(r"\n{3,}", "\n\n", result).rstrip() diff --git a/plugins/_a0_connector/extensions/python/_functions/extensions/python/system_prompt/_11_tools_prompt/build_prompt/end/_70_include_remote_tool_stubs.py b/plugins/_a0_connector/extensions/python/_functions/extensions/python/system_prompt/_11_tools_prompt/build_prompt/end/_70_include_remote_tool_stubs.py deleted file mode 100644 index 1fe05f0d5..000000000 --- a/plugins/_a0_connector/extensions/python/_functions/extensions/python/system_prompt/_11_tools_prompt/build_prompt/end/_70_include_remote_tool_stubs.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from helpers.extension import Extension - - -_COMPUTER_USE_PROMPT = "agent.system.tool.computer_use_remote.md" -_COMPUTER_USE_TOOL_MARKER = '"tool_name": "computer_use_remote"' - - -class IncludeRemoteToolStubs(Extension): - def execute(self, data: dict[str, Any] = {}, **kwargs: Any) -> None: - if self.agent is None: - return - if not isinstance(data, dict): - return - result = data.get("result") - if not isinstance(result, str): - return - if _COMPUTER_USE_TOOL_MARKER in result: - return - - try: - prompt = self.agent.read_prompt(_COMPUTER_USE_PROMPT).strip() - except Exception: - return - if not prompt: - return - - data["result"] = f"{result.rstrip()}\n\n{prompt}" diff --git a/plugins/_a0_connector/helpers/remote_tool_prompts.py b/plugins/_a0_connector/helpers/remote_tool_prompts.py new file mode 100644 index 000000000..65472fabe --- /dev/null +++ b/plugins/_a0_connector/helpers/remote_tool_prompts.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Any + +from plugins._a0_connector.helpers import ws_runtime + + +REMOTE_TOOL_PROMPTS: dict[str, str] = { + "code_execution_remote": "agent.system.tool.code_execution_remote.md", + "computer_use_remote": "agent.system.tool.computer_use_remote.md", + "text_editor_remote": "agent.system.tool.text_editor_remote.md", +} + + +def remote_tool_prompt_availability(context_id: str) -> dict[str, bool]: + """Return which connector remote tools should be advertised in prompts.""" + candidates = ws_runtime.remote_tool_sids_for_context(context_id) + return { + "code_execution_remote": any( + _remote_exec_prompt_available(sid) for sid in candidates + ), + "computer_use_remote": any( + _computer_use_prompt_available(sid) for sid in candidates + ), + "text_editor_remote": any( + _remote_file_prompt_available(sid) for sid in candidates + ), + } + + +def should_include_remote_tool_prompt(agent: Any, tool_name: str) -> bool: + if tool_name not in REMOTE_TOOL_PROMPTS: + return True + + context = getattr(agent, "context", None) + context_id = str(getattr(context, "id", "") or "").strip() + if not context_id: + return False + + return bool(remote_tool_prompt_availability(context_id).get(tool_name)) + + +def _remote_file_prompt_available(sid: str) -> bool: + metadata = ws_runtime.remote_file_metadata_for_sid(sid) or {} + return bool(metadata.get("enabled")) + + +def _remote_exec_prompt_available(sid: str) -> bool: + metadata = ws_runtime.remote_exec_metadata_for_sid(sid) or {} + return bool(metadata.get("enabled")) + + +def _computer_use_prompt_available(sid: str) -> bool: + metadata = ws_runtime.computer_use_metadata_for_sid(sid) or {} + status = str(metadata.get("status", "") or "").strip().lower() + return bool( + metadata.get("supported") + and metadata.get("enabled") + and status != "rearm required" + ) diff --git a/plugins/_a0_connector/helpers/ws_runtime.py b/plugins/_a0_connector/helpers/ws_runtime.py index 1987e5350..e35948359 100644 --- a/plugins/_a0_connector/helpers/ws_runtime.py +++ b/plugins/_a0_connector/helpers/ws_runtime.py @@ -1,10 +1,13 @@ from __future__ import annotations import asyncio +import base64 +import binascii import copy +import json import threading import time -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any @@ -14,6 +17,8 @@ class PendingFileOperation: loop: asyncio.AbstractEventLoop future: asyncio.Future[dict[str, Any]] context_id: str | None = None + chunk_count: int | None = None + chunks: dict[int, bytes] = field(default_factory=dict) @dataclass @@ -75,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 @@ -411,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, @@ -440,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) @@ -459,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, @@ -524,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, @@ -570,9 +614,101 @@ def resolve_pending_file_op( sid: str, payload: dict[str, Any], ) -> bool: + if payload.get("chunked") is True: + return _resolve_pending_file_chunk(op_id, sid=sid, payload=payload) return _resolve_pending(_pending_file_ops, op_id, sid=sid, payload=payload) +def _resolve_pending_file_chunk( + op_id: str, + *, + sid: str, + payload: dict[str, Any], +) -> bool: + error = _validate_file_chunk_payload(payload) + if error: + return _fail_pending( + _pending_file_ops, + op_id, + sid=sid, + error=f"Invalid chunked file operation result: {error}", + ) + + chunk_index = int(payload["chunk_index"]) + chunk_count = int(payload["chunk_count"]) + encoded = str(payload.get("data") or "") + try: + chunk = base64.b64decode(encoded.encode("ascii"), validate=True) + except (UnicodeEncodeError, binascii.Error) as exc: + return _fail_pending( + _pending_file_ops, + op_id, + sid=sid, + error=f"Invalid chunked file operation result: {exc}", + ) + + with _state_lock: + pending = _pending_file_ops.get(op_id) + if pending is None or pending.sid != sid: + return False + + if pending.chunk_count is None: + pending.chunk_count = chunk_count + elif pending.chunk_count != chunk_count: + _pending_file_ops.pop(op_id, None) + pending.loop.call_soon_threadsafe( + _set_future_result, + pending.future, + { + "op_id": op_id, + "ok": False, + "error": "Invalid chunked file operation result: chunk_count changed", + }, + ) + return True + + pending.chunks[chunk_index] = chunk + if len(pending.chunks) < chunk_count: + return True + + ordered = [pending.chunks[index] for index in range(chunk_count)] + _pending_file_ops.pop(op_id, None) + + try: + assembled = b"".join(ordered).decode("utf-8") + result = json.loads(assembled) + if not isinstance(result, dict): + raise ValueError("decoded result is not an object") + except Exception as exc: + result = { + "op_id": op_id, + "ok": False, + "error": f"Invalid chunked file operation result: {exc}", + } + + pending.loop.call_soon_threadsafe(_set_future_result, pending.future, result) + return True + + +def _validate_file_chunk_payload(payload: dict[str, Any]) -> str: + if payload.get("encoding") != "json+base64": + return "encoding must be json+base64" + + try: + chunk_index = int(payload.get("chunk_index")) + chunk_count = int(payload.get("chunk_count")) + except (TypeError, ValueError): + return "chunk_index and chunk_count must be integers" + + if chunk_count <= 0: + return "chunk_count must be positive" + if chunk_index < 0 or chunk_index >= chunk_count: + return "chunk_index out of range" + if not isinstance(payload.get("data"), str): + return "data must be a string" + return "" + + def fail_pending_file_op( op_id: str, *, diff --git a/plugins/_a0_connector/prompts/agent.system.tool.code_execution_remote.md b/plugins/_a0_connector/prompts/agent.system.tool.code_execution_remote.md index 2e1251153..8c334d0a8 100644 --- a/plugins/_a0_connector/prompts/agent.system.tool.code_execution_remote.md +++ b/plugins/_a0_connector/prompts/agent.system.tool.code_execution_remote.md @@ -1,9 +1,10 @@ # code_execution_remote tool -Runs shell-backed execution on the machine where a connected A0 CLI is running. -Use this tool, not `code_execution_tool`, when the user asks for the connected -local terminal, the A0 CLI host, their local machine, or explicitly says not to -use Docker/server/container execution. +Shown when a connected A0 CLI advertises remote execution, which the user enables +with F4 in the CLI. Runs shell-backed execution on the machine where that CLI is +running. Use this tool, not `code_execution_tool`, when the user asks for the +connected local terminal, the A0 CLI host, their local machine, or explicitly +says not to use Docker/server/container execution. For complex local project work, optionally load skill `host-code-execution`. Availability and permissions are checked when the tool runs. If no CLI is diff --git a/plugins/_a0_connector/prompts/agent.system.tool.computer_use_remote.md b/plugins/_a0_connector/prompts/agent.system.tool.computer_use_remote.md index 163f23db4..558208ee0 100644 --- a/plugins/_a0_connector/prompts/agent.system.tool.computer_use_remote.md +++ b/plugins/_a0_connector/prompts/agent.system.tool.computer_use_remote.md @@ -1,6 +1,6 @@ ### computer_use_remote -Runtime-gated beta desktop control through a connected A0 CLI on the user's host machine. The callable contract is available in the tool prompt. Availability, backend support, and trust mode are checked when the tool runs, together with CLI presence, local enablement, and re-arm state. Computer Use enablement is scoped to the current CLI session, not scoped to a single chat context. +Shown when a connected A0 CLI advertises enabled Computer Use (`/computer-use on`) and does not need re-arming. Runtime-gated beta desktop control through that CLI on the user's host machine. Availability, backend support, and trust mode are checked when the tool runs, together with CLI presence, local enablement, and re-arm state. Computer Use enablement is scoped to the current CLI session, not scoped to a single chat context. Use this for native host desktop UI inspection, screenshots, background-safe window/element actions when supported, clicking, scrolling, typing, key presses, and status checks. Do not use it for ordinary web-page navigation or host-browser control; use the browser tool for web pages unless browser automation cannot express the task. For complex desktop workflows, load and follow skill `host-computer-use` before proceeding. diff --git a/plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md b/plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md index 514108b8b..0c2b0341f 100644 --- a/plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md +++ b/plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md @@ -1,9 +1,10 @@ # text_editor_remote tool -Reads, writes, and patches files on the machine where a connected A0 CLI is -running. Use this tool, not server-side file tools, when the user asks for files -on the connected local machine, A0 CLI host, or explicitly says not to use -Docker/server files. For complex remote edits, optionally load skill `host-file-editing`. +Shown when a connected A0 CLI advertises remote file access. Reads, writes, and +patches files on the machine where that CLI is running. Use this tool, not +server-side file tools, when the user asks for files on the connected local +machine, A0 CLI host, or explicitly says not to use Docker/server files. For +complex remote edits, optionally load skill `host-file-editing`. Availability and permissions are checked when the tool runs. If no CLI is connected, remote file access is disabled, or a write/patch needs Read&Write, diff --git a/plugins/_browser/AGENTS.md b/plugins/_browser/AGENTS.md new file mode 100644 index 000000000..a0e570a9d --- /dev/null +++ b/plugins/_browser/AGENTS.md @@ -0,0 +1,50 @@ +# Browser Plugin DOX + +## Purpose + +- Own the built-in Playwright browser tool and WebUI browser viewer. +- Bridge browser automation, page inspection helpers, and browser panel UI. + +## Ownership + +- `plugin.yaml` and `default_config.yaml` own metadata and browser settings defaults. +- `tools/browser.py` owns the agent-facing browser tool. +- `helpers/` owns Playwright runtime, selectors, URL helpers, extension management, and connector runtime logic. +- `api/` owns status, extension, and browser WebSocket handlers. +- `assets/`, `prompts/`, `skills/`, `extensions/`, and `webui/` own browser scripts, prompts, skill guidance, hook contributions, and UI. + +## Local Contracts + +- Keep browser actions safe around external pages, credentials, and user data. +- 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 ``/data URL path for snapshots and fallback rendering. +- Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path. +- Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values. +- Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other AgentContext runtimes only when the Browser settings tab scope is `shared`. +- Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar. +- For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic CLI selection, a browser family/id, or an explicit CDP endpoint and must be forwarded to the connector runtime as `browser_selection`. +- Browser Settings must refresh connected A0 CLI host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening. +- Browser Settings keeps the Host browser dropdown focused on automatic selection, advertised debug endpoints, and a validated Custom endpoint field instead of listing every installed local profile. +- Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces. +- Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback. +- Do not hardcode user-specific browser paths or secrets. + +## Work Guidance + +- Coordinate tool, helper, and panel changes so browser state shown in the UI matches tool behavior. +- Do not depend on nested Electron `` support or launcher-specific preload bridges unless the launcher exposes that bridge as an explicit contract. +- Keep `prompts/agent.system.tool.browser.md` as a compact callable contract; move detailed browser workflows into `skills/browser-automation/SKILL.md`. +- Keep `skills/browser-automation/SKILL.md` frontmatter triggers current with rendered browsing, host-browser, screenshot, and web-interaction user phrasing so relevant-skill recall can surface the skill before the full browser workflow is needed. +- Keep fragile form guidance progressively disclosed through `skills/browser-form-workflows/SKILL.md`, linked from the browser prompt through `browser-automation`. + +## Verification + +- Smoke-test browser launch, navigation, DOM capture, and WebUI viewer after runtime changes. +- For viewer render-path changes, verify the live Browser panel paints a screencast frame on canvas with `frameSrc` empty and snapshots still falling back to the image path. +- Run browser prompt/skill regression tests after changing browser prompt or Browser plugin skills. + +## Child DOX Index + +No child DOX files. diff --git a/plugins/_browser/api/ws_browser.py b/plugins/_browser/api/ws_browser.py index a061c406a..be7092826 100644 --- a/plugins/_browser/api/ws_browser.py +++ b/plugins/_browser/api/ws_browser.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import base64 import contextlib import time from typing import Any, ClassVar @@ -8,13 +9,23 @@ from typing import Any, ClassVar from agent import AgentContext from helpers.ws import WsHandler from helpers.ws_manager import WsResult +from plugins._browser.helpers.config import ( + DEFAULT_BROWSER_TAB_SCOPE, + TAB_SCOPE_KEY, + get_browser_config, +) from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions -FRAME_IDLE_POLL_SECONDS = 0.05 +FRAME_READ_TIMEOUT_SECONDS = 0.5 FRAME_RETRY_DELAY_SECONDS = 0.5 FRAME_STATE_REFRESH_SECONDS = 0.75 -SCREENCAST_QUALITY = 92 +SNAPSHOT_STATE_POLL_SECONDS = 0.75 +SCREENCAST_STREAM_QUALITY = 80 +SCREENSHOT_QUALITY = 92 +VIEWER_TRANSPORT_SCREENCAST = "screencast" +VIEWER_TRANSPORT_SNAPSHOT = "snapshot" +VIEWER_TRANSPORTS = {VIEWER_TRANSPORT_SCREENCAST, VIEWER_TRANSPORT_SNAPSHOT} class WsBrowser(WsHandler): @@ -92,21 +103,41 @@ class WsBrowser(WsHandler): if existing: existing.cancel() viewer_id = str(data.get("viewer_id") or "") + viewer_transport = self._viewer_transport(data) + binary_frames = self._bool(data.get("binary_frames", data.get("binaryFrames"))) + slim_frames = self._bool(data.get("slim_frames", data.get("slimFrames", binary_frames))) + capture_scale = self._capture_scale_from_data(data) snapshot = None if runtime: - self._streams[stream_key] = asyncio.create_task( - self._stream_frames(sid, context_id, active_id, viewer_id) - ) + if viewer_transport == VIEWER_TRANSPORT_SCREENCAST: + stream_task = self._stream_frames( + sid, + context_id, + active_id, + viewer_id, + binary_frames=binary_frames, + slim_frames=slim_frames, + capture_scale=capture_scale, + ) + else: + stream_task = self._stream_state(sid, context_id, active_id, viewer_id) + self._streams[stream_key] = asyncio.create_task(stream_task) snapshot = await self._snapshot_for_browser(runtime, active_id) + browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers) + return { "context_id": context_id, "active_browser_context_id": context_id, "active_browser_id": active_id, "snapshot": snapshot, - "browsers": await self._all_browser_tabs(), - "all_browsers": True, + "browsers": browsers, + "all_browsers": all_browsers, + "tab_scope": tab_scope, "viewer_id": viewer_id, + "viewer_transport": viewer_transport, + "binary_frames": binary_frames, + "slim_frames": slim_frames, } def _unsubscribe(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult: @@ -119,10 +150,23 @@ class WsBrowser(WsHandler): return {"context_id": context_id, "unsubscribed": True} async def _sessions(self, data: dict[str, Any]) -> dict[str, Any]: + context_id = self._context_id(data) + tab_scope = self._tab_scope() + if tab_scope == "shared": + return { + "context_id": context_id, + "browsers": await self._all_browser_tabs(), + "all_browsers": True, + "tab_scope": tab_scope, + } + + runtime = await get_runtime(context_id, create=False) if context_id else None + listing = await runtime.call("list") if runtime else {} return { - "context_id": self._context_id(data), - "browsers": await self._all_browser_tabs(), - "all_browsers": True, + "context_id": context_id, + "browsers": listing.get("browsers") or [], + "all_browsers": False, + "tab_scope": tab_scope, } async def _snapshot(self, data: dict[str, Any]) -> dict[str, Any] | WsResult: @@ -134,13 +178,15 @@ class WsBrowser(WsHandler): runtime = await get_runtime(context_id, create=False) if not runtime: + browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, []) return { "context_id": context_id, "active_browser_context_id": context_id, "active_browser_id": None, "snapshot": None, - "browsers": await self._all_browser_tabs(), - "all_browsers": True, + "browsers": browsers, + "all_browsers": all_browsers, + "tab_scope": tab_scope, } listing = await runtime.call("list") @@ -149,9 +195,9 @@ class WsBrowser(WsHandler): snapshot = None if active_id: try: - quality = int(data.get("quality") or SCREENCAST_QUALITY) + quality = int(data.get("quality") or SCREENSHOT_QUALITY) except (TypeError, ValueError): - quality = SCREENCAST_QUALITY + quality = SCREENSHOT_QUALITY with contextlib.suppress(Exception): snapshot = await runtime.call( "screenshot", @@ -159,13 +205,16 @@ class WsBrowser(WsHandler): quality=quality, ) + browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers) + return { "context_id": context_id, "active_browser_context_id": context_id, "active_browser_id": active_id, "snapshot": snapshot, - "browsers": await self._all_browser_tabs(), - "all_browsers": True, + "browsers": browsers, + "all_browsers": all_browsers, + "tab_scope": tab_scope, } async def _command(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult: @@ -200,7 +249,10 @@ class WsBrowser(WsHandler): listing = await runtime.call("list") last_interacted_browser_id = listing.get("last_interacted_browser_id") snapshot = await self._snapshot_for_result(runtime, result) - all_browsers = await self._all_browser_tabs() + browsers, all_browsers, tab_scope = await self._tabs_for_scope( + context_id, + listing.get("browsers") or [], + ) await self.emit_to( sid, "browser_viewer_state", @@ -212,22 +264,26 @@ class WsBrowser(WsHandler): "browser_id": browser_id, "result": result, "snapshot": snapshot, - "browsers": all_browsers, - "all_browsers": True, + "browsers": browsers, + "all_browsers": all_browsers, + "tab_scope": tab_scope, "last_interacted_browser_id": last_interacted_browser_id, + "viewer_transport": self._viewer_transport(data), }, correlation_id=data.get("correlationId"), ) return { "result": result, "snapshot": snapshot, - "browsers": all_browsers, - "all_browsers": True, + "browsers": browsers, + "all_browsers": all_browsers, + "tab_scope": tab_scope, "active_browser_context_id": context_id, "last_interacted_browser_id": last_interacted_browser_id, "command": command, "browser_id": browser_id, "viewer_id": viewer_id, + "viewer_transport": self._viewer_transport(data), } async def _input(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult: @@ -337,7 +393,7 @@ class WsBrowser(WsHandler): if not browser_id: return None with contextlib.suppress(Exception): - return await runtime.call("screenshot", browser_id, quality=SCREENCAST_QUALITY) + return await runtime.call("screenshot", browser_id, quality=SCREENSHOT_QUALITY) return None async def _snapshot_for_browser( @@ -348,9 +404,27 @@ class WsBrowser(WsHandler): if not browser_id: return None with contextlib.suppress(Exception): - return await runtime.call("screenshot", browser_id, quality=SCREENCAST_QUALITY) + return await runtime.call("screenshot", browser_id, quality=SCREENSHOT_QUALITY) return None + @staticmethod + def _tab_scope() -> str: + scope = str( + (get_browser_config() or {}).get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE) + or DEFAULT_BROWSER_TAB_SCOPE + ).strip().lower().replace("-", "_") + return "shared" if scope == "shared" else DEFAULT_BROWSER_TAB_SCOPE + + async def _tabs_for_scope( + self, + context_id: str, + browsers: list[dict[str, Any]] | None, + ) -> tuple[list[dict[str, Any]], bool, str]: + tab_scope = self._tab_scope() + if tab_scope == "shared": + return await self._all_browser_tabs(), True, tab_scope + return browsers or [], False, tab_scope + async def _all_browser_tabs(self) -> list[dict[str, Any]]: browsers: list[dict[str, Any]] = [] for session in await list_runtime_sessions(): @@ -367,6 +441,10 @@ class WsBrowser(WsHandler): context_id: str, browser_id: int | str | None, viewer_id: str = "", + *, + binary_frames: bool = False, + slim_frames: bool = False, + capture_scale: float = 1.0, ) -> None: runtime = None stream_id = None @@ -374,7 +452,12 @@ class WsBrowser(WsHandler): try: runtime = await get_runtime(context_id, create=False) if not runtime: - await self._emit_empty_frame(sid, context_id, viewer_id=viewer_id) + await self._emit_empty_frame( + sid, + context_id, + viewer_id=viewer_id, + frame_source=VIEWER_TRANSPORT_SCREENCAST, + ) await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS) continue @@ -382,36 +465,75 @@ class WsBrowser(WsHandler): browsers = listing.get("browsers") or [] active_id = self._active_browser_id(listing, browser_id) if not active_id: - await self._emit_empty_frame(sid, context_id, browsers=browsers, viewer_id=viewer_id) + await self._emit_viewer_state( + sid, + context_id, + active_id, + browsers=browsers, + viewer_id=viewer_id, + state=None, + viewer_transport=VIEWER_TRANSPORT_SCREENCAST, + ) await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS) continue screencast = await runtime.call( "start_screencast", active_id, - quality=SCREENCAST_QUALITY, + quality=SCREENCAST_STREAM_QUALITY, every_nth_frame=1, + capture_scale=capture_scale, ) stream_id = screencast["stream_id"] active_id = screencast["browser_id"] state = screencast.get("state") - await self.emit_to( + await self._emit_viewer_state( sid, - "browser_viewer_frame", - { - "context_id": context_id, - "viewer_id": viewer_id, - "browser_id": active_id, - "browsers": browsers, - "image": "", - "mime": "", - "state": state, - "frame_source": "state", - }, + context_id, + active_id, + browsers=browsers, + viewer_id=viewer_id, + state=state, + viewer_transport=VIEWER_TRANSPORT_SCREENCAST, ) last_state_refresh = 0.0 + last_state_signature = self._state_signature(active_id, browsers) + frame_sequence = 0 + server_loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + async def emit_frame(frame: dict[str, Any]) -> None: + nonlocal frame_sequence + try: + frame_sequence += 1 + payload = self._frame_payload( + frame, + context_id=context_id, + viewer_id=viewer_id, + browser_id=active_id, + sequence=frame_sequence, + binary_frames=binary_frames, + ) + if not slim_frames: + payload["browsers"] = browsers + payload["state"] = state + await self._emit_to_connected_viewer(sid, "browser_viewer_frame", payload) + except BaseException: + stop_event.set() + raise + + def frame_consumer(frame: dict[str, Any]): + return asyncio.run_coroutine_threadsafe(emit_frame(frame), server_loop) + + def stop_consumer() -> None: + server_loop.call_soon_threadsafe(stop_event.set) + + await runtime.call("attach_screencast_consumer", stream_id, frame_consumer, stop_consumer) + while True: + if stop_event.is_set(): + break now = time.monotonic() if now - last_state_refresh >= FRAME_STATE_REFRESH_SECONDS: listing = await runtime.call("list") @@ -420,23 +542,25 @@ class WsBrowser(WsHandler): if str(active_id) not in browser_ids: break state = self._state_for_browser(browsers, active_id, state) + state_signature = self._state_signature(active_id, browsers) + if state_signature != last_state_signature: + await self._emit_viewer_state( + sid, + context_id, + active_id, + browsers=browsers, + viewer_id=viewer_id, + state=state, + viewer_transport=VIEWER_TRANSPORT_SCREENCAST, + ) + last_state_signature = state_signature last_state_refresh = now try: - frame = await runtime.call("pop_screencast_frame", stream_id) - except KeyError: + await asyncio.wait_for(stop_event.wait(), timeout=FRAME_READ_TIMEOUT_SECONDS) break - if frame is None: - await asyncio.sleep(FRAME_IDLE_POLL_SECONDS) + except TimeoutError: continue - - frame["context_id"] = context_id - frame["viewer_id"] = viewer_id - frame["browser_id"] = active_id - frame["browsers"] = browsers - frame["state"] = state - frame["frame_source"] = "screencast" - await self.emit_to(sid, "browser_viewer_frame", frame) except asyncio.CancelledError: raise except Exception: @@ -447,6 +571,64 @@ class WsBrowser(WsHandler): await runtime.call("stop_screencast", stream_id) stream_id = None + async def _stream_state( + self, + sid: str, + context_id: str, + browser_id: int | str | None, + viewer_id: str = "", + ) -> None: + last_signature = None + while True: + try: + runtime = await get_runtime(context_id, create=False) + if not runtime: + signature = (None, ()) + if signature != last_signature: + await self._emit_empty_frame( + sid, + context_id, + viewer_id=viewer_id, + frame_source=VIEWER_TRANSPORT_SNAPSHOT, + ) + last_signature = signature + await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS) + continue + + listing = await runtime.call("list") + browsers = listing.get("browsers") or [] + active_id = self._active_browser_id(listing, browser_id) + state = self._state_for_browser(browsers, active_id, None) if active_id else None + signature = ( + str(active_id or ""), + tuple( + ( + str(browser.get("context_id") or context_id), + str(browser.get("id") or ""), + str(browser.get("currentUrl") or ""), + str(browser.get("title") or ""), + bool(browser.get("loading")), + ) + for browser in browsers + ), + ) + if signature != last_signature: + await self._emit_viewer_state( + sid, + context_id, + active_id, + browsers=browsers, + viewer_id=viewer_id, + state=state, + viewer_transport=VIEWER_TRANSPORT_SNAPSHOT, + ) + last_signature = signature + await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS) + except asyncio.CancelledError: + raise + except Exception: + await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS) + @staticmethod def _active_browser_id( listing: dict[str, Any], @@ -477,6 +659,107 @@ class WsBrowser(WsHandler): return browser return current_state + @staticmethod + def _state_signature( + active_id: int | str | None, + browsers: list[dict[str, Any]], + ) -> tuple[str, tuple[tuple[str, str, str, str, bool], ...]]: + return ( + str(active_id or ""), + tuple( + ( + str(browser.get("context_id") or ""), + str(browser.get("id") or ""), + str(browser.get("currentUrl") or ""), + str(browser.get("title") or ""), + bool(browser.get("loading")), + ) + for browser in browsers + ), + ) + + @staticmethod + def _frame_payload( + frame: dict[str, Any], + *, + context_id: str, + viewer_id: str, + browser_id: int | str, + sequence: int, + binary_frames: bool, + ) -> dict[str, Any]: + image = str(frame.get("image") or "") + payload: dict[str, Any] = { + "context_id": context_id, + "viewer_id": viewer_id, + "browser_id": browser_id, + "seq": sequence, + "mime": frame.get("mime") or "image/jpeg", + "frame_source": VIEWER_TRANSPORT_SCREENCAST, + "viewer_transport": VIEWER_TRANSPORT_SCREENCAST, + } + dimensions = WsBrowser._frame_dimensions(frame.get("metadata")) + if dimensions: + payload.update(dimensions) + if binary_frames: + try: + payload["image"] = base64.b64decode(image, validate=False) + payload["encoding"] = "binary" + except Exception: + payload["image"] = image + payload["encoding"] = "base64" + else: + payload["image"] = image + payload["encoding"] = "base64" + return payload + + @staticmethod + def _frame_dimensions(metadata: Any) -> dict[str, int]: + if not isinstance(metadata, dict): + return {} + for width_key, height_key in ( + ("expectedWidth", "expectedHeight"), + ("deviceWidth", "deviceHeight"), + ("jpegWidth", "jpegHeight"), + ): + try: + width = int(metadata.get(width_key) or 0) + height = int(metadata.get(height_key) or 0) + except (TypeError, ValueError): + continue + if width > 0 and height > 0: + return {"width": width, "height": height} + return {} + + async def _emit_viewer_state( + self, + sid: str, + context_id: str, + browser_id: int | str | None, + *, + browsers: list[dict[str, Any]] | None = None, + viewer_id: str = "", + state: dict[str, Any] | None = None, + viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT, + ) -> None: + browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers or []) + await self._emit_to_connected_viewer( + sid, + "browser_viewer_state", + { + "context_id": context_id, + "active_browser_context_id": context_id, + "viewer_id": viewer_id, + "browser_id": browser_id, + "active_browser_id": browser_id, + "browsers": browsers or [], + "state": state, + "all_browsers": all_browsers, + "tab_scope": tab_scope, + "viewer_transport": viewer_transport, + }, + ) + async def _emit_empty_frame( self, sid: str, @@ -484,8 +767,10 @@ class WsBrowser(WsHandler): *, browsers: list[dict[str, Any]] | None = None, viewer_id: str = "", + frame_source: str = "", ) -> None: - await self.emit_to( + browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers or []) + await self._emit_to_connected_viewer( sid, "browser_viewer_frame", { @@ -493,12 +778,47 @@ class WsBrowser(WsHandler): "viewer_id": viewer_id, "browser_id": None, "browsers": browsers or [], + "all_browsers": all_browsers, + "tab_scope": tab_scope, "image": "", "mime": "", "state": None, + "frame_source": frame_source, + "viewer_transport": frame_source or VIEWER_TRANSPORT_SNAPSHOT, }, ) + async def _emit_to_connected_viewer( + self, + sid: str, + event: str, + data: dict[str, Any], + ) -> None: + manager = getattr(self, "_manager", None) + if manager is not None: + with manager.lock: + connected = (getattr(self, "namespace", "/ws"), sid) in manager.connections + if not connected: + raise asyncio.CancelledError() + await self.emit_to(sid, event, data) + + @staticmethod + def _viewer_transport(data: dict[str, Any]) -> str: + raw = ( + data.get("viewer_transport") + or data.get("viewerTransport") + or data.get("surface_transport") + or data.get("surfaceTransport") + or data.get("transport") + or "" + ) + normalized = str(raw or "").strip().lower().replace("-", "_") + if normalized == "live": + normalized = VIEWER_TRANSPORT_SCREENCAST + if normalized in VIEWER_TRANSPORTS: + return normalized + return VIEWER_TRANSPORT_SNAPSHOT + @staticmethod def _viewport_from_data(data: dict[str, Any]) -> dict[str, int] | None: try: @@ -513,6 +833,20 @@ class WsBrowser(WsHandler): "height": max(200, min(4096, height)), } + @staticmethod + def _capture_scale_from_data(data: dict[str, Any]) -> float: + try: + scale = float( + data.get("device_pixel_ratio") + or data.get("devicePixelRatio") + or data.get("pixel_ratio") + or data.get("pixelRatio") + or 1 + ) + except (TypeError, ValueError): + return 1.0 + return max(1.0, min(2.0, scale)) + @staticmethod def _context_id(data: dict[str, Any]) -> str: return str(data.get("context_id") or data.get("context") or "").strip() diff --git a/plugins/_browser/assets/browser-page-content.js b/plugins/_browser/assets/browser-page-content.js index 65a798ac6..f583b4493 100644 --- a/plugins/_browser/assets/browser-page-content.js +++ b/plugins/_browser/assets/browser-page-content.js @@ -1,7 +1,7 @@ (() => { const GLOBAL_KEY = "__spaceBrowserPageContent__"; const DOM_HELPER_KEY = "__spaceBrowserDomHelper__"; - const VERSION = "12"; + const VERSION = "13"; const REQUIRED_API_NAMES = Object.freeze([ "annotate", "boundingBoxFor", @@ -3103,8 +3103,12 @@ function buildActionResult(entry, extra = {}) { return { + actionStrategy: entry.helperBacked ? "frame_chain_ref" : "dom_ref", captureId: state.captureId, descriptorTags: Array.isArray(entry?.descriptorTags) ? entry.descriptorTags.slice() : [], + frameChain: Array.isArray(entry?.frameChain) ? entry.frameChain.slice() : [], + frameId: entry.frameId || "", + nodeId: entry.nodeId || "", referenceId: entry.referenceId, semanticTags: Array.isArray(entry?.semanticTags) ? entry.semanticTags.slice() : [], state: entry.state || collectElementStateMetadata(entry.element, state.captureOptions), @@ -3116,6 +3120,7 @@ function buildHelperBackedActionResult(entry, helperResult, extra = {}) { return { + actionStrategy: "frame_chain_ref", captureId: state.captureId, descriptorTags: Array.isArray(helperResult?.descriptorTags) ? helperResult.descriptorTags : (entry.descriptorTags || []), frameChain: entry.frameChain.slice(), diff --git a/plugins/_browser/default_config.yaml b/plugins/_browser/default_config.yaml index b87c040e1..896239304 100644 --- a/plugins/_browser/default_config.yaml +++ b/plugins/_browser/default_config.yaml @@ -8,6 +8,11 @@ default_homepage: "about:blank" # When the Browser surface is already open, keep it synced to agent Browser tool results. autofocus_active_page: true +# Browser tab visibility in the WebUI: +# - per_context: each chat shows only its own Browser tabs. +# - shared: show Browser tabs from all active chats. +browser_tab_scope: "per_context" + # Maximum number of Browser tabs/pages a single chat context may keep open. # Raise this only for deliberate parallel browsing workflows. max_open_tabs: 32 @@ -28,6 +33,11 @@ host_browser_privacy_policy: "allow" # - agent: use a clean A0-controlled browser profile on the host. host_browser_profile_mode: "existing" +# Optional host browser target when using an existing browser. +# Empty means A0 CLI chooses the first supported/active browser. +# Values may be browser family ids (chrome, edge, chromium) or CLI-advertised ids/endpoints. +host_browser_selection: "" + # Optional _model_config preset used by Browser-owned model helpers. # Empty uses the effective Main Model. model_preset: "" diff --git a/plugins/_browser/helpers/config.py b/plugins/_browser/helpers/config.py index f39e52e51..5279f6215 100644 --- a/plugins/_browser/helpers/config.py +++ b/plugins/_browser/helpers/config.py @@ -11,13 +11,17 @@ PLUGIN_NAME = "_browser" MODEL_PRESET_KEY = "model_preset" DEFAULT_HOMEPAGE_KEY = "default_homepage" AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page" +TAB_SCOPE_KEY = "browser_tab_scope" MAX_OPEN_TABS_KEY = "max_open_tabs" RUNTIME_BACKEND_KEY = "runtime_backend" HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy" HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode" +HOST_BROWSER_SELECTION_KEY = "host_browser_selection" RUNTIME_BACKENDS = {"container", "host_required"} +BROWSER_TAB_SCOPES = {"per_context", "shared"} HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"} HOST_BROWSER_PROFILE_MODES = {"existing", "agent"} +DEFAULT_BROWSER_TAB_SCOPE = "per_context" DEFAULT_MAX_OPEN_TABS = 32 MIN_MAX_OPEN_TABS = 1 HARD_MAX_OPEN_TABS = 50 @@ -55,6 +59,15 @@ def _normalize_model_preset(value: Any) -> str: return str(value or "").strip() +def _normalize_host_browser_selection(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + normalized = raw.lower().replace(" ", "_") + # Keep explicit CLI ids/ports/endpoints usable while avoiding control characters. + return "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-", ":", ".", "/"})[:200] + + def _normalize_default_homepage(value: Any) -> str: homepage = str(value or "").strip() return homepage or "about:blank" @@ -117,6 +130,11 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]: raw.get(AUTOFOCUS_ACTIVE_PAGE_KEY, True), default=True, ), + TAB_SCOPE_KEY: _normalize_choice( + raw.get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE), + allowed=BROWSER_TAB_SCOPES, + default=DEFAULT_BROWSER_TAB_SCOPE, + ), MAX_OPEN_TABS_KEY: _normalize_int( raw.get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS), default=DEFAULT_MAX_OPEN_TABS, @@ -136,6 +154,9 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]: allowed=HOST_BROWSER_PROFILE_MODES, default="existing", ), + HOST_BROWSER_SELECTION_KEY: _normalize_host_browser_selection( + raw.get(HOST_BROWSER_SELECTION_KEY, raw.get("host_browser_choice", "")) + ), MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")), } diff --git a/plugins/_browser/helpers/connector_runtime.py b/plugins/_browser/helpers/connector_runtime.py index 82107aa7b..d5f05cb71 100644 --- a/plugins/_browser/helpers/connector_runtime.py +++ b/plugins/_browser/helpers/connector_runtime.py @@ -57,8 +57,13 @@ HOST_BROWSER_PROFILE_MODE_KEY = getattr( "HOST_BROWSER_PROFILE_MODE_KEY", "host_browser_profile_mode", ) +HOST_BROWSER_SELECTION_KEY = getattr( + browser_config, + "HOST_BROWSER_SELECTION_KEY", + "host_browser_selection", +) get_browser_config = browser_config.get_browser_config -_LOCAL_PROVIDERS = {"ollama", "lm_studio"} +_LOCAL_PROVIDERS = {"ollama", "lm_studio", "llama_cpp", "omlx", "vllm"} _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"} _SENSITIVE_ACTIONS = {"content", "detail", "evaluate", "screenshot", "screenshot_file"} _KEY_ALIASES = { @@ -78,7 +83,8 @@ _REQUIRED_API_NAMES_RE = re.compile( re.S, ) _HOST_BROWSER_REMOTE_DEBUGGING_HELP = ( - 'For an already-open Chrome-family browser, open `chrome://inspect/#remote-debugging`, ' + "For an already-open Chromium-family browser, open its inspect page, such as " + "`chrome://inspect/#remote-debugging` or `opera://inspect/#remote-debugging`, " 'enable "Allow remote debugging for this browser instance", run `/browser host on`, ' "and retry." ) @@ -123,6 +129,7 @@ class ConnectorBrowserRuntime: "context_id": self.context_id, "action": action, "profile_mode": self._host_browser_profile_mode(), + "browser_selection": self._host_browser_selection(), } if action == "open": @@ -283,6 +290,7 @@ class ConnectorBrowserRuntime: "context_id": self.context_id, "action": "ensure", "profile_mode": self._host_browser_profile_mode(), + "browser_selection": self._host_browser_selection(), }, ), ) @@ -295,6 +303,10 @@ class ConnectorBrowserRuntime: mode = str(config.get(HOST_BROWSER_PROFILE_MODE_KEY) or "existing").strip().lower() return "agent" if mode == "agent" else "existing" + def _host_browser_selection(self) -> str: + config = get_browser_config(self.agent) + return str(config.get(HOST_BROWSER_SELECTION_KEY) or "").strip() + def _with_content_helper(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]: return self._with_browser_helpers(sid, payload) @@ -508,7 +520,10 @@ class ConnectorBrowserRuntime: if not message: message = "Host browser operation failed" normalized = message.lower() - if "chrome://inspect/#remote-debugging" in normalized: + if ( + "chrome://inspect/#remote-debugging" in normalized + or "opera://inspect/#remote-debugging" in normalized + ): return _append_docker_browser_recovery(message) if any(token in normalized for token in _REMOTE_DEBUGGING_ERROR_TOKENS): return _append_docker_browser_recovery( diff --git a/plugins/_browser/helpers/runtime.py b/plugins/_browser/helpers/runtime.py index e7455d81a..994c6f04e 100644 --- a/plugins/_browser/helpers/runtime.py +++ b/plugins/_browser/helpers/runtime.py @@ -317,8 +317,11 @@ class _BrowserScreencast: self.browser_id = browser_id self.session = session self.mime = mime + self.frame_consumer: Any | None = None + self.stop_callback: Any | None = None self.queue = asyncio.Queue(maxsize=1) self.stopped = False + self._closed = False self._ack_tasks: set[asyncio.Task] = set() self._expected_width = 0 self._expected_height = 0 @@ -329,10 +332,14 @@ class _BrowserScreencast: quality: int, every_nth_frame: int, viewport: dict[str, int], + capture_scale: float = 1.0, ) -> None: self.session.on("Page.screencastFrame", self._on_frame) width = max(320, min(4096, int(viewport.get("width") or DEFAULT_VIEWPORT["width"]))) height = max(200, min(4096, int(viewport.get("height") or DEFAULT_VIEWPORT["height"]))) + scale = max(1.0, min(2.0, float(capture_scale or 1.0))) + max_width = max(320, min(SCREENCAST_MAX_WIDTH, int(round(width * scale)))) + max_height = max(200, min(SCREENCAST_MAX_HEIGHT, int(round(height * scale)))) self._expected_width = width self._expected_height = height with contextlib.suppress(Exception): @@ -343,8 +350,8 @@ class _BrowserScreencast: { "format": "jpeg", "quality": max(20, min(95, int(quality))), - "maxWidth": SCREENCAST_MAX_WIDTH, - "maxHeight": SCREENCAST_MAX_HEIGHT, + "maxWidth": max_width, + "maxHeight": max_height, "everyNthFrame": max(1, int(every_nth_frame)), }, ) @@ -394,10 +401,21 @@ class _BrowserScreencast: raise RuntimeError("Browser screencast stopped.") return frame + async def attach_consumer(self, frame_consumer: Any, stop_callback: Any | None = None) -> None: + self.frame_consumer = frame_consumer + self.stop_callback = stop_callback + frame = await self.pop_frame() + if frame: + await self._deliver_frame(frame) + async def stop(self) -> None: - if self.stopped: + if self._closed: return + was_stopped = self.stopped + self._closed = True self.stopped = True + if not was_stopped: + self._notify_stopped() self._drop_queued_frames() with contextlib.suppress(asyncio.QueueFull): self.queue.put_nowait(None) @@ -419,6 +437,8 @@ class _BrowserScreencast: task.add_done_callback(self._ack_tasks.discard) async def _handle_frame(self, params: dict[str, Any]) -> None: + stop_after_ack = False + notify_stop = False try: data = params.get("data") or "" if data: @@ -428,7 +448,7 @@ class _BrowserScreencast: metadata["jpegWidth"], metadata["jpegHeight"] = size metadata["expectedWidth"] = self._expected_width metadata["expectedHeight"] = self._expected_height - self._queue_latest( + await self._deliver_frame( { "browser_id": self.browser_id, "mime": self.mime, @@ -436,6 +456,14 @@ class _BrowserScreencast: "metadata": metadata, } ) + except asyncio.CancelledError: + stop_after_ack = True + except Exception: + if self.frame_consumer: + stop_after_ack = True + notify_stop = True + else: + raise finally: session_id = params.get("sessionId") if session_id is not None and not self.stopped: @@ -444,6 +472,24 @@ class _BrowserScreencast: "Page.screencastFrameAck", {"sessionId": int(session_id)}, ) + if stop_after_ack: + self.stopped = True + if notify_stop: + self._notify_stopped() + + def _notify_stopped(self) -> None: + if not self.stop_callback: + return + with contextlib.suppress(Exception): + self.stop_callback() + + async def _deliver_frame(self, frame: dict[str, Any]) -> None: + if not self.frame_consumer: + self._queue_latest(frame) + return + future = self.frame_consumer(frame) + if future is not None: + await asyncio.wrap_future(future) def _queue_latest(self, frame: dict[str, Any]) -> None: self._drop_queued_frames() @@ -1616,6 +1662,7 @@ class _BrowserRuntimeCore: *, quality: int = 78, every_nth_frame: int = 1, + capture_scale: float = 1.0, ) -> dict[str, Any]: await self.ensure_started() resolved_id = self._resolve_browser_id(browser_id) @@ -1634,6 +1681,7 @@ class _BrowserRuntimeCore: quality=quality, every_nth_frame=every_nth_frame, viewport=page.viewport_size or DEFAULT_VIEWPORT, + capture_scale=capture_scale, ) except Exception: self.screencasts.pop(stream_id, None) @@ -1663,6 +1711,17 @@ class _BrowserRuntimeCore: raise KeyError("Browser screencast is not active.") return await screencast.pop_frame() + async def attach_screencast_consumer( + self, + stream_id: str, + frame_consumer: Any, + stop_callback: Any | None = None, + ) -> None: + screencast = self.screencasts.get(str(stream_id or "")) + if not screencast: + raise KeyError("Browser screencast is not active.") + await screencast.attach_consumer(frame_consumer, stop_callback) + async def stop_screencast(self, stream_id: str) -> None: screencast = self.screencasts.pop(str(stream_id or ""), None) if screencast: diff --git a/plugins/_browser/prompts/agent.system.tool.browser.md b/plugins/_browser/prompts/agent.system.tool.browser.md index 4ecfd4660..402ef6efa 100644 --- a/plugins/_browser/prompts/agent.system.tool.browser.md +++ b/plugins/_browser/prompts/agent.system.tool.browser.md @@ -3,36 +3,15 @@ Rendered browser automation for pages that need interaction, JavaScript, forms, Prefer `search_engine` or `document_query` for plain text research. The tool must not open a Browser surface automatically. Use the tool headlessly unless the user opens the Browser surface or asks for the optional visible WebUI viewer. -The browser may run in Docker container mode or A0 CLI host-browser mode depending on settings. Container-mode paths resolve inside Agent Zero; host-mode paths resolve on the connected A0 CLI host. +When the user asks for "my browser", "host browser", "local browser", a local Chromium browser, or opening a URL in their host browser, use this `browser` tool. Do not substitute `computer_use_remote`, `code_execution_remote`, `xdg-open`, `sensible-browser`, or Python `webbrowser.open`. If setup fails and mentions remote debugging, tell the user to open the browser inspect page, such as `chrome://inspect/#remote-debugging` or `opera://inspect/#remote-debugging`, enable "Allow remote debugging for this browser instance", run `/browser host on`, and retry. -When the user asks for "my browser", "host browser", "local browser", local Chrome, or opening a URL in their host browser, use this `browser` tool. Do not substitute `computer_use_remote`, `code_execution_remote`, or host shell launchers such as `xdg-open`, `sensible-browser`, or Python `webbrowser.open`. If host-browser setup fails and mentions remote debugging, stop and tell the user to open `chrome://inspect/#remote-debugging`, enable "Allow remote debugging for this browser instance", run `/browser host on`, and retry. +For rendered browsing workflows, multi-step interaction, screenshots, downloads, uploads, forms, or host/container mode decisions, first load `browser-automation` with `skills_tool:load`, then call this tool using the loaded instructions. For fragile forms, `browser-automation` links to `browser-form-workflows`; load it when selects, checkboxes, radios, uploads, contenteditable fields, validation, or submission state are central. -For complex browser workflows, load skill `browser-automation`. For fragile forms, load skill `browser-form-workflows`. +Actions: tabs `open`, `list`, `state`, `set_active`, `navigate`, `back`, `forward`, `reload`, `close`, `close_all`; inspect `content`, `detail`, `screenshot`; interact `click`, `hover`, `double_click`, `right_click`, `drag`, `type`, `submit`, `type_submit`, `scroll`, `select_option`, `set_checked`, `upload_file`; advanced `evaluate`, `key_chord`, `mouse`, `wheel`, `keyboard`, `clipboard`, `set_viewport`, `multi`. -Actions: `open`, `list`, `state`, `set_active`, `navigate`, `back`, `forward`, `reload`, `content`, `detail`, `screenshot`, `click`, `hover`, `double_click`, `right_click`, `drag`, `type`, `submit`, `type_submit`, `scroll`, `evaluate`, `key_chord`, `mouse`, `wheel`, `keyboard`, `clipboard`, `set_viewport`, `select_option`, `set_checked`, `upload_file`, `multi`, `close`, `close_all`. - -Common args: `action`, `browser_id`, `url`, `ref`, `target_ref`, `text`, `selector`, `selectors`, `script`, `modifiers`, `keys`, `key`, `include_content`, `focus_popup`, `event_type`, `x`, `y`, `to_x`, `to_y`, `delta_x`, `delta_y`, `button`, `quality`, `full_page`, `path`, `paths`, `value`, `values`, `checked`, `width`, `height`, `calls`. - -Workflow: -- `open` creates a tab and returns id/state. In host-browser mode, if the requested URL is already open, the host may reuse and activate that existing tab. -- If the user asks for an existing tab, page title, or already-open URL, call `list` first, match by `title` or `currentUrl`, then use `set_active` or `navigate` on that `browser_id` instead of opening a new tab. -- `content` returns markdown with refs like `[link 3]`, `[button 6]`, `[input text 8]`. -- Interactions use refs from the latest `content` capture. -- For same-page controls that are easier to identify structurally, `click`, `type`, `submit`, `type_submit`, `scroll`, `select_option`, `set_checked`, and `upload_file` may use `selector` instead of `ref`; the tool resolves the selector through `content` first. -- `click` with `x`/`y` and no `ref` is treated as a coordinate mouse click. `type` with text and no `ref` types into the currently focused element. `key_chord` accepts either `["Control", "A"]` or `"CTRL+A"`. -- `navigate` reuses an existing `browser_id` and is preferred for serial browsing. -- Screenshots are explicit only; the browser does not automatically load screenshots. Call `vision_load` with the returned `vision_load.tool_args.paths` value before reasoning visually. When no `path` is requested, browser screenshots are saved as chat-scoped artifacts; explicit `path` requests remain user-owned files. +Rules: +- If the user asks for an existing tab, page title, or already-open URL, call `list` first, match by `title` or `currentUrl`, then use `set_active` or `navigate` on that `browser_id`. +- Prefer DOM/CDP actions: use refs from the latest `content`, including frame-chain refs, or same-page `selector`; use coordinates only when refs/selectors are unavailable or the user explicitly asks for visual manual control. +- Screenshots are explicit only; the browser does not automatically load screenshots. Call `vision_load` with the returned `vision_load.tool_args.paths` value before reasoning visually. - Keep the tab set small; close pages after extracting what you need. - -`multi` is only a browser action: use `tool_name: "browser"` with `tool_args.action: "multi"`. Never use `tool_name: "multi"`. - -Example: -~~~json -{ - "tool_name": "browser", - "tool_args": { - "action": "open", - "url": "https://example.com" - } -} -~~~ +- `multi` is only a browser action: use `tool_name: "browser"` with `tool_args.action: "multi"`. Never use `tool_name: "multi"`. diff --git a/plugins/_browser/skills/browser-automation/SKILL.md b/plugins/_browser/skills/browser-automation/SKILL.md index b79d0e42b..6a09f56a6 100644 --- a/plugins/_browser/skills/browser-automation/SKILL.md +++ b/plugins/_browser/skills/browser-automation/SKILL.md @@ -1,19 +1,39 @@ --- name: browser-automation description: Use for complex Agent Zero browser automation, including multi-tab browsing, screenshots, forms, uploads, raw pointer/keyboard actions, host-vs-container browser mode, and visual verification workflows. +triggers: + - "browser automation" + - "web automation" + - "open website" + - "open URL" + - "navigate browser" + - "interact with web page" + - "JavaScript page" + - "browser screenshot" + - "screenshot webpage" + - "visual verification" + - "multi-tab browsing" + - "download file from website" + - "upload file in browser" + - "host browser" + - "local browser" + - "my browser" --- # Browser Tool -Use the `browser` tool for rendered pages, forms, logins, downloads, JavaScript-heavy sites, screenshots, and visual inspection. Prefer `search_engine` or `document_query` for plain text research. +Use this skill after the compact `browser` tool prompt points you here. It is the progressive-disclosure workflow guide for rendered pages, multi-step browser work, logins, downloads, JavaScript-heavy sites, screenshots, host/container browser mode, and visual inspection. Prefer `search_engine` or `document_query` for plain text research. + +For fragile forms, load `browser-form-workflows` with `skills_tool:load` before acting when selects, checkboxes, radios, file uploads, contenteditable fields, validation, or final submission state are central to the task. ## Core Workflow 1. `open` creates a browser tab and returns a `browser_id`. 2. `content` returns readable markdown plus typed refs like `[link 3]`, `[button 6]`, `[input text 8]`. -3. Interact with refs using `click`, `type`, `submit`, `scroll`, etc. +3. Interact with refs using `click`, `type`, `submit`, `scroll`, etc.; iframe/shadow targets may return frame-chain metadata in action results. 4. Use `navigate` on an existing `browser_id` for serial browsing. 5. Keep only a small working tab set; close pages when finished. +6. If the user asks for an existing tab, page title, or already-open URL, call `list` first, match by `title` or `currentUrl`, then use `set_active` or `navigate` on that `browser_id` instead of opening a new tab. ## Modes @@ -39,12 +59,13 @@ Screenshot args include `quality`, `full_page`, and optional `path`. Without `pa - `select_option` works for native selects and detectable ARIA listbox/combobox controls. - `set_checked` works for checkbox, radio, switch, and toggle-like refs. - `upload_file` works for file input refs or associated labels; verify the file exists in the active browser environment. -- For fragile forms, load skill `browser-form-workflows`. +- For fragile forms, call `skills_tool` with `action: "load"` and `skill_name: "browser-form-workflows"`, then follow that form-specific workflow before filling or submitting. ## Pointer And Keyboard -- `hover`, `double_click`, `right_click`, and `drag` accept refs or viewport coordinates. -- Coordinates are Chromium viewport CSS pixels and match screenshots. +- Prefer refs/selectors and DOM/CDP actions over viewport coordinates. +- `hover`, `double_click`, `right_click`, and `drag` accept refs or viewport coordinates when no reliable ref exists. +- Coordinates are Chromium viewport CSS pixels and match screenshots; treat them as visual fallback, not the default interaction path. - `key_chord` presses keys in order and releases in reverse. - `clipboard` actions are copy, cut, or paste. - `set_viewport` resizes the page viewport. diff --git a/plugins/_browser/skills/browser-form-workflows/SKILL.md b/plugins/_browser/skills/browser-form-workflows/SKILL.md index 4dffd483d..0a199d4bb 100644 --- a/plugins/_browser/skills/browser-form-workflows/SKILL.md +++ b/plugins/_browser/skills/browser-form-workflows/SKILL.md @@ -1,11 +1,24 @@ --- name: browser-form-workflows description: Use for complex Agent Zero Browser form workflows involving selects, checkboxes, radios, file uploads, contenteditable fields, multi-step validation, or visually verified submission. +triggers: + - "browser form" + - "web form" + - "fill form" + - "fill web form" + - "submit form" + - "select option" + - "dropdown" + - "checkbox" + - "radio button" + - "file upload" + - "contenteditable" + - "form validation" --- # Browser Forms -Use this skill for complex Browser form workflows where the page state may depend on selects, checkboxes, radios, file uploads, contenteditable fields, validation, or visual confirmation. +Use this skill as the form-specific extension of `browser-automation`. Load it after, or alongside, `browser-automation` when the page state may depend on selects, checkboxes, radios, file uploads, contenteditable fields, validation, or visual confirmation. Start with `browser:content` to capture current refs, then use `browser:detail` on ambiguous fields before acting. Prefer ref-based form actions before coordinates. diff --git a/plugins/_browser/webui/browser-config-store.js b/plugins/_browser/webui/browser-config-store.js index 81fee2e85..4f32fe0ac 100644 --- a/plugins/_browser/webui/browser-config-store.js +++ b/plugins/_browser/webui/browser-config-store.js @@ -4,8 +4,14 @@ import { callJsonApi } from "/js/api.js"; const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions"; const BROWSER_STATUS_API = "/plugins/_browser/status"; const RUNTIME_BACKENDS = new Set(["container", "host_required"]); +const BROWSER_TAB_SCOPES = new Set(["per_context", "shared"]); const HOST_PRIVACY_POLICIES = new Set(["enforce_local", "warn", "allow"]); const HOST_PROFILE_MODES = new Set(["existing", "agent"]); +const DEFAULT_MAX_OPEN_TABS = 32; +const MIN_MAX_OPEN_TABS = 1; +const HARD_MAX_OPEN_TABS = 50; +const HOST_BROWSER_STATUS_REFRESH_MS = 1000; +const CUSTOM_HOST_BROWSER_SELECTION = "__custom_endpoint__"; function normalizePathList(value) { const source = Array.isArray(value) @@ -27,6 +33,8 @@ function ensureConfig(config) { config.extension_paths = normalizePathList(config.extension_paths); config.default_homepage = String(config.default_homepage || "about:blank").trim() || "about:blank"; config.autofocus_active_page = normalizeBoolean(config.autofocus_active_page, true); + config.browser_tab_scope = normalizeChoice(config.browser_tab_scope, BROWSER_TAB_SCOPES, "per_context"); + config.max_open_tabs = normalizeInt(config.max_open_tabs, DEFAULT_MAX_OPEN_TABS, MIN_MAX_OPEN_TABS, HARD_MAX_OPEN_TABS); config.runtime_backend = normalizeRuntimeBackend(config.runtime_backend); config.host_browser_privacy_policy = normalizeChoice( config.host_browser_privacy_policy, @@ -38,6 +46,7 @@ function ensureConfig(config) { HOST_PROFILE_MODES, "existing", ); + config.host_browser_selection = normalizeHostBrowserSelection(config.host_browser_selection); config.model_preset = String(config.model_preset || "").trim(); delete config.model; return config; @@ -48,12 +57,55 @@ function normalizeChoice(value, allowed, fallback) { return allowed.has(normalized) ? normalized : fallback; } +function normalizeInt(value, fallback, minimum, maximum) { + const number = Number.parseInt(value, 10); + if (!Number.isFinite(number)) return fallback; + return Math.max(minimum, Math.min(maximum, number)); +} + function normalizeRuntimeBackend(value) { const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_"); if (normalized === "host_when_available") return "host_required"; return RUNTIME_BACKENDS.has(normalized) ? normalized : "container"; } +function normalizeHostBrowserSelection(value) { + return String(value || "").trim().toLowerCase().replace(/\s+/g, "_").slice(0, 200); +} + +function normalizeCustomHostBrowserEndpoint(value) { + const raw = String(value || "").trim(); + if (!raw) return ""; + const candidate = raw.includes("://") ? raw : `ws://${raw}`; + try { + const url = new URL(candidate); + if (!["ws:", "wss:"].includes(url.protocol) || !url.host || !url.pathname.startsWith("/devtools/browser/")) { + return ""; + } + return normalizeHostBrowserSelection(`${url.protocol}//${url.host}${url.pathname}${url.search || ""}`); + } catch (_error) { + return ""; + } +} + +function isCustomHostBrowserEndpoint(value) { + return Boolean(normalizeCustomHostBrowserEndpoint(value)); +} + +function debugPortVersionUrl(value) { + const raw = String(value || "").trim(); + if (!raw) return ""; + const candidate = raw.includes("://") ? raw : `ws://${raw}`; + try { + const url = new URL(candidate); + if (!["ws:", "wss:"].includes(url.protocol) || !url.host) return ""; + if (url.pathname && url.pathname !== "/") return ""; + return `http://${url.host}/json/version`; + } catch (_error) { + return ""; + } +} + function normalizeBoolean(value, fallback = true) { if (value === undefined || value === null || value === "") return fallback; if (typeof value === "boolean") return value; @@ -74,6 +126,9 @@ function hostBrowserFamilyLabel(value) { chromium: "Chromium", edge: "Edge", "edge-dev": "Edge Dev", + brave: "Brave", + opera: "Opera", + vivaldi: "Vivaldi", }; const label = labels[base] || "Host browser"; if (remoteDebugging) return `${label} (allowed)`; @@ -99,13 +154,18 @@ export const store = createStore("browserConfig", { extensionDeleteLoadingPath: "", hostBrowserStatus: null, hostBrowserStatusLoading: false, + hostBrowserStatusRefreshTimer: null, + hostBrowserCustomEndpoint: "", + hostBrowserCustomMode: false, async init(config) { this.bindConfig(config); await Promise.all([this.loadExtensionsList(), this.loadHostBrowserStatus()]); + this.startHostBrowserStatusRefresh(); }, cleanup() { + this.stopHostBrowserStatusRefresh(); this.config = null; this.extensionsList = []; this.extensionsError = ""; @@ -113,6 +173,22 @@ export const store = createStore("browserConfig", { this.extensionDeleteLoadingPath = ""; this.hostBrowserStatus = null; this.hostBrowserStatusLoading = false; + this.hostBrowserCustomEndpoint = ""; + this.hostBrowserCustomMode = false; + }, + + startHostBrowserStatusRefresh() { + this.stopHostBrowserStatusRefresh(); + this.hostBrowserStatusRefreshTimer = window.setInterval( + () => this.loadHostBrowserStatus(), + HOST_BROWSER_STATUS_REFRESH_MS, + ); + }, + + stopHostBrowserStatusRefresh() { + if (!this.hostBrowserStatusRefreshTimer) return; + window.clearInterval(this.hostBrowserStatusRefreshTimer); + this.hostBrowserStatusRefreshTimer = null; }, bindConfig(config) { @@ -120,6 +196,9 @@ export const store = createStore("browserConfig", { if (!safeConfig) return; if (this.config === safeConfig) return; this.config = safeConfig; + if (isCustomHostBrowserEndpoint(safeConfig.host_browser_selection)) { + this.hostBrowserCustomEndpoint = safeConfig.host_browser_selection; + } }, setAutofocusActivePage(enabled) { @@ -132,6 +211,27 @@ export const store = createStore("browserConfig", { return this.config?.autofocus_active_page === false ? "Off" : "On"; }, + setBrowserTabScope(value) { + const safeConfig = ensureConfig(this.config); + if (!safeConfig) return; + safeConfig.browser_tab_scope = normalizeChoice(value, BROWSER_TAB_SCOPES, "per_context"); + }, + + browserTabScopeLabel() { + return this.config?.browser_tab_scope === "shared" ? "Shared" : "Per chat"; + }, + + normalizeMaxOpenTabs() { + const safeConfig = ensureConfig(this.config); + if (!safeConfig) return; + safeConfig.max_open_tabs = normalizeInt( + safeConfig.max_open_tabs, + DEFAULT_MAX_OPEN_TABS, + MIN_MAX_OPEN_TABS, + HARD_MAX_OPEN_TABS, + ); + }, + runtimeBackendLabel() { const value = this.config?.runtime_backend || "container"; if (value === "host_required") return "Bring Your Own Browser"; @@ -145,6 +245,93 @@ export const store = createStore("browserConfig", { return "Local Models Only"; }, + hostBrowserOptions() { + const connectors = Array.isArray(this.hostBrowserStatus?.connectors) + ? this.hostBrowserStatus.connectors + : []; + const options = [{ value: "", label: "Automatic (A0 CLI chooses)" }]; + const seen = new Set([""]); + for (const connector of connectors) { + const advertised = Array.isArray(connector?.available_browsers) + ? connector.available_browsers + : []; + for (const browser of advertised) { + const value = normalizeCustomHostBrowserEndpoint(browser?.cdp_endpoint || browser?.id); + if (!value || seen.has(value)) continue; + seen.add(value); + const label = browser?.label || hostBrowserFamilyLabel(browser?.family || value); + const status = browser?.status ? ` - ${hostBrowserStatusLabel(browser.status)}` : ""; + options.push({ value, label: `${label}${status}` }); + } + const fallbackValue = normalizeCustomHostBrowserEndpoint(connector?.cdp_endpoint || connector?.browser_id); + if (fallbackValue && !seen.has(fallbackValue)) { + seen.add(fallbackValue); + const label = connector?.browser_label || hostBrowserFamilyLabel(connector?.browser_family || fallbackValue); + options.push({ value: fallbackValue, label }); + } + } + const selected = normalizeHostBrowserSelection(this.config?.host_browser_selection); + if (selected && !seen.has(selected) && !isCustomHostBrowserEndpoint(selected)) { + seen.add(selected); + options.push({ value: selected, label: `Saved: ${selected}` }); + } + options.push({ value: CUSTOM_HOST_BROWSER_SELECTION, label: "Custom endpoint" }); + return options; + }, + + hostBrowserSelectValue() { + if (this.hostBrowserCustomMode) return CUSTOM_HOST_BROWSER_SELECTION; + const selected = normalizeHostBrowserSelection(this.config?.host_browser_selection); + if (!selected) return ""; + if (this.hostBrowserOptions().some((option) => option.value === selected)) return selected; + if (isCustomHostBrowserEndpoint(selected)) return CUSTOM_HOST_BROWSER_SELECTION; + return selected; + }, + + setHostBrowserSelection(value) { + const safeConfig = ensureConfig(this.config); + if (!safeConfig) return; + if (value === CUSTOM_HOST_BROWSER_SELECTION) { + this.hostBrowserCustomMode = true; + if (isCustomHostBrowserEndpoint(safeConfig.host_browser_selection)) { + this.hostBrowserCustomEndpoint = safeConfig.host_browser_selection; + } else { + safeConfig.host_browser_selection = ""; + } + return; + } + this.hostBrowserCustomMode = false; + safeConfig.host_browser_selection = normalizeHostBrowserSelection(value); + }, + + showCustomHostBrowserEndpoint() { + return this.hostBrowserSelectValue() === CUSTOM_HOST_BROWSER_SELECTION; + }, + + setCustomHostBrowserEndpoint(value) { + this.hostBrowserCustomMode = true; + this.hostBrowserCustomEndpoint = String(value || "").trim(); + const safeConfig = ensureConfig(this.config); + if (!safeConfig) return; + const endpoint = normalizeCustomHostBrowserEndpoint(this.hostBrowserCustomEndpoint); + if (endpoint || !this.hostBrowserCustomEndpoint) { + safeConfig.host_browser_selection = endpoint; + } + }, + + customHostBrowserEndpointDiagnostic() { + if (!this.hostBrowserCustomEndpoint) { + return "Paste a ws://.../devtools/browser/... endpoint from the browser inspect page."; + } + const endpoint = normalizeCustomHostBrowserEndpoint(this.hostBrowserCustomEndpoint); + if (endpoint) return `Using ${endpoint}`; + const versionUrl = debugPortVersionUrl(this.hostBrowserCustomEndpoint); + if (versionUrl) { + return `This looks like a debug port. Open ${versionUrl} and copy webSocketDebuggerUrl.`; + } + return "Endpoint must be a ws:// or wss:// URL ending in /devtools/browser/..."; + }, + hostBrowserProfileModeLabel() { const value = this.config?.host_browser_profile_mode || "existing"; if (value === "agent") return "Clean Agent Profile"; diff --git a/plugins/_browser/webui/browser-panel.html b/plugins/_browser/webui/browser-panel.html index c72112064..c92a1acec 100644 --- a/plugins/_browser/webui/browser-panel.html +++ b/plugins/_browser/webui/browser-panel.html @@ -16,7 +16,7 @@
-