Normalize tool contracts and slim prompt surface

Standardize multi-action tools around tool_args.action while keeping parser compatibility for older tool/args, tool_name:action, and method-shaped requests. This keeps new prompts clean without breaking agents that learned the previous dialect. Move A0 connector remote execution/file tools into stable standard prompts, make remote targeting independent of the active chat context, and skill-gate beta computer-use remote so it no longer weighs down the always-on tool list. Align text editor, scheduler, skills, office artifact, memory, notify, and browser prompts/tools around the canonical action contract. Add scheduler update/timezone handling, skills_tool read_file, text editor patch coverage, and fixes for memory_forget, behaviour_adjustment, and code execution progress warnings. Reduce default prompt pressure by compacting browser and scheduler prompts into skill-backed manifests, shortening skill catalog descriptions, and pruning noisy framework knowledge. Remove obsolete connector prompt stubs and root tool-call knowledge examples. Tests: conda run -n a0 pytest tests/test_a0_connector_prompt_gating.py tests/test_tool_action_contracts.py tests/test_task_scheduler_timezone.py tests/test_text_editor_context_patch.py tests/test_tool_request_normalization.py tests/test_office_document_store.py::test_odf_is_advertised_and_docx_remains_explicit_compatibility tests/test_office_document_store.py::test_document_artifact_accepts_method_alias_for_ods_create tests/test_skills_runtime.py tests/test_default_prompt_budget.py::test_a0_small_profile_removed_and_prompt_text_generic -q

Alessandro committed May 9, 2026 at 21:54 UTC daf95ec3abe6a08d4bf2a927bd54e45f58091ed0
55 files changed +1649 -1147
agent.py
-4
@@ -885,10 +885,6 @@ class Agent:
885 tool_name = raw_tool_name # Initialize tool_name with raw_tool_name
886 tool_method = None # Initialize tool_method
887
888 - # Split raw_tool_name into tool_name and tool_method if applicable
889 - if ":" in raw_tool_name:
890 - tool_name, tool_method = raw_tool_name.split(":", 1)
891 -
888 tool = None # Initialize tool to None
889
890 # Try getting tool from MCP first
extensions/python/system_prompt/_13_skills_prompt.py
+4 -2
@@ -26,8 +26,10 @@ async def build_prompt(agent: Agent) -> str:
26 result: list[str] = []
27 for skill in available:
28 name = skill.name.strip().replace("\n", " ")[:100]
29 - descr = skill.description.replace("\n", " ")[:500]
30 - result.append(f"**{name}** {descr}")
29 + descr = skill.description.replace("\n", " ").strip()
30 + if len(descr) > 100:
31 + descr = descr[:100].rstrip() + "..."
32 + result.append(f"- {name}: {descr}" if descr else f"- {name}")
33
34 if not result:
35 return ""
helpers/extract_tools.py
+9
@@ -33,6 +33,15 @@ def normalize_tool_request(tool_request: Any) -> tuple[str, dict]:
33 tool_args = tool_request.get("args")
34 if not isinstance(tool_args, dict):
35 raise ValueError("Tool request must have a tool_args (type dictionary) field")
36 + tool_args = dict(tool_args)
37 + if ":" in tool_name:
38 + tool_name, action = tool_name.split(":", 1)
39 + if not tool_name or not action:
40 + raise ValueError("tool_name method suffix must include tool and action")
41 + tool_args.setdefault("action", action)
42 + method = tool_args.get("method")
43 + if "action" not in tool_args and isinstance(method, str) and method:
44 + tool_args["action"] = method
45 return tool_name, tool_args
46
47
helpers/skills.py
+1 -1
@@ -434,7 +434,7 @@ def load_skill_for_agent(
434 files_tree = _get_skill_files(skill.path)
435 lines.append("")
436 if files_tree:
437 - lines.append("Files (use skills_tool method=read_file to open):")
437 + lines.append("Files (use skills_tool action=read_file to open):")
438 lines.append(files_tree)
439 else:
440 lines.append("No additional files found.")
helpers/task_scheduler.py
+28 -5
@@ -27,6 +27,19 @@ import pytz
27 from typing import Annotated
28
29 SCHEDULER_FOLDER = "usr/scheduler"
30 +LOCAL_TIMEZONE_ALIASES = {"local", "user", "default", "current", "current_timezone"}
31 +
32 +
33 +def normalize_schedule_timezone(timezone_name: str | None) -> str:
34 + name = str(timezone_name or "").strip()
35 + if not name or name.lower() in LOCAL_TIMEZONE_ALIASES:
36 + return Localization.get().get_timezone()
37 + try:
38 + pytz.timezone(name)
39 + except pytz.exceptions.UnknownTimeZoneError:
40 + PrintStyle.error(f"Unknown task schedule timezone: {name}, using current user timezone")
41 + return Localization.get().get_timezone()
42 + return name
43
44 # ----------------------
45 # Task Models
@@ -304,9 +317,9 @@ class ScheduledTask(BaseTask):
317 ):
318 # Set timezone in schedule if provided
319 if timezone is not None:
307 - schedule.timezone = timezone
320 + schedule.timezone = normalize_schedule_timezone(timezone)
321 else:
309 - schedule.timezone = Localization.get().get_timezone()
322 + schedule.timezone = normalize_schedule_timezone(schedule.timezone)
323
324 return cls(name=name,
325 system_prompt=system_prompt,
@@ -344,7 +357,8 @@ class ScheduledTask(BaseTask):
357 crontab = CronTab(crontab=self.schedule.to_crontab()) # type: ignore
358
359 # Get the timezone from the schedule or use UTC as fallback
347 - task_timezone = pytz.timezone(self.schedule.timezone or Localization.get().get_timezone())
360 + self.schedule.timezone = normalize_schedule_timezone(self.schedule.timezone)
361 + task_timezone = pytz.timezone(self.schedule.timezone)
362
363 # Get reference time in task's timezone (by default now - frequency_seconds)
364 reference_time = datetime.now(timezone.utc) - timedelta(seconds=frequency_seconds)
@@ -364,7 +378,15 @@ class ScheduledTask(BaseTask):
378 def get_next_run(self) -> datetime | None:
379 with self._lock:
380 crontab = CronTab(crontab=self.schedule.to_crontab()) # type: ignore
367 - return crontab.next(now=datetime.now(timezone.utc), return_datetime=True) # type: ignore
381 + self.schedule.timezone = normalize_schedule_timezone(self.schedule.timezone)
382 + task_timezone = pytz.timezone(self.schedule.timezone)
383 + now_in_task_timezone = datetime.now(timezone.utc).astimezone(task_timezone)
384 + next_run = crontab.next(now=now_in_task_timezone, return_datetime=True) # type: ignore
385 + if next_run is None:
386 + return None
387 + if next_run.tzinfo is None:
388 + next_run = task_timezone.localize(next_run)
389 + return next_run.astimezone(timezone.utc)
390
391
392 class PlannedTask(BaseTask):
@@ -1021,6 +1043,7 @@ def parse_datetime(dt_str: Optional[str]) -> Optional[datetime]:
1043
1044 def serialize_task_schedule(schedule: TaskSchedule) -> Dict[str, str]:
1045 """Convert TaskSchedule to a standardized dictionary format."""
1046 + schedule.timezone = normalize_schedule_timezone(schedule.timezone)
1047 return {
1048 'minute': schedule.minute,
1049 'hour': schedule.hour,
@@ -1040,7 +1063,7 @@ def parse_task_schedule(schedule_data: Dict[str, str]) -> TaskSchedule:
1063 day=schedule_data.get('day', '*'),
1064 month=schedule_data.get('month', '*'),
1065 weekday=schedule_data.get('weekday', '*'),
1043 - timezone=schedule_data.get('timezone', Localization.get().get_timezone())
1066 + timezone=normalize_schedule_timezone(schedule_data.get('timezone'))
1067 )
1068 except Exception as e:
1069 raise ValueError(f"Invalid schedule format: {e}") from e
knowledge/main/about/architecture.md
+13 -62
@@ -1,67 +1,18 @@
1 -# Agent Zero - Internal Architecture
1 +# Agent Zero Architecture
2
3 -## The Agent Loop (Monologue Cycle)
3 +The agent loop builds a system prompt, appends conversation history, asks the model for one JSON tool request, executes that tool, records the result, and repeats until `response` ends the task.
4
5 -Each agent runs a continuous monologue loop. On each cycle the agent receives its current context (system prompt + message history), produces a JSON response (thoughts, headline, tool name, tool args), and the framework executes the named tool. The tool result is appended to history and the loop continues until the agent calls `response` to deliver a final answer to its superior.
5 +Key runtime files:
6 +- `agent.py`: `Agent`, `AgentContext`, loop state, tool dispatch
7 +- `initialize.py`: framework initialization
8 +- `run_ui.py`: Web UI entry point
9 +- `helpers/`: shared framework helpers
10 +- `tools/`: core tools
11 +- `plugins/`: framework plugins
12 +- `usr/`: user data, custom plugins, settings, workdir
13
7 -The loop handles: message history management, context window limits (via summarization), memory recall injection, intervention from superiors, and error recovery (misformat retries, tool-not-found handling).
14 +Prompt assembly is file-based. Main prompts come from `prompts/`, profile overrides from `agents/<profile>/prompts/`, and plugin prompts from `plugins/<plugin>/prompts/`.
15
9 -## Context and State
16 +Plugins can add tools, prompts, API handlers, Web UI components, extensions, and hooks. User plugins live in `usr/plugins/` and should survive updates.
17
11 -`AgentContext` (defined in `agent.py`) is the central state container for a conversation. It holds:
12 -- Agent number and identifier
13 -- Message history
14 -- The active agent profile and prompt configuration
15 -- Reference to memory, knowledge, and tool systems
16 -- Project context if a project is active
17 -- `extras` dict - additional content injected into the system prompt each turn (memories, solutions, agent info, workdir structure)
18 -
19 -Each WebSocket session connects to one `AgentContext`. Multiple concurrent chats run in separate contexts. The framework is initialized in `initialize.py` and the server entry point is `run_ui.py`.
20 -
21 -## Prompt Assembly
22 -
23 -System prompts are assembled from fragment files on each loop iteration. The main system prompt is `prompts/agent.system.main.md`, which includes sub-prompts via `{{ include "filename.md" }}` directives. Agent profiles (in `agents/<profile>/prompts/`) can override individual fragments. This means a subordinate with the `developer` profile gets a different role and communication section while sharing the same tool list and solving workflow as the base agent.
24 -
25 -Prompt fragments are in `prompts/`. Plugin system prompts are in `plugins/<plugin>/prompts/`. The assembled system prompt is dynamic - it changes based on profile, active project, loaded tools, recalled memories, and injected extras.
26 -
27 -## Multi-Agent Hierarchy
28 -
29 -The hierarchy is a tree with the human user at the root. Each node is an agent instance running in its own context. A superior calls `call_subordinate` with a message and optional profile name; this creates a new `AgentContext` and runs the subordinate agent's loop until it returns a response.
30 -
31 -Agent 0 is always the top-level agent whose superior is the user. When Agent 0 delegates a task to a subordinate, that subordinate can itself delegate further. There is no enforced depth limit. Agents share the same tool system but each has its own isolated context and history.
32 -
33 -Subordinates can be given specific prompt profiles (`developer`, `researcher`, or any custom profile in `agents/`). Profiles change the role, communication style, and available instructions without changing the underlying framework.
34 -
35 -## Memory and Knowledge Pipeline
36 -
37 -### Knowledge (vector DB, read-only)
38 -Knowledge files (in `knowledge/` and `usr/knowledge/`) are loaded when a memory DB is initialized (normally at the start of the first monologue in a chat), embedded, and stored in a FAISS vector index per memory subdir. Files are tracked by checksum; only changed files are re-indexed. Supported formats: `.md`, `.txt`, `.pdf`, `.csv`, `.html`, `.json`.
39 -
40 -The memory areas are:
41 -- `main` - general knowledge and facts (files in knowledge root or `main/` subdir)
42 -- `fragments` - partial or supplementary knowledge
43 -- `solutions` - known solutions to problems
44 -
45 -### Recall (automatic, per conversation turn)
46 -The `RecallMemories` extension runs every N loop iterations (configurable). It queries the vector store using either the raw conversation or a utility-LLM-generated search query. Results from `main` and `fragments` areas plus `solutions` are injected into `loop_data.extras_persistent`, which gets rendered into the system prompt via `agent.context.extras.md` template.
47 -
48 -The agent sees recalled memories as a section in its system prompt labeled "Memories on the topic". The agent is instructed not to over-rely on them.
49 -
50 -### Agent memory (read-write, via memorize tool)
51 -The agent can explicitly save facts, solutions, and code snippets using the `memorize` tool. These are stored in the same FAISS index under the `main` or `solutions` area and recalled in future conversations. Memory can also be consolidated (summarized) and managed through the Memory Dashboard in the web UI.
52 -
53 -## Tool System
54 -
55 -Tools are Python classes in `python/tools/` that inherit from `Tool`. Each tool implements an `execute()` async method. Tools are discovered at startup and registered in the agent's tool list (rendered into the system prompt as `{{tools}}`). The agent names a tool in its JSON response; the framework finds and calls it.
56 -
57 -Plugin tools can be added in `plugins/<plugin>/tools/` or `usr/plugins/<plugin>/tools/` without modifying core files.
58 -
59 -## Extension and Plugin System
60 -
61 -The plugin system (`python/helpers/plugins.py`) discovers plugins from `plugins/` and `usr/plugins/`. Each plugin has a `plugin.yaml` manifest declaring name, version, and settings. Plugins can contribute: API handlers, tools, Web UI components, extensions, and hooks. User plugins in `usr/plugins/` are never overwritten by framework updates. The agent has skills to create, manage, debug, review and contribute plugins to the Plugin Index repository (https://github.com/agent0ai/a0-plugins)
62 -
63 -## Frontend Architecture
64 -
65 -The web UI is built with Alpine.js and ES module components. The main shell is `webui/index.html`. Components are in `webui/components/`. Frontend state is managed via Alpine stores defined with `createStore` from `/js/AlpineStore.js`.
66 -
67 -Real-time communication uses Socket.io WebSockets via a unified `/ws` namespace. WebSocket handlers (WsHandler subclasses) are in `api/ws_*.py`. The connection manager is in `helpers/ws_manager.py`. API handlers are in `api/`, each deriving from `ApiHandler` in `helpers/api.py`.
18 +Memory and knowledge use the memory plugin and vector search. Knowledge files are indexed for recall; they should be concise because irrelevant recall can steer behavior badly.
knowledge/main/about/capabilities.md
+19 -82
@@ -1,82 +1,19 @@
1 -# Agent Zero - Capabilities Reference
2 -
3 -## Code Execution
4 -
5 -The agent can write and execute code in any language available in the Docker container. The execution environment is a Kali Linux container with two Python runtimes:
6 -- `/opt/venv-a0` (Python 3.12) - the Agent Zero framework runtime
7 -- `/opt/venv` (Python 3.13) - the agent's execution runtime (default for agent-run code)
8 -
9 -The agent installs packages into the execution runtime (`/opt/venv`) via `pip install`. Packages needed by the framework itself must target `/opt/venv-a0`.
10 -
11 -Supported runtimes for code execution: Python, Node.js, Bash/shell. Other languages (Go, Rust, PHP, etc.) can be used if the compiler/runtime is installed in the container.
12 -
13 -Code runs in the terminal with real-time output streaming. Long-running processes, background jobs, and interactive sessions are supported. The agent can pause and resume code execution and interact with running processes.
14 -
15 -## Terminal and System Operations
16 -
17 -The agent has full root access to the Kali Linux Docker container. It can:
18 -- Install packages via `apt`, `pip`, `npm`, and other package managers
19 -- Create, read, write, move, and delete files anywhere in the container
20 -- Run any system command, manage processes, set up services
21 -- Access the network (HTTP requests, SSH, port scanning, etc.)
22 -- Use Kali Linux security tools pre-installed in the container
23 -
24 -## Skills (SKILL.md Standard)
25 -
26 -Skills are structured markdown files that provide contextual expertise for specific tasks. When a skill is relevant to the current task, it is loaded into the agent's context and followed as a set of instructions. Skills are discovered from:
27 -- `usr/skills/` (user-added skills)
28 -- Project-scoped skills in `.a0proj/skills/`
29 -- Skills imported via the web UI
30 -
31 -Skills follow the open SKILL.md standard, making them portable across tools that support it. The agent executes skill instructions using `code_execution_tool` or `skills_tool`.
32 -
33 -## Projects
34 -
35 -Projects provide isolated workspaces with their own:
36 -- Working directory (`usr/projects/<name>/`)
37 -- Memory and knowledge scope
38 -- Custom agent instructions (`.a0proj/agent.instructions.md`)
39 -- Secrets and credentials (stored encrypted, not visible in agent context)
40 -- MCP server configurations
41 -- Git repository (can be cloned directly with authentication)
42 -
43 -When a project is active, the agent's file operations, memory, and knowledge are scoped to that project. Projects prevent context bleed between separate work streams.
44 -
45 -## Knowledge Base Access
46 -
47 -The agent has automatic access to its knowledge base via similarity search. Knowledge is indexed from `knowledge/` (framework-level) and `usr/knowledge/<subdir>/` (user-level). The agent does not need to explicitly query knowledge - relevant content is surfaced automatically with memory recall. The `knowledge_tool` can also be called explicitly for targeted lookups.
48 -
49 -## Multi-Agent Delegation
50 -
51 -The agent can spawn subordinate agents with the `call_subordinate` tool. Subordinates can be given:
52 -- Specific prompt profiles (`developer`, `researcher`, custom profiles)
53 -- A defined role and task scope
54 -- Access to the same tool set
55 -
56 -Delegation is used to: parallelize work, maintain clean context per task, apply specialized profiles, and isolate long subtasks from the main context.
57 -
58 -## Document Query
59 -
60 -The `document_query_tool` can load and query arbitrary documents (local files or URLs) using a separate RAG pipeline. Unlike the knowledge base (which is pre-indexed), this tool indexes documents on demand with a configurable chunk size. Useful for analyzing large documents, codebases, or external content without polluting the persistent knowledge store.
61 -
62 -## Scheduler
63 -
64 -The agent can schedule tasks to run at specified times or intervals using the scheduler tool. Scheduled tasks run in the background with their own agent instances. Tasks are managed via the Scheduler UI in the web interface.
65 -
66 -## External API and MCP
67 -
68 -Agent Zero can act as both an MCP server and an MCP client:
69 -- As an **MCP server**: exposes agent capabilities to other MCP-compatible clients
70 -- As an **MCP client**: uses tools from external MCP servers (configured per project or globally)
71 -
72 -An external REST API is available for programmatic task submission. Agent-to-Agent (A2A) protocol is supported for inter-system agent communication.
73 -
74 -## Limitations
75 -
76 -- **No persistent state between chats** unless explicitly memorized or saved to files.
77 -- **Context window**: long conversations are summarized automatically, which can lose detail.
78 -- **Memory recall is approximate**: similarity search may miss relevant memories or surface irrelevant ones.
79 -- **No GUI interaction** outside built-in Browser tooling, A0 CLI host-browser mode, or configured computer-use integrations.
80 -- **Container boundary**: the agent cannot affect systems outside the Docker container unless one of these bridges is available: network access, volume mounts, A0 CLI access, or A0 CLI host-browser mode.
81 -- **Model capability ceiling**: tool usage quality and reasoning depth are bounded by the underlying LLM. Small models may struggle with complex multi-step tool use.
82 -- **No real-time data** beyond web search. The agent's own knowledge cutoff is the underlying model's training cutoff.
1 +# Agent Zero Capabilities
2 +
3 +Agent Zero can:
4 +- run terminal and code execution tools inside the Docker/server runtime
5 +- use A0 CLI connector tools for host/local machine execution when connected and enabled
6 +- read, write, and patch files with text editor tools
7 +- browse the web with the browser/search tools
8 +- create and query document artifacts
9 +- save, load, and forget memories
10 +- schedule tasks
11 +- call subordinate agents
12 +- use MCP and A2A integrations when configured
13 +
14 +Important boundary:
15 +- Docker/server tools operate inside the Agent Zero container, usually `/a0/usr/workdir`.
16 +- A0 CLI remote tools operate on the connected host machine, usually the CLI working directory.
17 +- Do not confuse host-local paths with container paths.
18 +
19 +Capabilities depend on enabled plugins, settings, model quality, permissions, and active project context.
knowledge/main/about/configuration.md
+10 -103
@@ -1,109 +1,16 @@
1 -# Agent Zero - Configuration Reference
1 +# Agent Zero Configuration
2
3 -## LLM Roles
3 +Main configuration lives in `usr/settings.json` and the Settings Web UI.
4
5 -Agent Zero uses three configurable LLM roles:
5 +LLM roles:
6 +- `chat_llm`: primary reasoning and tool use
7 +- `utility_llm`: summaries, memory queries, compression, filtering
8 +- `embedding_llm`: vector embeddings for memory and knowledge
9
7 -| Role | Purpose |
8 -|------|---------|
9 -| `chat_llm` | Primary model for all agent reasoning and tool use |
10 -| `utility_llm` | Secondary model for internal framework tasks: memory summarization, query generation, history compression, memory recall filtering |
11 -| `embedding_llm` | Produces vector embeddings for memory and knowledge indexing |
10 +Profiles live in `agents/<profile>/`; user profiles live in `usr/agents/<profile>/`. Profiles override prompt fragments without changing the framework.
11
13 -The utility model handles high-volume, lower-stakes operations and can be cheaper or faster than the chat model.
12 +Plugins live in `plugins/` and `usr/plugins/`. Each plugin has a `plugin.yaml`; activation can be global or scoped to projects/profiles.
13
15 -Browser automation is exposed through the direct `browser` tool. The main agent decides when to call it.
14 +Projects isolate workdir, memory/knowledge scope, custom instructions, secrets, MCP config, and repositories.
15
17 -The Browser defaults to Docker Playwright Chromium. It can optionally use A0 CLI Bring Your Own Browser mode for a host Chrome-family browser.
18 -
19 -Browser-owned helper operations can use a Browser LLM preset when configured.
20 -
21 -Changing the embedding model invalidates the existing vector index. The knowledge base is re-indexed automatically.
22 -
23 -## Model Providers
24 -
25 -Providers are defined in `conf/model_providers.yaml`. All chat and embedding providers go through LiteLLM, which normalizes the API interface. Supported chat providers (as of v0.9.8):
26 -
27 -- Agent Zero API (a0_venice) - hosted service with no API key required for basic use
28 -- Anthropic, OpenAI, OpenRouter, Google (Gemini), Groq, Mistral AI
29 -- DeepSeek, xAI, Moonshot AI, Sambanova, CometAPI, Z.AI, Inception AI
30 -- Venice.ai, AWS Bedrock, Azure OpenAI
31 -- GitHub Copilot, HuggingFace
32 -- Ollama, LM Studio (local models)
33 -- Other OpenAI-compatible endpoints (custom `api_base`)
34 -
35 -Embedding providers: OpenAI, Azure, Ollama, LM Studio, HuggingFace, Google, Mistral, OpenRouter (via OpenAI-compat), AWS Bedrock.
36 -
37 -### Model Naming Convention
38 -
39 -| Provider | Format |
40 -|----------|--------|
41 -| OpenAI | model name only (`gpt-4.1`, `o4-mini`) |
42 -| Anthropic | model name only (`claude-sonnet-4-5`) |
43 -| OpenRouter | `provider/model` (`anthropic/claude-sonnet-4-5`) |
44 -| Ollama | model name only (`llama3.2`, `qwen2.5`) |
45 -| Google | model name only (`gemini-2.0-flash`) |
46 -
47 -## Agent Profiles
48 -
49 -Profiles are in `agents/<profile>/`. Each profile can override any prompt fragment from the base `prompts/` directory. Built-in profiles:
50 -
51 -| Profile | Description |
52 -|---------|-------------|
53 -| `default` | Base template for creating new profiles |
54 -| `agent0` | Top-level general assistant; human as superior; delegates to specialized subordinates |
55 -| `developer` | "Master Developer" - software architecture and full-stack implementation focus |
56 -| `researcher` | "Deep Research" - research, analysis, and synthesis across academic and corporate domains |
57 -| `hacker` | Red/blue team; penetration testing; Kali tools focus |
58 -| `_example` | Minimal example for building custom profiles |
59 -
60 -Custom profiles go in `usr/agents/<profile>/` to survive framework updates.
61 -
62 -## Plugin System
63 -
64 -Plugins are discovered from `plugins/` (framework plugins) and `usr/plugins/` (user plugins). Each plugin requires a `plugin.yaml` with at minimum: `name`, `description`, `version`.
65 -
66 -### Activation
67 -
68 -- **Global activation**: enabled/disabled for all contexts via the Plugins settings panel
69 -- **Scoped activation**: enabled/disabled per project or per agent profile via the plugin Switch modal
70 -- Activation state stored as `.toggle-1` (ON) and `.toggle-0` (OFF) files in the plugin's config dir
71 -
72 -### Built-in Framework Plugins
73 -
74 -| Plugin | Purpose |
75 -|--------|---------|
76 -| `_memory` | Memory and knowledge pipeline, recall, consolidation |
77 -| `_code_execution` | Terminal and code execution tool |
78 -| `_text_editor` | Structured file read/write/patch tool |
79 -
80 -## Environment Variable Configuration
81 -
82 -Any setting can be set via environment variable using the `A0_SET_` prefix. This is the primary mechanism for automated deployment and container configuration.
83 -
84 -Format: `A0_SET_<setting_name>=<value>`
85 -
86 -Examples:
87 -```
88 -A0_SET_chat_model_provider=anthropic
89 -A0_SET_chat_model_name=claude-sonnet-4-5
90 -A0_SET_utility_model_provider=openai
91 -A0_SET_utility_model_name=gpt-4o-mini
92 -A0_SET_embedding_model_provider=openai
93 -A0_SET_embedding_model_name=text-embedding-3-small
94 -```
95 -
96 -These can be set in the `.env` file at the project root or passed as Docker `-e` flags during container creation.
97 -
98 -## Key Behavioral Settings
99 -
100 -| Setting | Effect |
101 -|---------|--------|
102 -| `agent_knowledge_subdir` | Which knowledge subdir to load (default: `custom`, resolved to `usr/knowledge/`) |
103 -| `memory_recall_interval` | How many loop iterations between automatic memory recalls |
104 -| `memory_results` | Number of memory chunks returned per recall query |
105 -| `memory_threshold` | Similarity threshold for memory recall (0-1); lower = more results, potentially less relevant |
106 -| `auth_login` / `auth_password` | Web UI authentication credentials |
107 -| `agent_temperature` | LLM temperature for the chat model |
108 -
109 -Settings are stored in `usr/settings.json` and managed through the Settings page in the web UI. The settings page also provides: API key management (multiple keys per provider with round-robin), backup/restore, external services (tunnels, MCP, A2A), and memory management.
16 +Environment settings can use `A0_SET_<setting_name>=<value>`.
knowledge/main/about/identity.md
+7 -31
@@ -1,34 +1,10 @@
1 -# Agent Zero - Identity and Design Philosophy
1 +# Agent Zero Identity
2
3 -## What Agent Zero Is
3 +Agent Zero is an open-source, general-purpose agentic framework by Jan Tomasek and the Agent Zero community. It runs locally or on user-controlled infrastructure and uses tools, memory, plugins, projects, and subordinate agents to solve tasks.
4
5 -Agent Zero is an open-source, general-purpose agentic framework. It is not pre-programmed for specific tasks and has no fixed capability set beyond the basics. Its defining characteristic is that it grows and adapts as it is used - accumulating knowledge, solutions, and behaviors through persistent memory and user customization.
5 +Core idea: prompts and plugins define behavior; tools do the work; memory and knowledge provide recall when relevant. Keep user intent above framework lore.
6
7 -The framework has been created by Jan Tomášek and is maintained by the Agent Zero dev team and the community. Source code lives at github.com/agent0ai/agent-zero.
8 -
9 -## Core Design Principles
10 -
11 -**No hard-coding.** Almost nothing in the framework is fixed in source code. Agent behavior, tool definitions, message templates, and response patterns are all controlled by files in the `prompts/` directory. Changing the prompts changes the agent - fundamentally if needed.
12 -
13 -**Transparency.** Every prompt, every message template, every tool implementation is readable and editable. There are no hidden instructions or black-box behaviors. The agent can be fully audited.
14 -
15 -**Computer as a tool.** Agent Zero does not have a library of pre-built skill functions. Instead, it uses the operating system directly - writing code, running terminal commands, and creating tools on demand. The terminal is the primary interface to everything.
16 -
17 -**Organic growth.** The agent accumulates knowledge through experience. Facts, solutions, discovered patterns, and useful code are stored in memory and recalled in future conversations. The agent becomes more effective at tasks it has done before.
18 -
19 -**Prompt-driven behavior.** The `prompts/` directory is the control plane. System prompts, tool instructions, framework messages, and utility AI prompts are all there. The agent's behavior is as good as its prompts.
20 -
21 -## Project Context
22 -
23 -- **Repository**: github.com/agent0ai/agent-zero
24 -- **License**: Open source
25 -- **Primary author**: Jan Tomášek
26 -- **Community**: Discord (discord.gg/B8KZKNsPpj), Skool community, YouTube channel
27 -- **Documentation**: docs/ folder in the repository; deepwiki.com/agent0ai/agent-zero for AI-generated docs
28 -- **Current version**: v0.9.8
29 -
30 -## Relationship With the User
31 -
32 -Agent Zero treats the human user as its top-level superior in the agent hierarchy. The user is functionally indistinguishable from a superior agent - they give tasks, receive reports, and can intervene at any time. The agent is not a chatbot that answers questions; it is an executor that solves tasks using whatever means are available to it.
33 -
34 -The framework is a personal tool, not a service. It runs locally (or on user-controlled infrastructure) and has access to the user's files, credentials, and systems as configured. This makes it powerful and requires the user to understand what they are delegating.
7 +Project reference:
8 +- Repository: `github.com/agent0ai/agent-zero`
9 +- Documentation: `docs/` in the repo and DeepWiki for broad architecture lookup
10 +- User data lives under `usr/`
knowledge/main/about/setup-and-deployment.md
+10 -94
@@ -1,111 +1,23 @@
1 -# Agent Zero - Setup and Deployment
1 +# Agent Zero Setup And Deployment
2
3 -## Docker Deployment (Standard)
4 -
5 -Agent Zero is distributed as a Docker image: `agent0ai/agent-zero`.
3 +Docker image:
4
5 ```bash
6 docker pull agent0ai/agent-zero
7 docker run -p 50001:80 agent0ai/agent-zero
8 ```
9
12 -The web UI is then accessible at `http://localhost:50001`. The container exposes port 80 internally; map any host port to it.
13 -
14 -## Persistence
15 -
16 -All user data lives in `/a0/usr/` inside the container. Without a volume mount, data is lost when the container is removed.
10 +Persist user data by mounting `/a0/usr`:
11
18 -Map `/a0/usr` to a host directory for persistence:
12 ```bash
13 docker run -p 50001:80 -v /path/on/host:/a0/usr agent0ai/agent-zero
14 ```
15
23 -Contents of `/a0/usr/`:
24 -- `settings.json` - all configuration including API keys and model settings
25 -- `memory/` - FAISS vector indexes and knowledge import state
26 -- `knowledge/` - user-added knowledge files
27 -- `agents/` - custom agent profiles
28 -- `plugins/` - user plugins
29 -- `projects/` - project workspaces
30 -- `work/` - default working directory for agent file output
31 -
32 -## Configuration After Start
33 -
34 -On first run, open Settings (gear icon) and configure:
35 -1. **API Keys** - add at least one provider API key under the relevant provider section
36 -2. **Chat Model** - select provider and model name for the primary LLM
37 -3. **Utility Model** - select a cheaper/faster model for internal tasks
38 -4. **Embedding Model** - select embedding provider and model (required for memory and knowledge)
39 -
40 -Settings are saved to `usr/settings.json` immediately on change.
41 -
42 -## Updating Agent Zero
43 -
44 -The recommended update process is to use Self Update:
45 -1. Open **Settings UI → Update** tab
46 -2. Open **Self Update**
47 -3. Wait for the update checker to see if you have the latest version or if there's an available update
48 -
49 -You'll also be prompted through the UI when a new A0 version is released. Note that backups are automatically managed internally during the self-update process.
50 -
51 -### Updating from Pre-v0.9.8
52 -
53 -If upgrading from v0.9.8 or earlier, the architecture has significantly changed. You must use the new install scripts and manually migrate your data:
54 -1. Backup your existing `usr/` directory.
55 -2. Run the Quick Install script (`curl -fsSL https://bash.agent-zero.ai | bash` for macOS/Linux or `irm https://ps.agent-zero.ai | iex` for Windows).
56 -3. Copy your backed-up `usr/` contents into the new installation's `a0/usr/` directory to preserve your settings, memory, and plugins.
57 -
58 -## Remote Access
59 -
60 -### Flare Tunnel (recommended for external access)
61 -Settings → External Services → Flare Tunnel → Create Tunnel
62 -
63 -This generates a public HTTPS URL without requiring firewall changes or a static IP. Set a username and password before creating the tunnel to enable authentication.
64 -
65 -### Local Network
66 -Access from other devices on the same network using the host machine's IP:
67 -`http://<host-ip>:<mapped-port>`
68 -
69 -### Microsoft Dev Tunnels
70 -Supported as an alternative to Flare for users in Microsoft environments. Configure under External Services in Settings.
71 -
72 -## Mobile Access
73 -
74 -Agent Zero is a Progressive Web App (PWA). On mobile, open the web UI URL in a browser, then add to home screen for an app-like experience. Works with both local network and tunnel URLs.
75 -
76 -## Common Troubleshooting
77 -
78 -**Agent responds but no memory/knowledge recall:**
79 -- Check that an embedding model is configured (provider + model name)
80 -- Verify the embedding provider API key is set
81 -- Embedding model changes require re-indexing; this happens automatically but takes time on first run
82 -
83 -**"Model not found" or API errors:**
84 -- Verify the model name matches the provider's naming convention exactly
85 -- Check that the API key has access to the requested model
86 -- For OpenRouter, model names must include the provider prefix (`anthropic/claude-sonnet-4-5`)
87 -
88 -**Container starts but web UI unreachable:**
89 -- Confirm the host port mapping in `docker ps`
90 -- Check that no firewall rule blocks the mapped port
91 -- The container needs a few seconds to initialize on first start
92 -
93 -**Knowledge files not being recalled:**
94 -- Supported formats: `.md`, `.txt`, `.pdf`, `.csv`, `.html`, `.json`
95 -- Files must be in `knowledge/` (framework level) or `usr/knowledge/<subdir>/`
96 -- The configured `agent_knowledge_subdir` must match the subdir where files are placed
97 -- Re-indexing is triggered automatically when file checksums change
98 -
99 -**Ollama / local model setup:**
100 -- Ollama must be running and accessible from inside the Docker container
101 -- Use `http://host.docker.internal:<port>` as the API URL for Ollama (not `localhost`)
102 -- Pull the model first: `ollama pull <model-name>`
16 +After first start, configure API keys, chat model, utility model, and embedding model in Settings. Embeddings are required for memory and knowledge recall.
17
104 -## Development Setup (non-Docker)
18 +For local development:
19
20 ```bash
107 -git clone https://github.com/agent0ai/agent-zero
108 -cd agent-zero
21 python -m venv .venv
22 source .venv/bin/activate
23 pip install -r requirements.txt
@@ -113,4 +25,8 @@ pip install -r requirements2.txt
25 python run_ui.py
26 ```
27
116 -The dev server runs on `http://localhost:5000` by default. User data is written to `usr/` in the project root.
28 +Typical troubleshooting:
29 +- Web UI unreachable: check `docker ps`, port mapping, and startup logs.
30 +- Model errors: verify provider, model name, and API key.
31 +- Memory/knowledge not recalling: verify embedding config and reindex if needed.
32 +- Host-local access: use A0 CLI connector tools, not Docker tools.
knowledge/main/tool_call_reference_examples.md deleted
-79
@@ -1,79 +0,0 @@
1 -# Tool Call Reference Examples
2 -
3 -These examples are intentionally short and high signal so tool-call shape guidance
4 -can live in knowledge without bloating the default prompt stack.
5 -
6 -## 1) Namespaced tool (`text_editor`) vs non-namespaced tool (`code_execution_tool`)
7 -
8 -- `text_editor` requires method in `tool_name`:
9 - - `text_editor:read`
10 - - `text_editor:write`
11 - - `text_editor:patch`
12 -- `code_execution_tool` uses a plain tool name plus behavior in `tool_args.runtime`.
13 -
14 -### Example A: read file lines with namespaced tool
15 -
16 -```json
17 -{
18 - "tool_name": "text_editor:read",
19 - "tool_args": {
20 - "path": "/workspace/agent-zero/README.md",
21 - "line_from": 1,
22 - "line_to": 60
23 - }
24 -}
25 -```
26 -
27 -### Example B: run shell command with `code_execution_tool`
28 -
29 -```json
30 -{
31 - "tool_name": "code_execution_tool",
32 - "tool_args": {
33 - "runtime": "terminal",
34 - "session": 0,
35 - "reset": false,
36 - "code": "pwd"
37 - }
38 -}
39 -```
40 -
41 -### Example C: poll ongoing terminal output
42 -
43 -```json
44 -{
45 - "tool_name": "code_execution_tool",
46 - "tool_args": {
47 - "runtime": "output",
48 - "session": 0
49 - }
50 -}
51 -```
52 -
53 -## 2) Memory tools use plain names and structured args
54 -
55 -```json
56 -{
57 - "tool_name": "memory_load",
58 - "tool_args": {
59 - "query": "tool argument format",
60 - "limit": 3,
61 - "threshold": 0.7
62 - }
63 -}
64 -```
65 -
66 -## 3) Subordinate tool booleans are JSON booleans
67 -
68 -```json
69 -{
70 - "tool_name": "call_subordinate",
71 - "tool_args": {
72 - "profile": "",
73 - "message": "Review this patch for edge cases.",
74 - "reset": true
75 - }
76 -}
77 -```
78 -
79 -Use these examples as structure references only. Adapt arguments to the current task.
plugins/_a0_connector/extensions/python/_functions/extensions/python/system_prompt/_11_tools_prompt/build_prompt/end/_70_include_remote_tool_stubs.py
+1 -165
@@ -1,174 +1,10 @@
1 from __future__ import annotations
2
3 -from dataclasses import dataclass
3 from typing import Any
4
5 from helpers.extension import Extension
6
8 -from plugins._a0_connector.helpers.ws_runtime import (
9 - computer_use_metadata_for_sid,
10 - remote_exec_metadata_for_sid,
11 - remote_file_metadata_for_sid,
12 - subscribed_sids_for_context,
13 -)
14 -
15 -
16 -@dataclass(frozen=True)
17 -class RemoteFileCapability:
18 - available: bool
19 - write_enabled: bool = False
20 - access_mode: str = "Unknown"
21 - advertised: bool = False
22 -
7
8 class IncludeRemoteToolStubs(Extension):
9 def execute(self, data: dict[str, Any] = {}, **kwargs: Any) -> None:
26 - if not self.agent:
27 - return
28 -
29 - result = data.get("result")
30 - if not isinstance(result, str):
31 - return
32 -
33 - context_id = str(getattr(self.agent.context, "id", "") or "").strip()
34 - if not context_id:
35 - return
36 -
37 - stubs: list[str] = []
38 - file_capability = _remote_file_capability(context_id)
39 -
40 - if file_capability.available:
41 - stubs.append(
42 - self.agent.read_prompt(
43 - "agent.connector_tool.text_editor_remote.md",
44 - access_mode=file_capability.access_mode,
45 - write_guidance=_file_write_guidance(file_capability),
46 - )
47 - )
48 -
49 - if _remote_exec_available(context_id):
50 - stubs.append(
51 - self.agent.read_prompt(
52 - "agent.connector_tool.code_execution_remote.md",
53 - access_mode=file_capability.access_mode,
54 - write_runtime_note=_exec_write_runtime_note(file_capability),
55 - )
56 - )
57 -
58 - computer_use = _computer_use_capability(context_id)
59 - if computer_use:
60 - stubs.append(
61 - self.agent.read_prompt(
62 - "agent.connector_tool.computer_use_remote.md",
63 - backend=computer_use["backend"],
64 - trust_mode=computer_use["trust_mode"],
65 - features=computer_use["features"],
66 - )
67 - )
68 -
69 - if not stubs:
70 - return
71 -
72 - data["result"] = (
73 - result.rstrip()
74 - + "\n\n"
75 - + "\n\n".join(stub.strip() for stub in stubs if stub.strip())
76 - )
77 -
78 -
79 -def _subscribed_sids(context_id: str) -> list[str]:
80 - return sorted(subscribed_sids_for_context(context_id))
81 -
82 -
83 -def _remote_file_capability(context_id: str) -> RemoteFileCapability:
84 - saw_advertised = False
85 - saw_enabled = False
86 - saw_write_enabled = False
87 -
88 - for sid in _subscribed_sids(context_id):
89 - metadata = remote_file_metadata_for_sid(sid)
90 - if not metadata:
91 - continue
92 - saw_advertised = True
93 - if not metadata.get("enabled", True):
94 - continue
95 - saw_enabled = True
96 - if metadata.get("write_enabled"):
97 - saw_write_enabled = True
98 -
99 - if not saw_enabled:
100 - return RemoteFileCapability(
101 - available=False,
102 - access_mode="Disabled" if saw_advertised else "Unknown",
103 - advertised=saw_advertised,
104 - )
105 -
106 - return RemoteFileCapability(
107 - available=True,
108 - write_enabled=saw_write_enabled,
109 - access_mode="Read&Write" if saw_write_enabled else "Read only",
110 - advertised=True,
111 - )
112 -
113 -
114 -def _remote_exec_available(context_id: str) -> bool:
115 - for sid in _subscribed_sids(context_id):
116 - metadata = remote_exec_metadata_for_sid(sid)
117 - if metadata and metadata.get("enabled"):
118 - return True
119 - return False
120 -
121 -
122 -def _computer_use_capability(context_id: str) -> dict[str, str] | None:
123 - for sid in _subscribed_sids(context_id):
124 - metadata = computer_use_metadata_for_sid(sid)
125 - if not metadata or not metadata.get("supported") or not metadata.get("enabled"):
126 - continue
127 -
128 - backend_id = str(metadata.get("backend_id") or "").strip() or "unknown"
129 - backend_family = str(metadata.get("backend_family") or "").strip()
130 - backend = backend_id if not backend_family else f"{backend_id}/{backend_family}"
131 - trust_mode = str(metadata.get("trust_mode") or "").strip() or "unknown"
132 - features_value = metadata.get("features")
133 - if isinstance(features_value, (list, tuple)):
134 - features = ", ".join(
135 - str(item).strip() for item in features_value if str(item).strip()
136 - )
137 - else:
138 - features = ""
139 -
140 - return {
141 - "backend": backend,
142 - "trust_mode": trust_mode,
143 - "features": features or "none advertised",
144 - }
145 -
146 - return None
147 -
148 -
149 -def _file_write_guidance(capability: RemoteFileCapability) -> str:
150 - if capability.write_enabled:
151 - return "Writes and patches are currently available."
152 - return (
153 - "Writes and patches are disabled until the user switches the CLI to "
154 - "Read&Write with F3."
155 - )
156 -
157 -
158 -def _exec_write_runtime_note(capability: RemoteFileCapability) -> str:
159 - if capability.write_enabled:
160 - return "Mutating runtimes are currently available because local access is Read&Write."
161 - if capability.available:
162 - return (
163 - "Mutating runtimes are disabled until the user switches the CLI to "
164 - "Read&Write with F3; use output/reset only for existing sessions."
165 - )
166 - if capability.advertised:
167 - return (
168 - "The CLI advertises remote file access as disabled; mutating runtimes "
169 - "are unavailable until local file access is enabled."
170 - )
171 - return (
172 - "The CLI did not advertise a file access mode; prefer non-mutating "
173 - "inspection until access is clear."
174 - )
10 + return
plugins/_a0_connector/helpers/ws_runtime.py
+51 -37
@@ -157,6 +157,24 @@ def subscribed_sids_for_context(context_id: str) -> set[str]:
157 return set(_context_subscriptions.get(context_id, set()))
158
159
160 +def connected_sids() -> set[str]:
161 + with _state_lock:
162 + return set(_sid_contexts.keys())
163 +
164 +
165 +def _candidate_sids_for_context_locked(context_id: str) -> list[str]:
166 + context_sids = sorted(_context_subscriptions.get(context_id, set()))
167 + context_set = set(context_sids)
168 + global_sids = sorted(sid for sid in _sid_contexts if sid not in context_set)
169 + return context_sids + global_sids
170 +
171 +
172 +def remote_tool_sids_for_context(context_id: str) -> list[str]:
173 + """Return connected CLI candidates, preferring clients subscribed to context_id."""
174 + with _state_lock:
175 + return _candidate_sids_for_context_locked(context_id)
176 +
177 +
178 def store_remote_tree_snapshot(
179 sid: str,
180 payload: dict[str, Any],
@@ -183,24 +201,31 @@ def latest_remote_tree_for_context(
201 ) -> dict[str, Any] | None:
202 now = time.time()
203 with _state_lock:
186 - subscribers = _context_subscriptions.get(context_id, set())
187 - snapshots = [
188 - _remote_tree_snapshots[sid]
189 - for sid in subscribers
190 - if sid in _remote_tree_snapshots
204 + context_sids = sorted(_context_subscriptions.get(context_id, set()))
205 + context_set = set(context_sids)
206 + global_sids = sorted(sid for sid in _sid_contexts if sid not in context_set)
207 + snapshot_groups = [
208 + [
209 + _remote_tree_snapshots[sid]
210 + for sid in context_sids
211 + if sid in _remote_tree_snapshots
212 + ],
213 + [
214 + _remote_tree_snapshots[sid]
215 + for sid in global_sids
216 + if sid in _remote_tree_snapshots
217 + ],
218 ]
219
193 - if not snapshots:
194 - return None
195 -
196 - snapshots.sort(key=lambda item: item.updated_at, reverse=True)
197 - for snapshot in snapshots:
198 - if max_age_seconds > 0 and now - snapshot.updated_at > max_age_seconds:
199 - continue
200 - payload = dict(snapshot.payload)
201 - payload["sid"] = snapshot.sid
202 - payload["updated_at"] = snapshot.updated_at
203 - return payload
220 + for snapshots in snapshot_groups:
221 + snapshots.sort(key=lambda item: item.updated_at, reverse=True)
222 + for snapshot in snapshots:
223 + if max_age_seconds > 0 and now - snapshot.updated_at > max_age_seconds:
224 + continue
225 + payload = dict(snapshot.payload)
226 + payload["sid"] = snapshot.sid
227 + payload["updated_at"] = snapshot.updated_at
228 + return payload
229 return None
230
231
@@ -248,20 +273,16 @@ def remote_file_metadata_for_sid(sid: str) -> dict[str, Any] | None:
273
274 def select_remote_file_target_sid(context_id: str, *, require_writes: bool = False) -> str | None:
275 with _state_lock:
251 - subscribers = sorted(_context_subscriptions.get(context_id, set()))
252 - fallback_sid: str | None = None
253 - for sid in subscribers:
276 + for sid in _candidate_sids_for_context_locked(context_id):
277 metadata = _sid_remote_file_metadata.get(sid)
278 if metadata is None:
256 - if fallback_sid is None:
257 - fallback_sid = sid
279 continue
280 if not metadata.enabled:
281 continue
282 if require_writes and not metadata.write_enabled:
283 continue
284 return sid
264 - return fallback_sid
285 + return None
286
287
288 def store_sid_remote_exec_metadata(sid: str, payload: dict[str, Any]) -> RemoteExecMetadata:
@@ -292,23 +313,19 @@ def remote_exec_metadata_for_sid(sid: str) -> dict[str, Any] | None:
313
314 def select_remote_exec_target_sid(context_id: str, *, require_writes: bool = False) -> str | None:
315 with _state_lock:
295 - subscribers = sorted(_context_subscriptions.get(context_id, set()))
296 - fallback_sid: str | None = None
297 - for sid in subscribers:
316 + for sid in _candidate_sids_for_context_locked(context_id):
317 metadata = _sid_remote_exec_metadata.get(sid)
318 if metadata is None:
300 - if fallback_sid is None:
301 - fallback_sid = sid
319 continue
320 if metadata.enabled:
321 if require_writes:
322 file_metadata = _sid_remote_file_metadata.get(sid)
306 - if file_metadata is not None and (
323 + if file_metadata is None or (
324 not file_metadata.enabled or not file_metadata.write_enabled
325 ):
326 continue
327 return sid
311 - return fallback_sid
328 + return None
329
330
331 def store_sid_computer_use_metadata(sid: str, payload: dict[str, Any]) -> ComputerUseMetadata:
@@ -429,9 +446,8 @@ def host_browser_metadata_for_sid(sid: str) -> dict[str, Any] | None:
446
447 def select_host_browser_target_sid(context_id: str) -> str | None:
448 with _state_lock:
432 - subscribers = sorted(_context_subscriptions.get(context_id, set()))
449 fallback: str | None = None
434 - for sid in subscribers:
450 + for sid in _candidate_sids_for_context_locked(context_id):
451 metadata = _sid_host_browser_metadata.get(sid)
452 if not metadata:
453 continue
@@ -446,9 +462,8 @@ def select_host_browser_target_sid(context_id: str) -> str | None:
462
463 def select_host_browser_candidate_sid(context_id: str) -> str | None:
464 with _state_lock:
449 - subscribers = sorted(_context_subscriptions.get(context_id, set()))
465 fallback: str | None = None
451 - for sid in subscribers:
466 + for sid in _candidate_sids_for_context_locked(context_id):
467 metadata = _sid_host_browser_metadata.get(sid)
468 if not metadata or not (metadata.supported or metadata.can_prepare):
469 continue
@@ -463,9 +478,9 @@ def select_host_browser_candidate_sid(context_id: str) -> str | None:
478
479 def host_browser_metadata_for_context(context_id: str) -> list[dict[str, Any]]:
480 with _state_lock:
466 - subscribers = sorted(_context_subscriptions.get(context_id, set()))
481 + candidates = _candidate_sids_for_context_locked(context_id)
482 rows: list[dict[str, Any]] = []
468 - for sid in subscribers:
483 + for sid in candidates:
484 metadata = host_browser_metadata_for_sid(sid)
485 if metadata is not None:
486 metadata["sid"] = sid
@@ -498,8 +513,7 @@ def all_host_browser_metadata() -> list[dict[str, Any]]:
513
514 def select_computer_use_target_sid(context_id: str) -> str | None:
515 with _state_lock:
501 - subscribers = sorted(_context_subscriptions.get(context_id, set()))
502 - for sid in subscribers:
516 + for sid in _candidate_sids_for_context_locked(context_id):
517 metadata = _sid_computer_use_metadata.get(sid)
518 if metadata and metadata.supported and metadata.enabled:
519 return sid
plugins/_a0_connector/prompts/agent.connector_tool.code_execution_remote.md deleted
-27
@@ -1,27 +0,0 @@
1 -# code_execution_remote tool
2 -
3 -Runs shell-backed execution on the machine where the subscribed A0 CLI is running.
4 -Load `code-execution-remote` before using this tool for nontrivial local project work.
5 -
6 -Current local access mode: `{{access_mode}}`
7 -
8 -## Requirements
9 -- A CLI client is subscribed to this chat and advertises remote execution.
10 -- Paths and shell syntax are evaluated on the CLI host, not inside Agent Zero.
11 -- {{write_runtime_note}}
12 -
13 -## Arguments
14 -- `runtime`: one of `terminal`, `python`, `nodejs`, `output`, `reset`
15 -- `runtime=input` is a temporary deprecated compatibility alias for sending one line of
16 - keyboard input into a running shell session
17 -- `session`: integer session id (default `0`)
18 -
19 -Runtime-specific fields:
20 -- `terminal`, `python`, `nodejs`: require `code`
21 -- `input`: requires `keyboard` (or `code` as fallback)
22 -- `reset`: optional `reason`
23 -
24 -## Notes
25 -- Reuse `session` when continuing a workflow.
26 -- Use `output` to poll a running session and `reset` for a stuck session.
27 -- If the CLI returns a disabled/no-client error, ask the user to enable or reconnect the CLI instead of falling back to server-side execution.
plugins/_a0_connector/prompts/agent.connector_tool.computer_use_remote.md deleted
-27
@@ -1,27 +0,0 @@
1 -# computer_use_remote tool
2 -
3 -Controls the subscribed A0 CLI host machine as a local desktop target.
4 -Load `computer-use-remote` before using this tool.
5 -
6 -## Requirements
7 -- A CLI client is subscribed to this chat and advertises enabled local computer use.
8 -- Backend: `{{backend}}`
9 -- Trust mode: `{{trust_mode}}`
10 -- Features: `{{features}}`
11 -
12 -## Arguments
13 -- `action`: one of `start_session`, `status`, `capture`, `move`, `click`, `scroll`, `key`, `type`, `stop_session`
14 -- `session_id`: optional for actions after `start_session`
15 -
16 -Action-specific fields:
17 -- `move`: `x`, `y` normalized to `[0,1]`
18 -- `click`: optional `x`, `y`, plus optional `button` (`left`, `right`, `middle`) and `count`
19 -- `scroll`: `dx`, `dy`
20 -- `key`: `key` or `keys`
21 -- `type`: `text`, optional `submit` boolean
22 -
23 -## Runtime Notes
24 -- Use `start_session` before interactive actions. `status` only inspects state.
25 -- Successful interactive actions attach a fresh screenshot; base decisions on the latest capture.
26 -- Prefer keyboard/accessibility routes before pointer actions.
27 -- Coordinates are normalized global screen coordinates.
plugins/_a0_connector/prompts/agent.connector_tool.text_editor_remote.md deleted
-21
@@ -1,21 +0,0 @@
1 -# text_editor_remote tool
2 -
3 -Reads, writes, and patches files on the machine where the subscribed A0 CLI is running.
4 -This is different from server-side file tools. Load `text-editor-remote` before using it for edits.
5 -
6 -Current access mode: `{{access_mode}}`
7 -
8 -## Requirements
9 -- A CLI client is subscribed to this chat and advertises remote file access.
10 -- Paths are evaluated on the CLI host filesystem, not the Agent Zero server.
11 -- {{write_guidance}}
12 -
13 -## Operations
14 -- `read`: optional `line_from`, `line_to`
15 -- `write`: requires `content`
16 -- `patch`: requires either `patch_text` or `edits`
17 -
18 -## Notes
19 -- Prefer `read` before line-number edits.
20 -- Prefer `patch_text` for context-anchored changes and `edits` only for fresh, surgical line ranges.
21 -- If freshness checks reject a line patch, reread the file and retry with updated ranges.
plugins/_a0_connector/prompts/agent.system.tool.code_execution_remote.md new
+46
@@ -0,0 +1,46 @@
1 +# code_execution_remote tool
2 +
3 +Runs shell-backed execution on the machine where a connected A0 CLI is running.
4 +Use this tool, not `code_execution_tool`, when the user asks for the connected
5 +local terminal, the A0 CLI host, their local machine, or explicitly says not to
6 +use Docker/server/container execution.
7 +For complex local project work, optionally load skill `code-execution-remote`.
8 +
9 +Availability and permissions are checked when the tool runs. If no CLI is
10 +connected, remote execution is disabled, or local access is not Read&Write for a
11 +mutating command, report that to the user instead of falling back to server-side
12 +execution.
13 +
14 +## Arguments
15 +- `runtime`: one of `terminal`, `python`, `nodejs`, `output`, `reset`
16 +- `session`: integer session id (default `0`)
17 +
18 +Runtime-specific fields:
19 +- `terminal`, `python`, `nodejs`: require `code`
20 +- `reset`: optional `reason`
21 +
22 +## Notes
23 +- Reuse `session` when continuing a workflow.
24 +- Use `output` to poll a running session and `reset` for a stuck session.
25 +- Paths and shell syntax are evaluated on the CLI host, not inside Agent Zero.
26 +- When the user gives a relative path like `tmp/file.txt`, keep it relative to
27 + the CLI host terminal. Do not prepend or `cd` to `/a0/usr/workdir`; that is the
28 + Agent Zero server/Docker workdir, not the connected local terminal folder.
29 +- If the current terminal folder matters, run `pwd` first or include `pwd` in
30 + the same command without changing directories.
31 +
32 +## Usage
33 +~~~json
34 +{
35 + "thoughts": [
36 + "The user asked for the connected local terminal rather than Docker, so I should execute on the A0 CLI host."
37 + ],
38 + "headline": "Running command on connected local terminal",
39 + "tool_name": "code_execution_remote",
40 + "tool_args": {
41 + "runtime": "terminal",
42 + "session": 0,
43 + "code": "pwd"
44 + }
45 +}
46 +~~~
plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md new
+41
@@ -0,0 +1,41 @@
1 +# text_editor_remote tool
2 +
3 +Reads, writes, and patches files on the machine where a connected A0 CLI is
4 +running. Use this tool, not server-side file tools, when the user asks for files
5 +on the connected local machine, A0 CLI host, or explicitly says not to use
6 +Docker/server files. For complex remote edits, optionally load skill `text-editor-remote`.
7 +
8 +Availability and permissions are checked when the tool runs. If no CLI is
9 +connected, remote file access is disabled, or a write/patch needs Read&Write,
10 +report that to the user instead of falling back to server-side file tools.
11 +
12 +## Arguments
13 +- `action`: `read`, `write`, or `patch`
14 +- `path`: file path on the CLI host filesystem
15 +- `read`: optional `line_from`, `line_to`
16 +- `write`: requires `content`
17 +- `patch`: requires either `patch_text` or `edits`
18 +
19 +## Notes
20 +- Prefer `read` before line-number edits.
21 +- Prefer `patch_text` for context-anchored changes and `edits` only for fresh, surgical line ranges.
22 +- If freshness checks reject a line patch, reread the file and retry with updated ranges.
23 +- Relative paths are relative to the CLI host filesystem. Do not rewrite them to
24 + `/a0/usr/workdir`; that path belongs to the Agent Zero server/Docker side.
25 +
26 +## Usage
27 +~~~json
28 +{
29 + "thoughts": [
30 + "The user asked for a file on the connected local machine, so I should read it through the A0 CLI host."
31 + ],
32 + "headline": "Reading file on connected local machine",
33 + "tool_name": "text_editor_remote",
34 + "tool_args": {
35 + "action": "read",
36 + "path": "README.md",
37 + "line_from": 1,
38 + "line_to": 80
39 + }
40 +}
41 +~~~
plugins/_a0_connector/skills/code-execution-remote/SKILL.md
+9 -2
@@ -10,6 +10,14 @@ trigger_patterns:
10 - "run commands on the cli host"
11 - "run python on the cli host"
12 - "run node on the cli host"
13 + - "connected local terminal"
14 + - "connected terminal"
15 + - "local terminal"
16 + - "my terminal"
17 + - "a0 cli"
18 + - "cli host"
19 + - "not docker"
20 + - "not the docker terminal"
21 allowed_tools:
22 - code_execution_remote
23 ---
@@ -34,12 +42,11 @@ If the task belongs inside Agent Zero's own runtime, use the normal server-side
42 - Reuse the same integer `session` while continuing a workflow; session state is local to the CLI frontend.
43 - Use `runtime=output` when a previous command is still running or returned before the shell reached a prompt.
44 - Use `runtime=reset` when a session is stuck or a clean shell is safer.
37 -- Treat `runtime=input` as deprecated compatibility for sending one line to a running shell.
45 - Match the remote host shell syntax. A Windows CLI may need PowerShell syntax even when Agent Zero runs on Linux.
46
47 ## Failure Handling
48
42 -- If no CLI is connected or subscribed, ask the user to connect A0 CLI to this chat.
49 +- If no CLI is connected, ask the user to connect A0 CLI to this Agent Zero instance.
50 - If execution is disabled, tell the user to enable remote execution in the CLI.
51 - If mutating runtimes are blocked, tell the user to switch local file access to Read&Write with F3.
52 - If a request times out or the CLI disconnects, poll once if a session may still be running; otherwise summarize the failure and wait for reconnection.
plugins/_a0_connector/skills/text-editor-remote/SKILL.md
+6 -1
@@ -10,6 +10,11 @@ trigger_patterns:
10 - "edit my local files through a0 cli"
11 - "read files on the cli host"
12 - "patch files on the cli host"
13 + - "connected local files"
14 + - "connected local machine files"
15 + - "local files not docker"
16 + - "a0 cli files"
17 + - "cli host files"
18 allowed_tools:
19 - text_editor_remote
20 ---
@@ -46,6 +51,6 @@ If the task belongs inside Agent Zero's own runtime, use the normal server-side
51
52 ## Failure Handling
53
49 -- If no CLI is connected or subscribed, ask the user to connect A0 CLI to this chat.
54 +- If no CLI is connected, ask the user to connect A0 CLI to this Agent Zero instance.
55 - If writes are blocked, tell the user to switch local file access to Read&Write with F3.
56 - If a request times out or the CLI disconnects, summarize the failure and wait for reconnection.
plugins/_a0_connector/tools/code_execution_remote.py
+13 -16
@@ -13,9 +13,9 @@ from plugins._a0_connector.helpers.ws_runtime import (
13 clear_pending_exec_op,
14 remote_exec_metadata_for_sid,
15 remote_file_metadata_for_sid,
16 + remote_tool_sids_for_context,
17 select_remote_exec_target_sid,
18 store_pending_exec_op,
18 - subscribed_sids_for_context,
19 )
20
21
@@ -67,24 +67,21 @@ class CodeExecutionRemote(Tool):
67 )
68
69 context_id = self.agent.context.id
70 - subscribers = subscribed_sids_for_context(context_id)
70 + candidates = remote_tool_sids_for_context(context_id)
71 require_writes = self._runtime_requires_write_access(runtime)
72 sid = select_remote_exec_target_sid(context_id, require_writes=require_writes)
73 if not sid:
74 exec_enabled = False
75 write_blocked = False
76 - for subscriber_sid in subscribers:
77 - exec_metadata = remote_exec_metadata_for_sid(subscriber_sid)
78 - if exec_metadata is None:
79 - exec_enabled = True
80 - continue
81 - if not exec_metadata.get("enabled"):
76 + for candidate_sid in candidates:
77 + exec_metadata = remote_exec_metadata_for_sid(candidate_sid)
78 + if exec_metadata is None or not exec_metadata.get("enabled"):
79 continue
80 exec_enabled = True
81 if not require_writes:
82 break
86 - file_metadata = remote_file_metadata_for_sid(subscriber_sid)
87 - if file_metadata is not None and (
83 + file_metadata = remote_file_metadata_for_sid(candidate_sid)
84 + if file_metadata is None or (
85 not file_metadata.get("enabled", True)
86 or not file_metadata.get("write_enabled")
87 ):
@@ -92,16 +89,16 @@ class CodeExecutionRemote(Tool):
89
90 return Response(
91 message=(
95 - "code_execution_remote: no subscribed CLI in this context currently allows "
92 + "code_execution_remote: no connected CLI currently allows "
93 "shell-backed execution that may modify local files. Press F3 to switch "
94 "the CLI to Read&Write. `runtime=output` and `runtime=reset` remain "
95 "available for existing sessions."
99 - if subscribers and require_writes and exec_enabled and write_blocked
100 - else "code_execution_remote: no subscribed CLI in this context currently has "
96 + if candidates and require_writes and exec_enabled and write_blocked
97 + else "code_execution_remote: no connected CLI currently has "
98 "remote execution enabled. Connect the CLI and press F4 to switch exec on."
102 - if subscribers
103 - else "code_execution_remote: no CLI client connected to this context. "
104 - "Make sure the CLI is connected and subscribed."
99 + if candidates
100 + else "code_execution_remote: no CLI client connected to Agent Zero. "
101 + "Make sure the CLI is connected to this instance."
102 ),
103 break_loop=False,
104 )
plugins/_a0_connector/tools/computer_use_remote.py
+2 -2
@@ -69,8 +69,8 @@ class ComputerUseRemote(Tool):
69 if not sid:
70 return Response(
71 message=(
72 - "computer_use_remote: no subscribed CLI in this context currently advertises "
73 - "enabled local computer use. Enable it in the CLI with F2 and choose a trust mode first."
72 + "computer_use_remote: no connected CLI currently advertises enabled local "
73 + "computer use. Enable it in the CLI and choose a trust mode first."
74 ),
75 break_loop=False,
76 )
plugins/_a0_connector/tools/text_editor_remote.py
+28 -12
@@ -18,9 +18,10 @@ from plugins._a0_connector.helpers.text_editor_freshness import (
18 )
19 from plugins._a0_connector.helpers.ws_runtime import (
20 clear_pending_file_op,
21 + remote_file_metadata_for_sid,
22 + remote_tool_sids_for_context,
23 select_remote_file_target_sid,
24 store_pending_file_op,
23 - subscribed_sids_for_context,
25 )
26 from plugins._text_editor.helpers.patch_request import parse_patch_request
27
@@ -37,15 +38,23 @@ class TextEditorRemote(Tool):
38 """Send file-editing operations to the connected CLI machine."""
39
40 async def execute(self, **kwargs: Any) -> Response:
40 - op = str(self.args.get("op") or self.args.get("operation", "")).strip().lower()
41 + op = (
42 + str(
43 + self.args.get("action")
44 + or ""
45 + )
46 + .strip()
47 + .lower()
48 + .replace("-", "_")
49 + )
50 if not op:
51 return Response(
43 - message="op is required (read, write, or patch)",
52 + message="action is required (read, write, or patch)",
53 break_loop=False,
54 )
55 if op not in {"read", "write", "patch"}:
56 return Response(
48 - message=f"Unknown operation: {op!r}. Use read, write, or patch.",
57 + message=f"Unknown action: {op!r}. Use read, write, or patch.",
58 break_loop=False,
59 )
60
@@ -150,23 +159,30 @@ class TextEditorRemote(Tool):
159 ) -> dict[str, Any]:
160 context_id = self.agent.context.id
161 require_writes = op in {"write", "patch"}
153 - subscribers = subscribed_sids_for_context(context_id)
162 + candidates = remote_tool_sids_for_context(context_id)
163 sid = select_remote_file_target_sid(context_id, require_writes=require_writes)
164 if not sid:
156 - if not subscribers:
165 + if not candidates:
166 error = (
158 - "text_editor_remote: no CLI client connected to this context. "
159 - "Make sure the CLI is connected and subscribed."
167 + "text_editor_remote: no CLI client connected to Agent Zero. "
168 + "Make sure the CLI is connected to this instance."
169 )
170 elif require_writes:
171 + write_blocked = any(
172 + (metadata := remote_file_metadata_for_sid(candidate_sid))
173 + and metadata.get("enabled", True)
174 + and not metadata.get("write_enabled")
175 + for candidate_sid in candidates
176 + )
177 error = (
163 - "text_editor_remote: no subscribed CLI in this context currently allows "
164 - "remote file writes. Press F3 to switch the CLI to Read&Write."
178 + "text_editor_remote: no connected CLI currently allows remote file writes. "
179 + "Press F3 to switch the CLI to Read&Write."
180 + if write_blocked
181 + else "text_editor_remote: no connected CLI currently advertises remote file access."
182 )
183 else:
184 error = (
168 - "text_editor_remote: no subscribed CLI in this context currently advertises "
169 - "remote file access."
185 + "text_editor_remote: no connected CLI currently advertises remote file access."
186 )
187 return {
188 "ok": False,
plugins/_browser/prompts/agent.system.tool.browser.md
+20 -167
@@ -1,180 +1,33 @@
1 ### browser
2 -direct Playwright browser control with optional visible WebUI viewer
3 -use for web browsing, page inspection, forms, downloads, and browser-only tasks
4 -state stays open per chat context
5 -refs come from content as typed markers: [link 3], [button 6], [image 1], [input text 8]
6 -Depending on project settings, the same browser tool may run in container mode or host mode. Container mode runs inside the project's container browser; host mode runs through A0 CLI against a Chrome-family browser on the user's host machine. Check or change the mode in the Browser project settings or plugin config. In host mode, page content and screenshots may be blocked to protect local browser data when remote models are active; local models are allowed by the host-content policy.
2 +Rendered browser automation for pages that need interaction, JavaScript, forms, downloads, screenshots, or visual inspection.
3
8 -Browser tool actions must not open a Browser surface automatically. Use the tool headlessly unless the user opens the Browser surface or explicitly asks for a visible browser view; if the Browser surface is already open, it may reflect the active page.
4 +Prefer `search_engine` or `document_query` for plain text research. Use the browser headlessly unless the user opens the Browser surface or asks for a visible browser.
5
10 -Browser does not automatically load screenshots or surface images into model context. Screenshots are explicit only.
6 +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.
7
12 -resource hygiene:
13 -- reuse an existing tab with navigate for serial research instead of opening a new tab for every result
14 -- keep only a small working set of tabs open; close pages with close or close_all after extracting what you need
15 -- avoid list with include_content:true when many tabs are open; call content on the specific tab instead
16 -- avoid large multi fan-outs unless the user explicitly needs parallel browsing
17 -- prefer search_engine/document_query for text research and use browser for pages that need interaction, rendering, login, forms, or visual inspection
8 +For complex browser workflows, load skill `browser-tool`. For fragile forms, load skill `browser-forms`.
9
19 -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
20 -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 offset_x offset_y target_offset_x target_offset_y delta_x delta_y button quality full_page path paths value values checked width height calls
10 +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`.
11
22 -workflow:
23 -- open creates a new browser and returns id/state
24 -- navigate reuses an existing browser_id and should be preferred during serial browsing
25 -- content returns readable page markdown with typed refs
26 -- detail inspects one ref, including link/image/input/button metadata
27 -- click/type/type_submit/submit/scroll use refs from latest content capture and return {action,state}
28 -- navigate/back/forward/reload return fresh state
29 -- list shows open browsers; pass include_content: true for one-call bulk read
12 +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`.
13
31 -explicit vision workflow:
32 -1. call browser with action: "screenshot"
33 -2. call vision_load with the returned path
34 -3. reason from the latest loaded screenshot, not an older screenshot
14 +Workflow:
15 +- `open` creates a tab and returns id/state.
16 +- `content` returns markdown with refs like `[link 3]`, `[button 6]`, `[input text 8]`.
17 +- Interactions use refs from the latest `content` capture.
18 +- `navigate` reuses an existing `browser_id` and is preferred for serial browsing.
19 +- Screenshots are explicit only; call `vision_load` with the returned path before reasoning visually.
20 +- Keep the tab set small; close pages after extracting what you need.
21
36 -screenshot:
37 -- saves a JPEG by default and returns path, a0_path, mime, state, and a ready vision_load tool_args object
38 -- pass quality 20..95, full_page true/false, or path
39 -- PNG is used only when path ends in .png
40 -- no base64 image data is returned in the tool message
41 -
42 -pointer and raw input:
43 -- hover moves to a ref center or x/y viewport CSS pixels
44 -- double_click and right_click accept ref or x/y; double_click accepts button and modifiers
45 -- drag moves from ref or x/y to target_ref or to_x/to_y
46 -- wheel scrolls at x/y with delta_x and delta_y
47 -- keyboard presses key or types text into the active page
48 -- clipboard is copy, cut, or paste; for browser:clipboard pass action: "paste" and optional text
49 -- set_viewport resizes the page viewport with width and height
50 -- coordinates are Chromium viewport CSS pixels and match screenshots/Browser surface
51 -- ref offsets are relative to the target element top-left; refs default to element center
52 -
53 -forms:
54 -- use select_option for native select and safely detectable ARIA listbox/combobox controls
55 -- use set_checked for checkbox, radio, switch, and toggle-like refs
56 -- use upload_file for file input refs or associated labels; in container mode paths resolve inside the running container, while in host mode paths resolve on the A0 CLI host machine; verify the file exists in that environment before upload
57 -- for complex forms, load browser-forms first with skills_tool:load
58 -
59 -modifier clicks:
60 -- click accepts modifiers like ["Control"], ["Shift"], ["Alt"], ["Meta"]
61 -- ctrl/meta-click opens link in new tab in background (Chrome rule)
62 -- override with focus_popup: true (focus follows new tab) or false (always background)
63 -- the new tab id is reported in action.opened_browser_ids; list shows all tabs
64 -
65 -popup awareness:
66 -- tabs opened by site (window.open, target=_blank, ctrl-click) auto-register
67 -- list returns every tab; last_interacted_browser_id tracks current focus
68 -
69 -background work (do not steal focus):
70 -- operations on a non-active tab (read, click, type, evaluate, etc.) target that tab WITHOUT moving focus
71 -- last_interacted_browser_id (and the WebUI viewer that follows it) only changes on:
72 - - open (new tab created)
73 - - explicit set_active action
74 - - action on the already-active tab
75 - - chrome popup-focus rule (plain click on target=_blank -> follow; ctrl-click -> stay)
76 -- to switch focus deliberately: {"action":"set_active","browser_id":N}
77 -
78 -key_chord:
79 -- presses keys in order, releases in reverse; safe across exceptions
80 -- example: {"action":"key_chord","keys":["Control","a"]} selects all
81 -
82 -multi (parallel batch):
83 -- run many actions concurrently across tabs in one tool call
84 -- pass calls: array of action objects (each has its own action+args)
85 -- different browser_ids run in parallel; same browser_id runs in submit order
86 -- returns array of {"ok":true,"result":...} or {"ok":false,"error":"..."} matching input order
87 -- ideal for: scrape N tabs at once, fan-out reads, parallel evaluate
88 -- new v1 actions such as screenshot, hover, wheel, keyboard, select_option, set_checked, and upload_file are accepted
89 -- avoid mutating same tab twice in one batch unless serial order is intended
90 -
91 -examples:
92 -~~~json
93 -{
94 - "tool_name": "browser",
95 - "tool_args": {
96 - "action": "open",
97 - "url": "https://example.com"
98 - }
99 -}
100 -~~~
101 -
102 -~~~json
103 -{
104 - "tool_name": "browser",
105 - "tool_args": {
106 - "action": "content",
107 - "browser_id": 1
108 - }
109 -}
110 -~~~
111 -
112 -~~~json
113 -{
114 - "tool_name": "browser",
115 - "tool_args": {
116 - "action": "screenshot",
117 - "browser_id": 1,
118 - "quality": 80
119 - }
120 -}
121 -~~~
122 -
123 -~~~json
124 -{
125 - "tool_name": "vision_load",
126 - "tool_args": {
127 - "paths": ["/absolute/local/path.jpg"]
128 - }
129 -}
130 -~~~
131 -
132 -~~~json
133 -{
134 - "tool_name": "browser",
135 - "tool_args": {
136 - "action": "select_option",
137 - "browser_id": 1,
138 - "ref": 8,
139 - "value": "Canada"
140 - }
141 -}
142 -~~~
143 -
144 -~~~json
145 -{
146 - "tool_name": "browser",
147 - "tool_args": {
148 - "action": "set_checked",
149 - "browser_id": 1,
150 - "ref": 9,
151 - "checked": true
152 - }
153 -}
154 -~~~
155 -
156 -~~~json
157 -{
158 - "tool_name": "browser",
159 - "tool_args": {
160 - "action": "upload_file",
161 - "browser_id": 1,
162 - "ref": 10,
163 - "path": "/a0/usr/workdir/resume.pdf"
164 - }
165 -}
166 -~~~
22 +`multi` is only a browser action: use `tool_name: "browser"` with `tool_args.action: "multi"`. Never use `tool_name: "multi"`.
23
24 +Example:
25 ~~~json
26 {
170 - "tool_name": "browser",
171 - "tool_args": {
172 - "action": "multi",
173 - "calls": [
174 - {"action": "content", "browser_id": 1},
175 - {"action": "screenshot", "browser_id": 2},
176 - {"action": "evaluate", "browser_id": 3, "script": "document.title"}
177 - ]
178 - }
27 + "tool_name": "browser",
28 + "tool_args": {
29 + "action": "open",
30 + "url": "https://example.com"
31 + }
32 }
33 ~~~
plugins/_browser/skills/browser-tool/SKILL.md new
+76
@@ -0,0 +1,76 @@
1 +---
2 +name: browser-tool
3 +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.
4 +---
5 +
6 +# Browser Tool
7 +
8 +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.
9 +
10 +## Core Workflow
11 +
12 +1. `open` creates a browser tab and returns a `browser_id`.
13 +2. `content` returns readable markdown plus typed refs like `[link 3]`, `[button 6]`, `[input text 8]`.
14 +3. Interact with refs using `click`, `type`, `submit`, `scroll`, etc.
15 +4. Use `navigate` on an existing `browser_id` for serial browsing.
16 +5. Keep only a small working tab set; close pages when finished.
17 +
18 +## Modes
19 +
20 +The same tool may run in Docker container mode or A0 CLI host-browser mode, depending on project/plugin settings.
21 +
22 +- Container mode: browser and upload paths resolve inside the Agent Zero container.
23 +- Host mode: browser and upload paths resolve on the connected A0 CLI host machine.
24 +
25 +In host mode, page content and screenshots may be blocked by host-content policy when remote models are active.
26 +
27 +## Screenshots And Vision
28 +
29 +Screenshots are explicit only; the browser does not automatically load images into model context.
30 +
31 +1. Call `browser` with `action: "screenshot"`.
32 +2. Call `vision_load` with the returned path.
33 +3. Reason from the latest loaded screenshot.
34 +
35 +Screenshot args include `quality`, `full_page`, and optional `path`. PNG is used when `path` ends with `.png`; otherwise JPEG is used.
36 +
37 +## Forms And Files
38 +
39 +- `select_option` works for native selects and detectable ARIA listbox/combobox controls.
40 +- `set_checked` works for checkbox, radio, switch, and toggle-like refs.
41 +- `upload_file` works for file input refs or associated labels; verify the file exists in the active browser environment.
42 +- For fragile forms, load skill `browser-forms`.
43 +
44 +## Pointer And Keyboard
45 +
46 +- `hover`, `double_click`, `right_click`, and `drag` accept refs or viewport coordinates.
47 +- Coordinates are Chromium viewport CSS pixels and match screenshots.
48 +- `key_chord` presses keys in order and releases in reverse.
49 +- `clipboard` actions are copy, cut, or paste.
50 +- `set_viewport` resizes the page viewport.
51 +
52 +## Tabs And Popups
53 +
54 +- Popups and target-blank tabs are auto-registered.
55 +- `list` shows open tabs; pass `include_content: true` sparingly.
56 +- `set_active` deliberately changes focus.
57 +- Operations on a non-active tab do not steal focus unless browser rules require it.
58 +
59 +## Browser Action Multi
60 +
61 +`multi` is only a browser action, never a top-level tool. Use:
62 +
63 +```json
64 +{
65 + "tool_name": "browser",
66 + "tool_args": {
67 + "action": "multi",
68 + "calls": [
69 + {"action": "content", "browser_id": 1},
70 + {"action": "screenshot", "browser_id": 2}
71 + ]
72 + }
73 +}
74 +```
75 +
76 +Use browser action `multi` for parallel reads across tabs. Avoid mutating the same tab twice in one batch unless serial order is intended.
plugins/_code_execution/tools/code_execution_tool.py
+2 -2
@@ -278,7 +278,7 @@ class CodeExecution(Tool):
278 if partial_output:
279 PrintStyle(font_color="#85C1E9").stream(partial_output)
280 truncated_output = self.fix_full_output(full_output)
281 - self.set_progress(truncated_output)
281 + await self.set_progress(truncated_output)
282 heading = self.get_heading_from_output(truncated_output, 0)
283 self.log.update(content=prefix + truncated_output, heading=heading)
284 last_output_time = now
@@ -397,7 +397,7 @@ class CodeExecution(Tool):
397 return None
398 raise
399 truncated_output = self.fix_full_output(full_output)
400 - self.set_progress(truncated_output)
400 + await self.set_progress(truncated_output)
401 heading = self.get_heading_from_output(truncated_output, 0)
402
403 last_lines = (
plugins/_memory/tools/behaviour_adjustment.py
+45 -5
@@ -39,11 +39,12 @@ async def update_behaviour(agent: Agent, log_item: LogItem, adjustments: str):
39 message=msg,
40 callback=log_callback,
41 )
42 + adjustments_merge = normalize_ruleset(adjustments_merge)
43
44 # update rules file
45 rules_file = get_custom_rules_file(agent)
46 files.write_file(rules_file, adjustments_merge)
46 - log_item.update(result="Behaviour updated")
47 + log_item.update(ruleset=adjustments_merge, result="Behaviour updated")
48
49
50 def get_custom_rules_file(agent: Agent):
@@ -53,8 +54,47 @@ def get_custom_rules_file(agent: Agent):
54 def read_rules(agent: Agent):
55 rules_file = get_custom_rules_file(agent)
56 if files.exists(rules_file):
56 - rules = agent.read_prompt(rules_file)
57 - return agent.read_prompt("agent.system.behaviour.md", rules=rules)
57 + return agent.read_prompt(rules_file)
58 else:
59 - rules = agent.read_prompt("agent.system.behaviour_default.md")
60 - return agent.read_prompt("agent.system.behaviour.md", rules=rules)
59 + return agent.read_prompt("agent.system.behaviour_default.md")
60 +
61 +
62 +def normalize_ruleset(ruleset: str):
63 + text = str(ruleset or "").strip()
64 +
65 + if text.startswith("```") and text.endswith("```"):
66 + lines = text.splitlines()
67 + text = "\n".join(lines[1:-1]).strip()
68 +
69 + text = text.replace("\r\n", "\n").replace("\r", "\n")
70 + text = text.replace("!!!", "")
71 + text = text.replace(".## ", ".\n## ")
72 +
73 + normalized_lines = []
74 + seen_structural_lines = set()
75 + previous_blank = False
76 +
77 + for raw_line in text.splitlines():
78 + line = raw_line.rstrip()
79 + stripped = line.strip()
80 +
81 + if not stripped:
82 + if normalized_lines and not previous_blank:
83 + normalized_lines.append("")
84 + previous_blank = True
85 + continue
86 +
87 + if stripped.startswith("# ") and not stripped.startswith("## "):
88 + stripped = "#" + stripped
89 + line = stripped
90 +
91 + dedupe_key = stripped.casefold()
92 + if stripped.startswith(("## ", "* ")) and dedupe_key in seen_structural_lines:
93 + continue
94 + if stripped.startswith(("## ", "* ")):
95 + seen_structural_lines.add(dedupe_key)
96 +
97 + normalized_lines.append(line)
98 + previous_blank = False
99 +
100 + return "\n".join(normalized_lines).strip() + "\n"
plugins/_memory/tools/memory_forget.py
+1 -1
@@ -1,7 +1,7 @@
1 from helpers.tool import Tool, Response
2 from plugins._memory.helpers.memory import Memory
3
4 -from tools.memory_load import DEFAULT_THRESHOLD
4 +from plugins._memory.tools.memory_load import DEFAULT_THRESHOLD
5
6
7 class MemoryForget(Tool):
plugins/_office/helpers/canvas_context.py
+2 -2
@@ -13,12 +13,12 @@ def build_context(max_items: int = 6) -> str:
13 return desktop_context
14
15 lines = [
16 - "These document artifacts have active document sessions. Content is omitted; load skill `document-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
16 + "These document artifacts have active document sessions. Content is omitted; load skill `document-artifacts` for edit workflow, then use `document_artifact` with action `read` before content-sensitive edits.",
17 ]
18 for doc in documents:
19 lines.append(format_document_line(doc))
20 lines.append(
21 - "Use document_artifact:edit with file_id or path for saved edits; tool results refresh the document canvas."
21 + "Use `document_artifact` with action `edit` and file_id or path for saved edits; tool results refresh the document canvas."
22 )
23 if desktop_context:
24 lines.extend(["", desktop_context])
plugins/_office/prompts/agent.system.tool.document_artifact.md
+2 -3
@@ -2,10 +2,9 @@
2 create/open/read/edit reusable document artifacts in Agent Zero
3 formats: md odt ods odp docx xlsx pptx
4 default format: md
5 -methods: create open read edit inspect export version_history restore_version status
6 -common args: method action kind title format content path file_id
5 +actions: create open read edit inspect export version_history restore_version status
6 +common args: action kind title format content path file_id
7 optional UI intent args: open_in_canvas open_in_desktop
8 -`method` is accepted as an alias for action when the tool_name has no suffix
8 create/read/edit results save or update artifacts only; they do not open a surface automatically unless the user explicitly asks to open the document UI
9 use action `open`, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop
10 created/updated artifacts are shown with explicit Download, Open Document, or Desktop edit message actions
plugins/_office/skills/calc-spreadsheets/SKILL.md
+4 -2
@@ -33,8 +33,9 @@ Create a workbook:
33
34 ```json
35 {
36 - "tool_name": "document_artifact:create",
36 + "tool_name": "document_artifact",
37 "tool_args": {
38 + "action": "create",
39 "kind": "spreadsheet",
40 "title": "Budget",
41 "format": "ods",
@@ -49,8 +50,9 @@ Edit cells:
50
51 ```json
52 {
52 - "tool_name": "document_artifact:edit",
53 + "tool_name": "document_artifact",
54 "tool_args": {
55 + "action": "edit",
56 "file_id": "abc123",
57 "operation": "set_cells",
58 "cells": {
plugins/_office/skills/document-artifacts/SKILL.md
+14 -9
@@ -26,7 +26,7 @@ allowed_tools:
26
27 Use `document_artifact` for substantial deliverables that should remain editable in the custom document editor or LibreOffice Desktop. Markdown remains the default for ordinary writing, notes, reports, briefs, and drafts when no binary office file is needed. For LibreOffice office files, ODF is first-class: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only when the user explicitly asks for OOXML compatibility, provides an existing file in that format, or needs that compatibility format.
28
29 -The document UI and Desktop are user-owned. Creating, reading, or editing an artifact must save the file and update its state, but it must not open a document modal or Desktop surface automatically if the user has not asked for that UI. Tool results provide explicit Download, Open Document, or Desktop edit actions for the user. Use `document_artifact:open`, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop.
29 +The document UI and Desktop are user-owned. Creating, reading, or editing an artifact must save the file and update its state, but it must not open a document modal or Desktop surface automatically if the user has not asked for that UI. Tool results provide explicit Download, Open Document, or Desktop edit actions for the user. Use the `open` action, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop.
30
31 For format-specific work, prefer the matching skill when available:
32
@@ -37,9 +37,9 @@ For format-specific work, prefer the matching skill when available:
37
38 ## Workflow
39
40 -1. Create or open the artifact with `document_artifact:create` / `document_artifact:open`, or with `tool_name: "document_artifact"` plus `method: "create"` / `method: "open"`.
41 -2. Before content-sensitive edits, call `document_artifact:read` with `file_id` or `path`.
42 -3. Apply saved changes with `document_artifact:edit`.
40 +1. Create or open the artifact with `tool_name: "document_artifact"` and `tool_args.action: "create"` or `"open"`.
41 +2. Before content-sensitive edits, call the `read` action with `file_id` or `path`.
42 +3. Apply saved changes with the `edit` action.
43 4. Use `version_history` or `restore_version` when the user asks to audit or roll back.
44
45 Document context may list opened files with `file_id`, path, version, size, and timestamp. It intentionally omits full file contents; use `read` when the content matters.
@@ -49,8 +49,9 @@ Document context may list opened files with `file_id`, path, version, size, and
49 Create:
50 ```json
51 {
52 - "tool_name": "document_artifact:create",
52 + "tool_name": "document_artifact",
53 "tool_args": {
54 + "action": "create",
55 "kind": "document",
56 "title": "Project Brief",
57 "format": "md",
@@ -64,8 +65,9 @@ For spreadsheets, `content` can be CSV, TSV, or a Markdown table; the tool write
65 Read:
66 ```json
67 {
67 - "tool_name": "document_artifact:read",
68 + "tool_name": "document_artifact",
69 "tool_args": {
70 + "action": "read",
71 "file_id": "abc123"
72 }
73 }
@@ -74,8 +76,9 @@ Read:
76 Edit text in a Markdown, ODT, DOCX, ODP, or PPTX file:
77 ```json
78 {
77 - "tool_name": "document_artifact:edit",
79 + "tool_name": "document_artifact",
80 "tool_args": {
81 + "action": "edit",
82 "file_id": "abc123",
83 "operation": "replace_text",
84 "find": "old phrase",
@@ -87,8 +90,9 @@ Edit text in a Markdown, ODT, DOCX, ODP, or PPTX file:
90 Set spreadsheet cells:
91 ```json
92 {
90 - "tool_name": "document_artifact:edit",
93 + "tool_name": "document_artifact",
94 "tool_args": {
95 + "action": "edit",
96 "path": "/a0/usr/workdir/documents/Budget.ods",
97 "operation": "set_cells",
98 "cells": {
@@ -102,8 +106,9 @@ Set spreadsheet cells:
106 Create an embedded spreadsheet chart:
107 ```json
108 {
105 - "tool_name": "document_artifact:edit",
109 + "tool_name": "document_artifact",
110 "tool_args": {
111 + "action": "edit",
112 "file_id": "abc123",
113 "operation": "create_chart",
114 "sheet": "Sheet1",
plugins/_office/skills/impress-presentations/SKILL.md
+4 -2
@@ -33,8 +33,9 @@ Create:
33
34 ```json
35 {
36 - "tool_name": "document_artifact:create",
36 + "tool_name": "document_artifact",
37 "tool_args": {
38 + "action": "create",
39 "kind": "presentation",
40 "title": "Roadmap",
41 "format": "odp",
@@ -47,8 +48,9 @@ Edit slides:
48
49 ```json
50 {
50 - "tool_name": "document_artifact:edit",
51 + "tool_name": "document_artifact",
52 "tool_args": {
53 + "action": "edit",
54 "file_id": "abc123",
55 "operation": "set_slides",
56 "slides": [
plugins/_office/skills/markdown-documents/SKILL.md
+4 -3
@@ -25,16 +25,17 @@ The document editor is user-owned UI. Create or update the saved Markdown artifa
25 ## Workflow
26
27 1. Decide whether a saved editable artifact is useful. Create one for substantial, reusable, or collaborative writing; do not create one for tiny one-shot edits or answers that can be completed cleanly in chat.
28 -2. Create Markdown with `document_artifact:create` using `kind: "document"` and `format: "md"`.
29 -3. For edits to an existing Markdown artifact, read first when content matters, then use `document_artifact:edit`.
28 +2. Create Markdown with `document_artifact` using `action: "create"`, `kind: "document"`, and `format: "md"`.
29 +3. For edits to an existing Markdown artifact, read first when content matters, then use the `edit` action.
30 4. Report the saved file path briefly. Do not say it was opened unless the user explicitly opened it.
31
32 Minimal create:
33
34 ```json
35 {
36 - "tool_name": "document_artifact:create",
36 + "tool_name": "document_artifact",
37 "tool_args": {
38 + "action": "create",
39 "kind": "document",
40 "title": "Project Brief",
41 "format": "md",
plugins/_office/skills/writer-documents/SKILL.md
+4 -3
@@ -31,8 +31,9 @@ Create:
31
32 ```json
33 {
34 - "tool_name": "document_artifact:create",
34 + "tool_name": "document_artifact",
35 "tool_args": {
36 + "action": "create",
37 "kind": "document",
38 "title": "Board Memo",
39 "format": "odt",
@@ -43,8 +44,8 @@ Create:
44
45 Edit:
46
46 -1. Use `document_artifact:read` with `file_id` or `path` before content-sensitive edits.
47 -2. Use `document_artifact:edit` for deterministic saved changes: `set_text`, `append_text`, `prepend_text`, `replace_text`, or `delete_text`.
47 +1. Use the `read` action with `file_id` or `path` before content-sensitive edits.
48 +2. Use the `edit` action for deterministic saved changes: `set_text`, `append_text`, `prepend_text`, `replace_text`, or `delete_text`.
49 3. Use the Desktop only when the user asks to see Writer or when layout cannot be handled reliably through structured edits.
50
51 Practical rules:
plugins/_text_editor/prompts/agent.system.tool.text_editor.md
+18 -10
@@ -2,19 +2,23 @@
2 file read write patch with numbered lines
3 not code execution rejects binary
4 terminal (grep find sed) advance search/replace
5 +actions: read write patch
6 +common args: action path
7
6 -#### text_editor:read
8 +#### read
9 read file with numbered lines
10 args path line_from line_to (inclusive optional)
9 -no range → first {{default_line_count}} lines
11 +no range -> first {{default_line_count}} lines
12 long lines cropped output may trim by token limit
13 read surrounding context before patching
14 usage:
15 ~~~json
16 {
15 - ...
16 - "tool_name": "text_editor:read",
17 + "thoughts": ["I need file context before editing."],
18 + "headline": "Reading file",
19 + "tool_name": "text_editor",
20 "tool_args": {
21 + "action": "read",
22 "path": "/path/file.py",
23 "line_from": 1,
24 "line_to": 50
@@ -22,22 +26,24 @@ usage:
26 }
27 ~~~
28
25 -#### text_editor:write
29 +#### write
30 create/overwrite file auto-creates dirs
31 args path content
32 usage:
33 ~~~json
34 {
31 - ...
32 - "tool_name": "text_editor:write",
35 + "thoughts": ["I need to create or replace the file content."],
36 + "headline": "Writing file",
37 + "tool_name": "text_editor",
38 "tool_args": {
39 + "action": "write",
40 "path": "/path/file.py",
41 "content": "import os\nprint('hello')\n"
42 }
43 }
44 ~~~
45
40 -#### text_editor:patch
46 +#### patch
47 edit existing file. prefer patch_text; use edits only right after read for tiny line edits
48 args path plus exactly one of: patch_text string OR edits [{from to content}]
49 patch_text uses current file content, no prior read required
@@ -54,9 +60,11 @@ ensure valid syntax in content (all braces brackets tags closed)
60 usage:
61 ~~~json
62 {
57 - ...
58 - "tool_name": "text_editor:patch",
63 + "thoughts": ["A context patch is safer than line-number surgery here."],
64 + "headline": "Patching file",
65 + "tool_name": "text_editor",
66 "tool_args": {
67 + "action": "patch",
68 "path": "/path/file.py",
69 "patch_text": "*** Begin Patch\n*** Update File: file.py\n@@ def run():\n+ print('ready')\n*** End Patch"
70 }
plugins/_text_editor/tools/text_editor.py
+21 -4
@@ -28,14 +28,18 @@ _MTIME_KEY = LOCAL_FRESHNESS_KEY
28 class TextEditor(Tool):
29
30 async def execute(self, **kwargs):
31 - if self.method == "read":
31 + action = _current_action(self, kwargs)
32 + if action == "read":
33 return await self._read(**kwargs)
33 - elif self.method == "write":
34 + elif action == "write":
35 return await self._write(**kwargs)
35 - elif self.method == "patch":
36 + elif action == "patch":
37 return await self._patch(**kwargs)
38 return Response(
38 - message=f"unknown method '{self.name}:{self.method}'",
39 + message=(
40 + f"unknown action '{action or self.method or ''}'. "
41 + "Supported actions: read, write, patch."
42 + ),
43 break_loop=False,
44 )
45
@@ -379,3 +383,16 @@ def _get_config(agent) -> dict:
383 "default_line_count": int(config.get("default_line_count", 100)),
384 "max_total_read_tokens": int(config.get("max_total_read_tokens", 4000)),
385 }
386 +
387 +
388 +def _current_action(tool: TextEditor, kwargs: dict) -> str:
389 + return (
390 + str(
391 + kwargs.get("action")
392 + or tool.args.get("action")
393 + or ""
394 + )
395 + .strip()
396 + .lower()
397 + .replace("-", "_")
398 + )
prompts/agent.system.main.communication.md
+2
@@ -9,6 +9,8 @@
9 - headline: short headline summary of the response
10 - tool_name: use tool name
11 - tool_args: key value pairs tool arguments
12 +- `tool_name` must be one listed tool name, never an action name such as `read`, `write`, `terminal`, or `multi`
13 +- To do two operations, call one tool now, then call the next tool after the first result
14
15 - No text output before or after the JSON object
16
prompts/agent.system.skills.md
+7 -3
@@ -1,4 +1,8 @@
1 ## skills
2 -use `skills_tool:search` when the user's wording sounds like a task, trigger phrase, or keyword match for a skill
3 -use `skills_tool:list` when you need a broader catalog view
4 -use `skills_tool:load` before following a skill
2 +use `skills_tool` action `search` when the user's wording sounds like a task, trigger phrase, or keyword match for a skill
3 +use `skills_tool` action `list` when you need a broader catalog view
4 +use `skills_tool` action `load` before following a skill
5 +loaded skills may document beta/specialized tools not in the always-on tool list; use them only after loading the skill
6 +
7 +available:
8 +{{skills}}
prompts/agent.system.skills.relevant.md
+2 -1
@@ -1,5 +1,6 @@
1 # relevant skills
2 - the following skills matched the user's current request by lexical search, including trigger phrases
3 -- use `skills_tool:load` to load one before following it
3 +- if the current request depends on one of these skills, use `skills_tool` with action `load` before following it
4 +- remote tool stubs are self-contained for routine use; load the matching remote skill for complex remote workflows
5
6 {{skills}}
prompts/agent.system.tool.notify_user.md
+1
@@ -2,4 +2,5 @@
2 send an out-of-band notification without ending the current task
3 args: `message`, optional `title`, `detail`, `type`, `priority`, `timeout`
4 types: `info`, `success`, `warning`, `error`, `progress`
5 +priority values: `20` high urgency, `10` normal urgency; omit for high
6 use for progress or alerts, not as the final answer
prompts/agent.system.tool.scheduler.md
+12 -26
@@ -1,27 +1,13 @@
1 ### scheduler
2 -manage saved tasks and schedules
3 -rules:
4 -- before `scheduler:create_*` or `scheduler:run_task`, inspect existing tasks with `scheduler:find_task_by_name` or `scheduler:list_tasks`
5 -- do not manually run a task just because it is scheduled or planned unless user asks to run now
6 -- do not create recursive task prompts that schedule more tasks
7 -methods:
8 -- `scheduler:list_tasks`: optional `state[]`, `type[]`, `next_run_within`, `next_run_after`
9 -- `scheduler:find_task_by_name`: `name`
10 -- `scheduler:show_task`: `uuid`
11 -- `scheduler:run_task`: `uuid`, optional `context`
12 -- `scheduler:delete_task`: `uuid`
13 -- `scheduler:create_scheduled_task`: `name`, `system_prompt`, `prompt`, optional `attachments[]`, `schedule{minute,hour,day,month,weekday}`, optional `dedicated_context`
14 -- `scheduler:create_adhoc_task`: `name`, `system_prompt`, `prompt`, optional `attachments[]`, optional `dedicated_context`
15 -- `scheduler:create_planned_task`: `name`, `system_prompt`, `prompt`, optional `attachments[]`, `plan[]` iso datetimes like `2025-04-29T18:25:00`, optional `dedicated_context`
16 -- `scheduler:wait_for_task`: `uuid`; works for dedicated-context tasks
17 -example:
18 -~~~json
19 -{
20 - "thoughts": ["I should check for an existing task before I create or run anything."],
21 - "headline": "Looking up scheduled task",
22 - "tool_name": "scheduler:find_task_by_name",
23 - "tool_args": {
24 - "name": "daily backup"
25 - }
26 -}
27 -~~~
2 +Manage saved tasks and schedules. For complex task work, load skill `scheduler-tasks`.
3 +
4 +Actions: `list_tasks`, `find_task_by_name`, `show_task`, `run_task`, `update_task`, `delete_task`, `create_scheduled_task`, `create_adhoc_task`, `create_planned_task`, `wait_for_task`.
5 +
6 +Common args: `action`, `name`, `uuid`, `system_prompt`, `prompt`, `attachments`, `schedule`, `timezone`, `plan`, `dedicated_context`.
7 +
8 +Rules:
9 +- Before `create_*`, `update_task`, `delete_task`, or `run_task`, inspect existing tasks with `find_task_by_name` or `list_tasks`.
10 +- Do not run scheduled/planned tasks unless the user asks to run now.
11 +- Do not create recursive task prompts that schedule more tasks.
12 +- New tasks use a dedicated context unless `dedicated_context` is `false`.
13 +- Use IANA timezones like `Europe/Rome`; omit timezone to use the current user timezone.
prompts/agent.system.tool.skills.md
+8 -4
@@ -1,9 +1,12 @@
1 ### skills_tool
2 use skills only when relevant
3 +actions: list search load read_file
4 +common args: action skill_name query file_path
5 workflow:
4 -- `skills_tool:search`: find candidate skills by keywords or trigger phrases from the current task
5 -- `skills_tool:list`: discover available skills
6 -- `skills_tool:load`: load one skill by `skill_name`
6 +- action `search`: find candidate skills by keywords or trigger phrases from the current task
7 +- action `list`: discover available skills
8 +- action `load`: load one skill by `skill_name`
9 +- action `read_file`: open one file inside a loaded skill directory
10 after loading a skill, follow its instructions and use referenced files or scripts with other tools
11 reload a skill if its instructions are no longer in context
12 example:
@@ -11,8 +14,9 @@ example:
14 {
15 "thoughts": ["The user's request sounds like a skill trigger phrase, so I should search first."],
16 "headline": "Searching for relevant skill",
14 - "tool_name": "skills_tool:search",
17 + "tool_name": "skills_tool",
18 "tool_args": {
19 + "action": "search",
20 "query": "set up a0 cli connector"
21 }
22 }
prompts/agent.system.tools.md
+1
@@ -1,3 +1,4 @@
1 ## available tools
2 use ONLY the tools listed below. match names exactly. do NOT invent tool names.
3 +Action names are not tool names. There is no top-level `multi` or batch tool; call one listed tool at a time. If a tool has an action named `multi`, keep that action inside `tool_args.action` for that specific tool.
4 {{tools}}
skills/computer-use-remote/SKILL.md
+28 -1
@@ -1,6 +1,6 @@
1 ---
2 name: computer-use-remote
3 -description: Detailed operating guide for using computer_use_remote on the connected local machine. Load this skill before using computer_use_remote for desktop control, screenshots, menus, browser chrome, or other native UI tasks.
3 +description: Beta local desktop control through a connected A0 CLI host; use for screenshots, menus, browser chrome, and native UI tasks.
4 version: 1.1.0
5 author: Agent Zero Team
6 tags: ["computer-use", "desktop", "local-ui", "screenshots", "native-ui"]
@@ -18,6 +18,8 @@ allowed_tools:
18
19 # Computer Use Remote
20
21 +This skill unlocks the beta `computer_use_remote` tool for connected local desktop control through A0 CLI.
22 +
23 ## When to Use
24
25 Load this skill before using `computer_use_remote` for local desktop and native UI tasks on the connected machine.
@@ -26,6 +28,31 @@ If the task is browser-only and the user is flexible, prefer direct browser tool
28
29 If the task needs shell execution on the CLI host, load `code-execution-remote` separately rather than treating desktop control and shell execution as one affordance.
30
31 +## Tool Contract
32 +
33 +Use:
34 +
35 +```json
36 +{
37 + "tool_name": "computer_use_remote",
38 + "tool_args": {
39 + "action": "start_session"
40 + }
41 +}
42 +```
43 +
44 +Arguments:
45 +
46 +- `action`: `start_session`, `status`, `capture`, `move`, `click`, `scroll`, `key`, `type`, `stop_session`
47 +- `session_id`: optional after `start_session`
48 +- `move`: `x`, `y` normalized to `[0,1]`
49 +- `click`: optional `x`, `y`, optional `button` (`left`, `right`, `middle`), optional `count`
50 +- `scroll`: `dx`, `dy`
51 +- `key`: `key` or `keys`
52 +- `type`: `text`, optional `submit` boolean
53 +
54 +Availability, backend support, and trust mode are checked when the tool runs. If no CLI is connected or local computer use is disabled, tell the user what to enable instead of using the server environment.
55 +
56 ## Core Loop
57
58 1. Call `start_session` first.
skills/scheduler-tasks/SKILL.md new
+53
@@ -0,0 +1,53 @@
1 +---
2 +name: scheduler-tasks
3 +description: Use for complex Agent Zero scheduler work, including creating, updating, deleting, running, waiting for, timezone-correcting, or auditing scheduled, planned, and adhoc tasks.
4 +---
5 +
6 +# Scheduler Tasks
7 +
8 +Use the `scheduler` tool to manage saved tasks. Always inspect existing tasks before creating, updating, deleting, or running one.
9 +
10 +## Actions
11 +
12 +- `list_tasks`: optional `state[]`, `type[]`, `next_run_within`, `next_run_after`
13 +- `find_task_by_name`: `name`
14 +- `show_task`: `uuid`
15 +- `run_task`: `uuid`, optional `context`
16 +- `update_task`: `uuid`, optional `name`, `system_prompt`, `prompt`, `attachments[]`, `schedule`, `timezone`, `plan[]`, `state`, `dedicated_context`
17 +- `delete_task`: `uuid`
18 +- `create_scheduled_task`: `name`, `system_prompt`, `prompt`, optional `attachments[]`, `schedule`, `timezone`, `dedicated_context`
19 +- `create_adhoc_task`: `name`, `system_prompt`, `prompt`, optional `attachments[]`, `dedicated_context`
20 +- `create_planned_task`: `name`, `system_prompt`, `prompt`, optional `attachments[]`, `plan[]`, `dedicated_context`
21 +- `wait_for_task`: `uuid`
22 +
23 +## Schedule Fields
24 +
25 +Schedules use cron-like fields:
26 +
27 +- `minute`
28 +- `hour`
29 +- `day`
30 +- `month`
31 +- `weekday`
32 +- `timezone`
33 +
34 +Use IANA timezones such as `Europe/Rome`. Omit timezone to use the current user timezone. Planned task datetimes should be ISO strings such as `2026-05-09T18:25:00`.
35 +
36 +## Safety
37 +
38 +- Do not create recursive task prompts that schedule more tasks.
39 +- Do not run a task just because it is scheduled; run only if the user asks.
40 +- Created tasks use a dedicated context unless `dedicated_context` is explicitly `false`.
41 +- For destructive operations, identify the task by UUID after lookup.
42 +
43 +## Example
44 +
45 +```json
46 +{
47 + "tool_name": "scheduler",
48 + "tool_args": {
49 + "action": "find_task_by_name",
50 + "name": "daily backup"
51 + }
52 +}
53 +```
tests/test_a0_connector_prompt_gating.py
+144 -56
@@ -1,5 +1,6 @@
1 import importlib.util
2 import sys
3 +import time
4 import uuid
5 from pathlib import Path
6
@@ -113,7 +114,7 @@ def _subscribe(
114 return sid
115
116
116 -def test_remote_tool_stubs_absent_without_subscribed_cli():
117 +def test_legacy_dynamic_remote_tool_gate_is_noop():
118 prompt = _apply_gate(_context_id())
119
120 assert "text_editor_remote tool" not in prompt
@@ -121,81 +122,156 @@ def test_remote_tool_stubs_absent_without_subscribed_cli():
122 assert "computer_use_remote tool" not in prompt
123
124
124 -def test_file_only_cli_adds_text_editor_stub():
125 +def test_remote_file_and_exec_tools_are_standard_tool_prompts_independent_from_context():
126 + text_stub = (PROMPT_ROOT / "agent.system.tool.text_editor_remote.md").read_text(encoding="utf-8")
127 + exec_stub = (PROMPT_ROOT / "agent.system.tool.code_execution_remote.md").read_text(encoding="utf-8")
128 +
129 + assert '"tool_name": "text_editor_remote"' in text_stub
130 + assert '"tool_name": "code_execution_remote"' in exec_stub
131 + assert "Availability and permissions are checked when the tool runs" in text_stub
132 + assert "Availability and permissions are checked when the tool runs" in exec_stub
133 +
134 +
135 +def test_beta_computer_use_remote_is_skill_only_not_standard_tool_prompt():
136 + skill = PROJECT_ROOT / "skills" / "computer-use-remote" / "SKILL.md"
137 +
138 + assert not (PROMPT_ROOT / "agent.system.tool.computer_use_remote.md").exists()
139 + assert '"tool_name": "computer_use_remote"' in skill.read_text(encoding="utf-8")
140 +
141 +
142 +def test_old_connector_prompt_files_removed():
143 + assert not (PROMPT_ROOT / "agent.connector_tool.text_editor_remote.md").exists()
144 + assert not (PROMPT_ROOT / "agent.connector_tool.code_execution_remote.md").exists()
145 + assert not (PROMPT_ROOT / "agent.connector_tool.computer_use_remote.md").exists()
146 +
147 +
148 +def test_remote_tool_selection_prefers_context_cli_then_global_cli():
149 context_id = _context_id()
126 - sid = _subscribe(
127 - context_id,
128 - remote_files={"enabled": True, "write_enabled": True},
129 - )
150 + sid_context = _sid()
151 + sid_global = _sid()
152 + for sid in (sid_context, sid_global):
153 + ws_runtime.register_sid(sid)
154 + ws_runtime.store_sid_remote_exec_metadata(sid, {"enabled": True})
155 + ws_runtime.store_sid_remote_file_metadata(
156 + sid,
157 + {"enabled": True, "write_enabled": True, "mode": "read_write"},
158 + )
159 + ws_runtime.subscribe_sid_to_context(sid_context, context_id)
160 try:
131 - prompt = _apply_gate(context_id)
161 + assert ws_runtime.remote_tool_sids_for_context(context_id) == [
162 + sid_context,
163 + sid_global,
164 + ]
165 + assert ws_runtime.select_remote_exec_target_sid(context_id) == sid_context
166 + assert (
167 + ws_runtime.select_remote_exec_target_sid(context_id, require_writes=True)
168 + == sid_context
169 + )
170 + assert ws_runtime.select_remote_file_target_sid(context_id) == sid_context
171 finally:
133 - ws_runtime.unregister_sid(sid)
172 + ws_runtime.unregister_sid(sid_context)
173 + ws_runtime.unregister_sid(sid_global)
174
135 - assert "text_editor_remote tool" in prompt
136 - assert "Current access mode: `Read&Write`" in prompt
137 - assert "code_execution_remote tool" not in prompt
138 - assert "computer_use_remote tool" not in prompt
175
140 -
141 -def test_exec_enabled_cli_adds_execution_stub():
176 +def test_remote_tool_selection_falls_back_to_global_cli():
177 context_id = _context_id()
143 - sid = _subscribe(
144 - context_id,
145 - remote_exec={"enabled": True},
178 + sid = _sid()
179 + ws_runtime.register_sid(sid)
180 + ws_runtime.store_sid_remote_exec_metadata(sid, {"enabled": True})
181 + ws_runtime.store_sid_remote_file_metadata(
182 + sid,
183 + {"enabled": True, "write_enabled": True, "mode": "read_write"},
184 )
185 try:
148 - prompt = _apply_gate(context_id)
186 + assert ws_runtime.select_remote_exec_target_sid(context_id) == sid
187 + assert (
188 + ws_runtime.select_remote_exec_target_sid(context_id, require_writes=True)
189 + == sid
190 + )
191 + assert ws_runtime.select_remote_file_target_sid(context_id) == sid
192 finally:
193 ws_runtime.unregister_sid(sid)
194
152 - assert "code_execution_remote tool" in prompt
153 - assert "text_editor_remote tool" not in prompt
154 - assert "computer_use_remote tool" not in prompt
155 -
195
157 -def test_read_only_mode_marks_mutating_operations_disabled():
196 +def test_latest_remote_tree_falls_back_to_global_cli_snapshot():
197 context_id = _context_id()
159 - sid = _subscribe(
160 - context_id,
161 - remote_files={"enabled": True, "write_enabled": False, "mode": "read_only"},
162 - remote_exec={"enabled": True},
198 + sid = _sid()
199 + ws_runtime.register_sid(sid)
200 + ws_runtime.store_remote_tree_snapshot(
201 + sid,
202 + {
203 + "root_path": "/home/example",
204 + "tree": "README.md",
205 + "generated_at": "2026-05-09T12:00:00Z",
206 + },
207 )
208 try:
165 - prompt = _apply_gate(context_id)
209 + snapshot = ws_runtime.latest_remote_tree_for_context(
210 + context_id,
211 + max_age_seconds=90,
212 + )
213 finally:
214 ws_runtime.unregister_sid(sid)
215
169 - assert "text_editor_remote tool" in prompt
170 - assert "code_execution_remote tool" in prompt
171 - assert "Current access mode: `Read only`" in prompt
172 - assert "Writes and patches are disabled" in prompt
173 - assert "Mutating runtimes are disabled" in prompt
216 + assert snapshot is not None
217 + assert snapshot["sid"] == sid
218 + assert snapshot["tree"] == "README.md"
219
220
176 -def test_computer_use_enabled_cli_adds_computer_stub():
221 +def test_latest_remote_tree_prefers_context_cli_snapshot():
222 context_id = _context_id()
178 - sid = _subscribe(
179 - context_id,
180 - computer_use={
181 - "supported": True,
182 - "enabled": True,
183 - "trust_mode": "ask",
184 - "backend_id": "local",
185 - "backend_family": "desktop",
186 - "features": ["screenshots", "keyboard"],
223 + sid_context = _sid()
224 + sid_global = _sid()
225 + now = time.time()
226 + for sid in (sid_context, sid_global):
227 + ws_runtime.register_sid(sid)
228 + ws_runtime.subscribe_sid_to_context(sid_context, context_id)
229 + ws_runtime.store_remote_tree_snapshot(
230 + sid_context,
231 + {
232 + "root_path": "/context",
233 + "tree": "context.txt",
234 + "generated_at": "2026-05-09T12:00:00Z",
235 + },
236 + )
237 + ws_runtime.store_remote_tree_snapshot(
238 + sid_global,
239 + {
240 + "root_path": "/global",
241 + "tree": "global.txt",
242 + "generated_at": "2026-05-09T12:00:01Z",
243 },
244 )
245 try:
190 - prompt = _apply_gate(context_id)
246 + # Make the global snapshot newer; context affinity should still win.
247 + ws_runtime._remote_tree_snapshots[sid_global] = ws_runtime.RemoteTreeSnapshot(
248 + sid=sid_global,
249 + payload=ws_runtime._remote_tree_snapshots[sid_global].payload,
250 + updated_at=now + 5,
251 + )
252 + snapshot = ws_runtime.latest_remote_tree_for_context(
253 + context_id,
254 + max_age_seconds=90,
255 + )
256 finally:
192 - ws_runtime.unregister_sid(sid)
257 + ws_runtime.unregister_sid(sid_context)
258 + ws_runtime.unregister_sid(sid_global)
259
194 - assert "computer_use_remote tool" in prompt
195 - assert "Backend: `local/desktop`" in prompt
196 - assert "Features: `screenshots, keyboard`" in prompt
197 - assert "text_editor_remote tool" not in prompt
198 - assert "code_execution_remote tool" not in prompt
260 + assert snapshot is not None
261 + assert snapshot["sid"] == sid_context
262 + assert snapshot["tree"] == "context.txt"
263 +
264 +
265 +def test_remote_exec_mutating_runtime_requires_explicit_write_access():
266 + context_id = _context_id()
267 + sid = _sid()
268 + ws_runtime.register_sid(sid)
269 + ws_runtime.store_sid_remote_exec_metadata(sid, {"enabled": True})
270 + try:
271 + assert ws_runtime.select_remote_exec_target_sid(context_id) == sid
272 + assert ws_runtime.select_remote_exec_target_sid(context_id, require_writes=True) is None
273 + finally:
274 + ws_runtime.unregister_sid(sid)
275
276
277 def test_remote_affordance_skills_parse():
@@ -230,17 +306,29 @@ def test_remote_affordance_skills_parse():
306 assert not legacy_connector_skill.exists()
307 assert text_editor_skill["name"] == "text-editor-remote"
308 assert text_editor_skill["allowed_tools"] == ["text_editor_remote"]
309 + assert "connected local files" in text_editor_skill["trigger_patterns"]
310 + assert "not docker" in code_execution_skill["trigger_patterns"]
311 + assert "connected local terminal" in code_execution_skill["trigger_patterns"]
312 assert code_execution_skill["name"] == "code-execution-remote"
313 assert code_execution_skill["allowed_tools"] == ["code_execution_remote"]
314 assert computer_skill["name"] == "computer-use-remote"
315 assert computer_skill["allowed_tools"] == ["computer_use_remote"]
316
317
239 -def test_remote_tool_stubs_point_to_per_tool_skills():
240 - text_stub = (PROMPT_ROOT / "agent.connector_tool.text_editor_remote.md").read_text(encoding="utf-8")
241 - exec_stub = (PROMPT_ROOT / "agent.connector_tool.code_execution_remote.md").read_text(encoding="utf-8")
242 -
243 - assert "Load `text-editor-remote`" in text_stub
244 - assert "Load `code-execution-remote`" in exec_stub
318 +def test_remote_tool_stubs_are_self_contained_and_reference_per_tool_skills():
319 + text_stub = (PROMPT_ROOT / "agent.system.tool.text_editor_remote.md").read_text(encoding="utf-8")
320 + exec_stub = (PROMPT_ROOT / "agent.system.tool.code_execution_remote.md").read_text(encoding="utf-8")
321 + computer_skill = (PROJECT_ROOT / "skills" / "computer-use-remote" / "SKILL.md").read_text(encoding="utf-8")
322 +
323 + assert "optionally load skill `text-editor-remote`" in text_stub
324 + assert "optionally load skill `code-execution-remote`" in exec_stub
325 + assert '"tool_name": "text_editor_remote"' in text_stub
326 + assert '"tool_name": "code_execution_remote"' in exec_stub
327 + assert '"tool_name": "computer_use_remote"' in computer_skill
328 + assert "Availability, backend support, and trust mode are checked when the tool runs" in computer_skill
329 + assert "not `code_execution_tool`" in exec_stub
330 + assert "not to" in exec_stub
331 + assert "Docker/server/container execution" in exec_stub
332 assert "a0-cli-remote-workflows" not in text_stub
333 assert "a0-cli-remote-workflows" not in exec_stub
334 + assert "a0-cli-remote-workflows" not in computer_skill
tests/test_default_prompt_budget.py
+7 -5
@@ -51,19 +51,21 @@ async def test_default_agent0_prompt_budget_and_guardrails():
51 assert "tool_args` must stay a json object" in system_text
52 assert '"tool_name": "call_subordinate"' in system_text
53 assert '"reset": true' in system_text
54 - assert '"tool_name": "text_editor:read"' in system_text
54 + assert '"tool_name": "text_editor"' in system_text
55 + assert '"action": "read"' in system_text
56 assert '"tool_name": "code_execution_tool"' in system_text
57 assert '"tool_name": "memory_load"' in system_text
58 assert "informative but tight" in system_text
58 - assert "# code_execution_remote tool" not in system_text
59 - assert "# text_editor_remote tool" not in system_text
60 - assert "# computer_use_remote tool" not in system_text
59 + assert '"tool_name": "code_execution_remote"' in system_text
60 + assert '"tool_name": "text_editor_remote"' in system_text
61 + assert '"tool_name": "computer_use_remote"' not in system_text
62 + assert "computer-use-remote" in system_text
63
64
65 def test_a0_small_profile_removed_and_prompt_text_generic():
66 assert not (PROJECT_ROOT / "agents" / "a0_small").exists()
67 assert not (PROJECT_ROOT / "knowledge" / "main" / "a0_small_tool_call_examples.md").exists()
66 - assert (PROJECT_ROOT / "knowledge" / "main" / "tool_call_reference_examples.md").exists()
68 + assert not (PROJECT_ROOT / "knowledge" / "main" / "tool_call_reference_examples.md").exists()
69
70 for path in _iter_prompt_files():
71 assert "a0_small" not in path.read_text(encoding="utf-8")
tests/test_office_document_store.py
+1 -1
@@ -312,7 +312,7 @@ def test_odf_is_advertised_and_docx_remains_explicit_compatibility(office_state)
312 assert "formats: md odt ods odp docx xlsx pptx" in prompt
313 assert "ODF is first-class for LibreOffice" in prompt
314 assert "DOCX/XLSX/PPTX are compatibility formats" in prompt
315 - assert "`method` is accepted as an alias for action" in prompt
315 + assert "`method` is accepted as an alias for action" not in prompt
316 assert "they do not open a surface automatically" in prompt
317 assert "explicit Download, Open Document, or Desktop edit message actions" in prompt
318 doc = document_store.create_document("document", "Use ODT", "odt", "")
tests/test_task_scheduler_timezone.py new
+65
@@ -0,0 +1,65 @@
1 +from datetime import datetime, timezone
2 +from pathlib import Path
3 +import sys
4 +from types import SimpleNamespace
5 +
6 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
7 +if str(PROJECT_ROOT) not in sys.path:
8 + sys.path.insert(0, str(PROJECT_ROOT))
9 +
10 +from helpers import task_scheduler
11 +from helpers.task_scheduler import ScheduledTask, TaskSchedule
12 +
13 +
14 +class FixedDateTime(datetime):
15 + @classmethod
16 + def now(cls, tz=None):
17 + value = cls(2026, 5, 9, 10, 0, tzinfo=timezone.utc)
18 + if tz is None:
19 + return value.replace(tzinfo=None)
20 + return value.astimezone(tz)
21 +
22 +
23 +def test_scheduled_task_next_run_uses_schedule_timezone(monkeypatch):
24 + monkeypatch.setattr(task_scheduler, "datetime", FixedDateTime)
25 + task = ScheduledTask.create(
26 + name="rome morning",
27 + system_prompt="",
28 + prompt="remind me",
29 + schedule=TaskSchedule(
30 + minute="30",
31 + hour="9",
32 + day="10",
33 + month="5",
34 + weekday="*",
35 + timezone="Europe/Rome",
36 + ),
37 + timezone="Europe/Rome",
38 + )
39 +
40 + assert task.get_next_run() == datetime(2026, 5, 10, 7, 30, tzinfo=timezone.utc)
41 +
42 +
43 +def test_scheduled_task_normalizes_legacy_local_timezone(monkeypatch):
44 + monkeypatch.setattr(task_scheduler, "datetime", FixedDateTime)
45 + monkeypatch.setattr(
46 + task_scheduler,
47 + "Localization",
48 + SimpleNamespace(get=lambda: SimpleNamespace(get_timezone=lambda: "Europe/Rome")),
49 + )
50 + task = ScheduledTask.create(
51 + name="legacy local",
52 + system_prompt="",
53 + prompt="remind me",
54 + schedule=TaskSchedule(
55 + minute="30",
56 + hour="9",
57 + day="10",
58 + month="5",
59 + weekday="*",
60 + timezone="local",
61 + ),
62 + )
63 +
64 + assert task.schedule.timezone == "Europe/Rome"
65 + assert task.get_next_run() == datetime(2026, 5, 10, 7, 30, tzinfo=timezone.utc)
tests/test_text_editor_context_patch.py
+30
@@ -327,6 +327,11 @@ class _FakeAgent:
327 self.data = {}
328
329 def read_prompt(self, name: str, **kwargs) -> str:
330 + if name.endswith("read_ok.md"):
331 + return (
332 + f"{kwargs['path']} read {kwargs['total_lines']} lines\n"
333 + f">>>\n{kwargs['content']}\n<<<"
334 + )
335 if name.endswith("patch_ok.md"):
336 return (
337 f"{kwargs['path']} patched {kwargs['edit_count']} edits applied "
@@ -341,6 +346,7 @@ class _FakeAgent:
346
347 def _load_text_editor_tool(monkeypatch: pytest.MonkeyPatch):
348 calls: list[tuple[str, dict | None]] = []
349 + import helpers
350
351 tool_stub = types.ModuleType("helpers.tool")
352 tool_stub.Tool = _FakeTool
@@ -370,6 +376,9 @@ def _load_text_editor_tool(monkeypatch: pytest.MonkeyPatch):
376 monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub)
377 monkeypatch.setitem(sys.modules, "helpers.plugins", plugins_stub)
378 monkeypatch.setitem(sys.modules, "helpers.runtime", runtime_stub)
379 + monkeypatch.setattr(helpers, "extension", extension_stub, raising=False)
380 + monkeypatch.setattr(helpers, "plugins", plugins_stub, raising=False)
381 + monkeypatch.setattr(helpers, "runtime", runtime_stub, raising=False)
382 sys.modules.pop("plugins._text_editor.tools.text_editor", None)
383 module = importlib.import_module("plugins._text_editor.tools.text_editor")
384 return module, calls
@@ -426,6 +435,27 @@ def test_text_editor_patch_text_does_not_require_prior_read(
435 assert calls[1][1]["mode"] == "patch_text"
436
437
438 +def test_text_editor_execute_accepts_action_alias_for_read(
439 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
440 +) -> None:
441 + module, _calls = _load_text_editor_tool(monkeypatch)
442 + target = tmp_path / "sample.txt"
443 + target.write_text("line-1\nline-2\n", encoding="utf-8")
444 + tool = module.TextEditor(
445 + _FakeAgent(),
446 + "text_editor",
447 + None,
448 + {"action": "read", "path": str(target), "line_from": 1, "line_to": 1},
449 + "",
450 + None,
451 + )
452 +
453 + response = asyncio.run(tool.execute(**tool.args))
454 +
455 + assert "read 2 lines" in response.message
456 + assert "line-1" in response.message
457 +
458 +
459 def test_text_editor_patch_text_rejects_simultaneous_edits(
460 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
461 ) -> None:
tests/test_tool_action_contracts.py new
+472
@@ -0,0 +1,472 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +import importlib
5 +import sys
6 +import types
7 +from dataclasses import dataclass
8 +from pathlib import Path
9 +
10 +
11 +@dataclass
12 +class _FakeResponse:
13 + message: str
14 + break_loop: bool
15 + additional: dict | None = None
16 +
17 +
18 +class _FakeTool:
19 + def __init__(
20 + self,
21 + agent,
22 + name: str,
23 + method: str | None,
24 + args: dict | None,
25 + message: str,
26 + loop_data=None,
27 + **kwargs,
28 + ) -> None:
29 + self.agent = agent
30 + self.name = name
31 + self.method = method
32 + self.args = args or {}
33 + self.message = message
34 + self.loop_data = loop_data
35 +
36 +
37 +class _FakeAgent:
38 + def __init__(self) -> None:
39 + self.data = {}
40 + self.context = types.SimpleNamespace(id="ctx")
41 +
42 + def read_prompt(self, _name: str, **kwargs) -> str:
43 + return f"deleted {kwargs.get('memory_count', 0)}"
44 +
45 +
46 +@dataclass
47 +class _FakeSkill:
48 + name: str
49 + description: str
50 + path: Path
51 + version: str = ""
52 + tags: list[str] | None = None
53 +
54 +
55 +def _install_tool_stub(monkeypatch) -> None:
56 + tool_stub = types.ModuleType("helpers.tool")
57 + tool_stub.Tool = _FakeTool
58 + tool_stub.Response = _FakeResponse
59 + monkeypatch.setitem(sys.modules, "helpers.tool", tool_stub)
60 +
61 +
62 +def _load_skills_tool(monkeypatch, skill_root: Path):
63 + _install_tool_stub(monkeypatch)
64 +
65 + skills_stub = types.ModuleType("helpers.skills")
66 + skills_stub.AGENT_DATA_NAME_LOADED_SKILLS = "loaded_skills"
67 + skills_stub.MAX_ACTIVE_SKILLS = 20
68 + fake_skill = _FakeSkill(
69 + name="browser-forms",
70 + description="Use for complex browser forms.",
71 + path=skill_root,
72 + tags=[],
73 + )
74 + skills_stub.list_skills = lambda *args, **kwargs: [fake_skill]
75 + skills_stub.search_skills = lambda *args, **kwargs: [fake_skill]
76 + skills_stub.find_skill = lambda *args, **kwargs: fake_skill
77 + monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
78 +
79 + print_style_stub = types.ModuleType("helpers.print_style")
80 + print_style_stub.PrintStyle = lambda *args, **kwargs: types.SimpleNamespace(
81 + print=lambda *a, **k: None
82 + )
83 + monkeypatch.setitem(sys.modules, "helpers.print_style", print_style_stub)
84 +
85 + sys.modules.pop("tools.skills_tool", None)
86 + return importlib.import_module("tools.skills_tool")
87 +
88 +
89 +def test_skills_tool_accepts_action_alias_for_search(monkeypatch, tmp_path: Path):
90 + module = _load_skills_tool(monkeypatch, tmp_path)
91 + tool = module.SkillsTool(
92 + _FakeAgent(),
93 + "skills_tool",
94 + None,
95 + {"action": "search", "query": "browser forms"},
96 + "",
97 + None,
98 + )
99 +
100 + response = asyncio.run(tool.execute(**tool.args))
101 +
102 + assert "browser-forms" in response.message
103 +
104 +
105 +def test_skills_tool_read_file_action_reads_inside_skill_dir(
106 + monkeypatch, tmp_path: Path
107 +):
108 + skill_root = tmp_path / "browser-forms"
109 + skill_root.mkdir()
110 + (skill_root / "notes.md").write_text("Use labels before typing.\n", encoding="utf-8")
111 + module = _load_skills_tool(monkeypatch, skill_root)
112 + tool = module.SkillsTool(
113 + _FakeAgent(),
114 + "skills_tool",
115 + None,
116 + {
117 + "action": "read_file",
118 + "skill_name": "browser-forms",
119 + "file_path": "notes.md",
120 + },
121 + "",
122 + None,
123 + )
124 +
125 + response = asyncio.run(tool.execute(**tool.args))
126 +
127 + assert "Skill file: browser-forms/notes.md" in response.message
128 + assert "Use labels before typing." in response.message
129 +
130 +
131 +def test_memory_forget_tool_imports_plugin_memory_load(monkeypatch):
132 + _install_tool_stub(monkeypatch)
133 + monkeypatch.syspath_prepend(str(Path.cwd()))
134 +
135 + class FakeDb:
136 + def __init__(self) -> None:
137 + self.calls = []
138 +
139 + async def delete_documents_by_query(self, **kwargs):
140 + self.calls.append(kwargs)
141 + return ["memory-1"]
142 +
143 + fake_db = FakeDb()
144 +
145 + async def get_memory(_agent):
146 + return fake_db
147 +
148 + memory_stub = types.ModuleType("plugins._memory.helpers.memory")
149 + memory_stub.Memory = types.SimpleNamespace(get=get_memory)
150 + monkeypatch.setitem(sys.modules, "plugins._memory.helpers.memory", memory_stub)
151 +
152 + sys.modules.pop("plugins._memory.tools.memory_load", None)
153 + sys.modules.pop("plugins._memory.tools.memory_forget", None)
154 + module = importlib.import_module("plugins._memory.tools.memory_forget")
155 + tool = module.MemoryForget(
156 + _FakeAgent(),
157 + "memory_forget",
158 + None,
159 + {
160 + "query": "codex memory forget token",
161 + "threshold": 0.99,
162 + "filter": "area=='codex_sweep'",
163 + },
164 + "",
165 + None,
166 + )
167 +
168 + response = asyncio.run(tool.execute(**tool.args))
169 +
170 + assert response.message == "deleted 1"
171 + assert fake_db.calls == [
172 + {
173 + "query": "codex memory forget token",
174 + "threshold": 0.99,
175 + "filter": "area=='codex_sweep'",
176 + }
177 + ]
178 +
179 +
180 +def test_behaviour_adjustment_normalizes_duplicate_rules(monkeypatch):
181 + _install_tool_stub(monkeypatch)
182 + monkeypatch.syspath_prepend(str(Path.cwd()))
183 +
184 + agent_stub = types.ModuleType("agent")
185 + agent_stub.Agent = object
186 + monkeypatch.setitem(sys.modules, "agent", agent_stub)
187 +
188 + log_stub = types.ModuleType("helpers.log")
189 + log_stub.LogItem = object
190 + monkeypatch.setitem(sys.modules, "helpers.log", log_stub)
191 +
192 + memory_stub = types.ModuleType("plugins._memory.helpers.memory")
193 + memory_stub.get_memory_subdir_abs = lambda agent: "/tmp"
194 + monkeypatch.setitem(sys.modules, "plugins._memory.helpers.memory", memory_stub)
195 +
196 + sys.modules.pop("plugins._memory.tools.behaviour_adjustment", None)
197 + module = importlib.import_module("plugins._memory.tools.behaviour_adjustment")
198 +
199 + rules = module.normalize_ruleset(
200 + "## Behavioral rules\n"
201 + "* Favor Linux commands.\n"
202 + "* Token rule.## Behavioral rules\n"
203 + "* Favor Linux commands.\n"
204 + "* Token rule."
205 + )
206 +
207 + assert rules == "## Behavioral rules\n* Favor Linux commands.\n* Token rule.\n"
208 +
209 +
210 +def test_notify_user_prompt_documents_numeric_priority_values():
211 + prompt = Path("prompts/agent.system.tool.notify_user.md").read_text(
212 + encoding="utf-8"
213 + )
214 +
215 + assert "priority values: `20` high urgency, `10` normal urgency" in prompt
216 +
217 +
218 +def test_tool_prompts_prevent_top_level_multi_tool():
219 + tools_prompt = Path("prompts/agent.system.tools.md").read_text(encoding="utf-8")
220 + communication_prompt = Path("prompts/agent.system.main.communication.md").read_text(
221 + encoding="utf-8"
222 + )
223 + browser_prompt = Path("plugins/_browser/prompts/agent.system.tool.browser.md").read_text(
224 + encoding="utf-8"
225 + )
226 +
227 + assert "There is no top-level `multi` or batch tool" in tools_prompt
228 + assert "never an action name such as `read`, `write`, `terminal`, or `multi`" in communication_prompt
229 + assert 'Never use `tool_name: "multi"`' in browser_prompt
230 +
231 +
232 +def _load_scheduler_tool(monkeypatch):
233 + _install_tool_stub(monkeypatch)
234 +
235 + scheduler_stub = types.ModuleType("helpers.task_scheduler")
236 + scheduler_stub.TaskScheduler = object
237 + scheduler_stub.ScheduledTask = type("ScheduledTask", (), {})
238 + scheduler_stub.AdHocTask = type("AdHocTask", (), {})
239 + scheduler_stub.PlannedTask = type("PlannedTask", (), {})
240 + scheduler_stub.serialize_task = lambda task: {}
241 + scheduler_stub.parse_datetime = lambda value: None
242 + scheduler_stub.parse_task_plan = lambda value: None
243 + scheduler_stub.serialize_datetime = lambda value: value
244 + scheduler_stub.TaskState = types.SimpleNamespace(
245 + IDLE="idle",
246 + RUNNING="running",
247 + )
248 + scheduler_stub.TaskSchedule = type("TaskSchedule", (), {})
249 + scheduler_stub.TaskPlan = type("TaskPlan", (), {})
250 + monkeypatch.setitem(sys.modules, "helpers.task_scheduler", scheduler_stub)
251 +
252 + agent_stub = types.ModuleType("agent")
253 + agent_stub.AgentContext = types.SimpleNamespace(
254 + get=lambda *args, **kwargs: None,
255 + remove=lambda *args, **kwargs: None,
256 + )
257 + monkeypatch.setitem(sys.modules, "agent", agent_stub)
258 +
259 + persist_chat_stub = types.ModuleType("helpers.persist_chat")
260 + persist_chat_stub.remove_chat = lambda *args, **kwargs: None
261 + monkeypatch.setitem(sys.modules, "helpers.persist_chat", persist_chat_stub)
262 +
263 + projects_stub = types.ModuleType("helpers.projects")
264 + projects_stub.get_context_project_name = lambda context: ""
265 + projects_stub.load_basic_project_data = lambda project: {}
266 + monkeypatch.setitem(sys.modules, "helpers.projects", projects_stub)
267 +
268 + sys.modules.pop("tools.scheduler", None)
269 + return importlib.import_module("tools.scheduler")
270 +
271 +
272 +def test_scheduler_accepts_action_alias(monkeypatch):
273 + module = _load_scheduler_tool(monkeypatch)
274 + tool = module.SchedulerTool(
275 + _FakeAgent(),
276 + "scheduler",
277 + None,
278 + {"action": "list_tasks"},
279 + "",
280 + None,
281 + )
282 +
283 + async def list_tasks(**kwargs):
284 + return module.Response("listed", False)
285 +
286 + tool.list_tasks = list_tasks
287 +
288 + response = asyncio.run(tool.execute(**tool.args))
289 +
290 + assert response.message == "listed"
291 +
292 +
293 +def test_scheduler_requires_action_field(monkeypatch):
294 + module = _load_scheduler_tool(monkeypatch)
295 + tool = module.SchedulerTool(
296 + _FakeAgent(),
297 + "scheduler",
298 + "list_tasks",
299 + {},
300 + "",
301 + None,
302 + )
303 +
304 + response = asyncio.run(tool.execute(**tool.args))
305 +
306 + assert "Unknown scheduler action" in response.message
307 +
308 +
309 +def test_scheduler_create_defaults_to_dedicated_context(monkeypatch):
310 + module = _load_scheduler_tool(monkeypatch)
311 +
312 + class FakeTaskSchedule:
313 + def __init__(self, **kwargs):
314 + self.__dict__.update(kwargs)
315 +
316 + def to_crontab(self):
317 + return f"{self.minute} {self.hour} {self.day} {self.month} {self.weekday}"
318 +
319 + class FakeScheduledTask:
320 + @classmethod
321 + def create(cls, **kwargs):
322 + task = cls()
323 + task.uuid = "task-1"
324 + task.context_id = kwargs.get("context_id")
325 + task.schedule = kwargs.get("schedule")
326 + return task
327 +
328 + class FakeScheduler:
329 + def __init__(self):
330 + self.added = None
331 +
332 + async def add_task(self, task):
333 + self.added = task
334 +
335 + fake_scheduler = FakeScheduler()
336 + module.TaskSchedule = FakeTaskSchedule
337 + module.ScheduledTask = FakeScheduledTask
338 + module.TaskScheduler = types.SimpleNamespace(get=lambda: fake_scheduler)
339 + tool = module.SchedulerTool(
340 + _FakeAgent(),
341 + "scheduler",
342 + None,
343 + {
344 + "action": "create_scheduled_task",
345 + "name": "check stuff",
346 + "prompt": "tell me if anything changed",
347 + "schedule": {"minute": "0", "hour": "9", "day": "*", "month": "*", "weekday": "*"},
348 + },
349 + "",
350 + None,
351 + )
352 +
353 + response = asyncio.run(tool.execute(**tool.args))
354 +
355 + assert "created" in response.message
356 + assert fake_scheduler.added.context_id is None
357 +
358 +
359 +def test_scheduler_local_timezone_alias_uses_current_user_timezone(monkeypatch):
360 + module = _load_scheduler_tool(monkeypatch)
361 +
362 + class FakeTaskSchedule:
363 + def __init__(self, **kwargs):
364 + self.__dict__.update(kwargs)
365 +
366 + module.TaskSchedule = FakeTaskSchedule
367 + module.Localization = types.SimpleNamespace(
368 + get=lambda: types.SimpleNamespace(get_timezone=lambda: "Europe/Rome")
369 + )
370 +
371 + assert module._schedule_timezone({"schedule": {"timezone": "local"}}) == "Europe/Rome"
372 + schedule = module._task_schedule_from_input(
373 + {"minute": "30", "hour": "9", "day": "*", "month": "*", "weekday": "*", "timezone": "current"}
374 + )
375 +
376 + assert schedule.timezone == "Europe/Rome"
377 +
378 +
379 +def test_scheduler_invalid_timezone_returns_repairable_message(monkeypatch):
380 + module = _load_scheduler_tool(monkeypatch)
381 + tool = module.SchedulerTool(
382 + _FakeAgent(),
383 + "scheduler",
384 + None,
385 + {
386 + "action": "create_scheduled_task",
387 + "name": "bad timezone",
388 + "prompt": "tell me something",
389 + "schedule": {
390 + "minute": "0",
391 + "hour": "9",
392 + "day": "*",
393 + "month": "*",
394 + "weekday": "*",
395 + "timezone": "Mars/Base",
396 + },
397 + },
398 + "",
399 + None,
400 + )
401 +
402 + response = asyncio.run(tool.execute(**tool.args))
403 +
404 + assert "Invalid timezone: Mars/Base" in response.message
405 +
406 +
407 +def test_scheduler_prompt_includes_update_timezone_and_dedicated_context():
408 + project_root = Path(__file__).resolve().parents[1]
409 + text = (
410 + project_root / "prompts/agent.system.tool.scheduler.md"
411 + ).read_text(encoding="utf-8")
412 +
413 + assert "update_task" in text
414 + assert "timezone" in text
415 + assert "IANA" in text
416 + assert "dedicated context" in text
417 +
418 +
419 +def test_skills_prompt_renders_catalog_placeholder():
420 + project_root = Path(__file__).resolve().parents[1]
421 + text = (project_root / "prompts/agent.system.skills.md").read_text(
422 + encoding="utf-8"
423 + )
424 +
425 + assert "{{skills}}" in text
426 +
427 +
428 +def test_corrected_tool_prompts_only_teach_action_contract():
429 + project_root = Path(__file__).resolve().parents[1]
430 + prompt_paths = [
431 + project_root / "plugins/_text_editor/prompts/agent.system.tool.text_editor.md",
432 + project_root / "prompts/agent.system.tool.skills.md",
433 + project_root / "prompts/agent.system.tool.scheduler.md",
434 + project_root / "plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md",
435 + project_root / "plugins/_office/prompts/agent.system.tool.document_artifact.md",
436 + project_root / "plugins/_office/skills/document-artifacts/SKILL.md",
437 + project_root / "plugins/_office/skills/markdown-documents/SKILL.md",
438 + project_root / "plugins/_office/skills/writer-documents/SKILL.md",
439 + project_root / "plugins/_office/skills/calc-spreadsheets/SKILL.md",
440 + project_root / "plugins/_office/skills/impress-presentations/SKILL.md",
441 + ]
442 + forbidden = (
443 + "text_editor:",
444 + "skills_tool:",
445 + "scheduler:",
446 + "document_artifact:",
447 + "`method`",
448 + "`op`",
449 + "`operation`",
450 + "alias",
451 + )
452 +
453 + for path in prompt_paths:
454 + text = path.read_text(encoding="utf-8")
455 + assert "action" in text
456 + for token in forbidden:
457 + assert token not in text
458 +
459 +
460 +def test_computer_use_remote_is_skill_gated():
461 + project_root = Path(__file__).resolve().parents[1]
462 + prompt_path = (
463 + project_root
464 + / "plugins/_a0_connector/prompts/agent.system.tool.computer_use_remote.md"
465 + )
466 + skill_text = (
467 + project_root / "skills/computer-use-remote/SKILL.md"
468 + ).read_text(encoding="utf-8")
469 +
470 + assert not prompt_path.exists()
471 + assert '"tool_name": "computer_use_remote"' in skill_text
472 + assert "Beta local desktop control" in skill_text
tests/test_tool_request_normalization.py
+31
@@ -12,6 +12,13 @@ if str(PROJECT_ROOT) not in sys.path:
12 from helpers.extract_tools import normalize_tool_request
13
14
15 +def test_normalize_tool_request_accepts_canonical_keys() -> None:
16 + assert normalize_tool_request({"tool_name": "response", "tool_args": {"text": "ok"}}) == (
17 + "response",
18 + {"text": "ok"},
19 + )
20 +
21 +
22 def test_normalize_tool_request_accepts_fallback_keys() -> None:
23 assert normalize_tool_request({"tool": "response", "args": {"text": "ok"}}) == (
24 "response",
@@ -31,6 +38,30 @@ def test_normalize_tool_request_uses_fallback_when_canonical_args_are_invalid()
38 ) == ("response", {"text": "ok"})
39
40
41 +def test_normalize_tool_request_translates_method_suffix_to_action() -> None:
42 + assert normalize_tool_request(
43 + {"tool_name": "text_editor:read", "tool_args": {"path": "README.md"}}
44 + ) == ("text_editor", {"path": "README.md", "action": "read"})
45 +
46 +
47 +def test_normalize_tool_request_translates_method_arg_to_action() -> None:
48 + assert normalize_tool_request(
49 + {"tool_name": "scheduler", "tool_args": {"method": "list_tasks"}}
50 + ) == ("scheduler", {"method": "list_tasks", "action": "list_tasks"})
51 +
52 +
53 +def test_normalize_tool_request_preserves_explicit_action_over_method() -> None:
54 + assert normalize_tool_request(
55 + {
56 + "tool_name": "scheduler:delete_task",
57 + "tool_args": {"method": "list_tasks", "action": "show_task"},
58 + }
59 + ) == (
60 + "scheduler",
61 + {"method": "list_tasks", "action": "show_task"},
62 + )
63 +
64 +
65 def test_normalize_tool_request_rejects_missing_args() -> None:
66 with pytest.raises(ValueError, match="tool_args"):
67 normalize_tool_request({"tool_name": "response"})
tools/scheduler.py
+190 -39
@@ -3,41 +3,153 @@ from datetime import datetime
3 import json
4 import random
5 import re
6 +from typing import Any
7 +import pytz
8 from helpers.tool import Tool, Response
9 from helpers.task_scheduler import (
10 TaskScheduler, ScheduledTask, AdHocTask, PlannedTask,
9 - serialize_task, TaskState, TaskSchedule, TaskPlan, parse_datetime, serialize_datetime
11 + serialize_task, TaskState, TaskSchedule, TaskPlan, parse_datetime,
12 + parse_task_plan, serialize_datetime
13 )
14 from agent import AgentContext
15 from helpers import persist_chat
16 +from helpers.localization import Localization
17 from helpers.projects import get_context_project_name, load_basic_project_data
18
19 DEFAULT_WAIT_TIMEOUT = 300
20 +LOCAL_TIMEZONE_ALIASES = {"local", "user", "default", "current", "current_timezone"}
21 +
22 +
23 +def _current_action(tool: Tool, kwargs: dict) -> str:
24 + return (
25 + str(
26 + kwargs.get("action")
27 + or tool.args.get("action")
28 + or ""
29 + )
30 + .strip()
31 + .lower()
32 + .replace("-", "_")
33 + )
34 +
35 +
36 +def _normalize_timezone(value: Any) -> str | None:
37 + if value is None:
38 + return None
39 + timezone_name = str(value).strip()
40 + if not timezone_name:
41 + return None
42 + if timezone_name.lower() in LOCAL_TIMEZONE_ALIASES:
43 + return Localization.get().get_timezone()
44 + try:
45 + pytz.timezone(timezone_name)
46 + except pytz.exceptions.UnknownTimeZoneError as exc:
47 + raise ValueError(
48 + f"Invalid timezone: {timezone_name}. Use an IANA timezone name such as Europe/Rome, "
49 + "or omit timezone to use the current user timezone."
50 + ) from exc
51 + return timezone_name
52 +
53 +
54 +def _schedule_timezone(kwargs: dict) -> str | None:
55 + schedule = kwargs.get("schedule")
56 + if isinstance(schedule, dict) and schedule.get("timezone"):
57 + return _normalize_timezone(schedule["timezone"])
58 + if kwargs.get("timezone"):
59 + return _normalize_timezone(kwargs["timezone"])
60 + return None
61 +
62 +
63 +def _task_schedule_from_input(schedule: Any, timezone: str | None = None) -> TaskSchedule:
64 + if isinstance(schedule, str):
65 + parts = schedule.split()
66 + schedule_data: dict[str, Any] = {
67 + "minute": parts[0] if len(parts) > 0 else "*",
68 + "hour": parts[1] if len(parts) > 1 else "*",
69 + "day": parts[2] if len(parts) > 2 else "*",
70 + "month": parts[3] if len(parts) > 3 else "*",
71 + "weekday": parts[4] if len(parts) > 4 else "*",
72 + }
73 + elif isinstance(schedule, dict):
74 + schedule_data = dict(schedule)
75 + else:
76 + schedule_data = {}
77 +
78 + task_schedule_kwargs = {
79 + "minute": str(schedule_data.get("minute", "*")),
80 + "hour": str(schedule_data.get("hour", "*")),
81 + "day": str(schedule_data.get("day", "*")),
82 + "month": str(schedule_data.get("month", "*")),
83 + "weekday": str(schedule_data.get("weekday", "*")),
84 + }
85 + normalized_timezone = _normalize_timezone(timezone if timezone is not None else schedule_data.get("timezone"))
86 + if normalized_timezone:
87 + task_schedule_kwargs["timezone"] = normalized_timezone
88 +
89 + return TaskSchedule(**task_schedule_kwargs)
90 +
91 +
92 +def _validate_task_schedule(task_schedule: TaskSchedule) -> str:
93 + # Validate cron expression, agent might hallucinate
94 + cron_regex = r"^((((\d+,)+\d+|(\d+(\/|-|#)\d+)|\d+L?|\*(\/\d+)?|L(-\d+)?|\?|[A-Z]{3}(-[A-Z]{3})?) ?){5,7})$"
95 + crontab = task_schedule.to_crontab()
96 + return "" if re.match(cron_regex, crontab) else f"Invalid cron expression: {crontab}"
97 +
98 +
99 +def _task_plan_from_input(plan: Any) -> tuple[TaskPlan | None, str]:
100 + if isinstance(plan, dict):
101 + try:
102 + return parse_task_plan(plan), ""
103 + except Exception as exc:
104 + return None, f"Invalid plan: {exc}"
105 +
106 + if not isinstance(plan, list):
107 + return None, "Plan must be an array of ISO datetimes."
108 +
109 + todo: list[datetime] = []
110 + for item in plan:
111 + dt = parse_datetime(str(item))
112 + if dt is None:
113 + return None, f"Invalid datetime: {item}"
114 + todo.append(dt)
115 +
116 + return TaskPlan.create(todo=todo, in_progress=None, done=[]), ""
117
118
119 class SchedulerTool(Tool):
120
121 async def execute(self, **kwargs):
21 - if self.method == "list_tasks":
122 + action = _current_action(self, kwargs)
123 + if action == "list_tasks":
124 return await self.list_tasks(**kwargs)
23 - elif self.method == "find_task_by_name":
125 + elif action == "find_task_by_name":
126 return await self.find_task_by_name(**kwargs)
25 - elif self.method == "show_task":
127 + elif action == "show_task":
128 return await self.show_task(**kwargs)
27 - elif self.method == "run_task":
129 + elif action == "run_task":
130 return await self.run_task(**kwargs)
29 - elif self.method == "delete_task":
131 + elif action == "delete_task":
132 return await self.delete_task(**kwargs)
31 - elif self.method == "create_scheduled_task":
133 + elif action == "update_task":
134 + return await self.update_task(**kwargs)
135 + elif action == "create_scheduled_task":
136 return await self.create_scheduled_task(**kwargs)
33 - elif self.method == "create_adhoc_task":
137 + elif action == "create_adhoc_task":
138 return await self.create_adhoc_task(**kwargs)
35 - elif self.method == "create_planned_task":
139 + elif action == "create_planned_task":
140 return await self.create_planned_task(**kwargs)
37 - elif self.method == "wait_for_task":
141 + elif action == "wait_for_task":
142 return await self.wait_for_task(**kwargs)
143 else:
40 - return Response(message=f"Unknown method '{self.name}:{self.method}'", break_loop=False)
144 + return Response(
145 + message=(
146 + f"Unknown scheduler action '{action or self.method or ''}'. "
147 + "Supported actions: list_tasks, find_task_by_name, show_task, "
148 + "run_task, delete_task, update_task, create_scheduled_task, "
149 + "create_adhoc_task, create_planned_task, wait_for_task."
150 + ),
151 + break_loop=False,
152 + )
153
154 def _resolve_project_metadata(self) -> tuple[str | None, str | None]:
155 context = self.agent.context
@@ -136,6 +248,59 @@ class SchedulerTool(Tool):
248 else:
249 return Response(message=f"Task failed to delete: {task_uuid}", break_loop=False)
250
251 + async def update_task(self, **kwargs) -> Response:
252 + task_uuid: str = kwargs.get("uuid", "")
253 + if not task_uuid:
254 + return Response(message="Task UUID is required", break_loop=False)
255 +
256 + scheduler = TaskScheduler.get()
257 + await scheduler.reload()
258 + task: ScheduledTask | AdHocTask | PlannedTask | None = scheduler.get_task_by_uuid(task_uuid)
259 + if not task:
260 + return Response(message=f"Task not found: {task_uuid}", break_loop=False)
261 +
262 + update_params: dict[str, Any] = {}
263 + for field in ("name", "system_prompt", "prompt", "attachments"):
264 + if field in kwargs:
265 + update_params[field] = kwargs[field]
266 +
267 + if "state" in kwargs:
268 + update_params["state"] = TaskState(kwargs.get("state", TaskState.IDLE))
269 +
270 + if "dedicated_context" in kwargs:
271 + dedicated_context = bool(kwargs.get("dedicated_context"))
272 + update_params["context_id"] = task.uuid if dedicated_context else self.agent.context.id
273 +
274 + try:
275 + timezone = _schedule_timezone(kwargs)
276 + if isinstance(task, ScheduledTask) and ("schedule" in kwargs or timezone):
277 + task_schedule = _task_schedule_from_input(
278 + kwargs.get("schedule") or serialize_task(task).get("schedule") or {},
279 + timezone=timezone,
280 + )
281 + if err := _validate_task_schedule(task_schedule):
282 + return Response(message=err, break_loop=False)
283 + update_params["schedule"] = task_schedule
284 + except ValueError as exc:
285 + return Response(message=str(exc), break_loop=False)
286 +
287 + if isinstance(task, ScheduledTask) and "schedule" in update_params:
288 + task_schedule = update_params["schedule"]
289 + if err := _validate_task_schedule(task_schedule):
290 + return Response(message=err, break_loop=False)
291 + elif isinstance(task, PlannedTask) and "plan" in kwargs:
292 + task_plan, err = _task_plan_from_input(kwargs.get("plan") or [])
293 + if err:
294 + return Response(message=err, break_loop=False)
295 + update_params["plan"] = task_plan
296 +
297 + updated_task = await scheduler.update_task(task_uuid, **update_params)
298 + await scheduler.save()
299 + if not updated_task:
300 + return Response(message=f"Task failed to update: {task_uuid}", break_loop=False)
301 +
302 + return Response(message=json.dumps(serialize_task(updated_task), indent=4), break_loop=False)
303 +
304 async def create_scheduled_task(self, **kwargs) -> Response:
305 # "name": "XXX",
306 # "system_prompt": "You are a software developer",
@@ -153,20 +318,15 @@ class SchedulerTool(Tool):
318 prompt: str = kwargs.get("prompt", "")
319 attachments: list[str] = kwargs.get("attachments", [])
320 schedule: dict[str, str] = kwargs.get("schedule", {})
156 - dedicated_context: bool = kwargs.get("dedicated_context", False)
157 -
158 - task_schedule = TaskSchedule(
159 - minute=schedule.get("minute", "*"),
160 - hour=schedule.get("hour", "*"),
161 - day=schedule.get("day", "*"),
162 - month=schedule.get("month", "*"),
163 - weekday=schedule.get("weekday", "*"),
164 - )
321 + dedicated_context: bool = kwargs.get("dedicated_context", True)
322
166 - # Validate cron expression, agent might hallucinate
167 - cron_regex = "^((((\d+,)+\d+|(\d+(\/|-|#)\d+)|\d+L?|\*(\/\d+)?|L(-\d+)?|\?|[A-Z]{3}(-[A-Z]{3})?) ?){5,7})$"
168 - if not re.match(cron_regex, task_schedule.to_crontab()):
169 - return Response(message="Invalid cron expression: " + task_schedule.to_crontab(), break_loop=False)
323 + try:
324 + task_schedule = _task_schedule_from_input(schedule, timezone=_schedule_timezone(kwargs))
325 + except ValueError as exc:
326 + return Response(message=str(exc), break_loop=False)
327 +
328 + if err := _validate_task_schedule(task_schedule):
329 + return Response(message=err, break_loop=False)
330
331 project_slug, project_color = self._resolve_project_metadata()
332
@@ -176,6 +336,7 @@ class SchedulerTool(Tool):
336 prompt=prompt,
337 attachments=attachments,
338 schedule=task_schedule,
339 + timezone=getattr(task_schedule, "timezone", None),
340 context_id=None if dedicated_context else self.agent.context.id,
341 project_name=project_slug,
342 project_color=project_color,
@@ -189,7 +350,7 @@ class SchedulerTool(Tool):
350 prompt: str = kwargs.get("prompt", "")
351 attachments: list[str] = kwargs.get("attachments", [])
352 token: str = str(random.randint(1000000000000000000, 9999999999999999999))
192 - dedicated_context: bool = kwargs.get("dedicated_context", False)
353 + dedicated_context: bool = kwargs.get("dedicated_context", True)
354
355 project_slug, project_color = self._resolve_project_metadata()
356
@@ -212,22 +373,12 @@ class SchedulerTool(Tool):
373 prompt: str = kwargs.get("prompt", "")
374 attachments: list[str] = kwargs.get("attachments", [])
375 plan: list[str] = kwargs.get("plan", [])
215 - dedicated_context: bool = kwargs.get("dedicated_context", False)
376 + dedicated_context: bool = kwargs.get("dedicated_context", True)
377
378 # Convert plan to list of datetimes in UTC
218 - todo: list[datetime] = []
219 - for item in plan:
220 - dt = parse_datetime(item)
221 - if dt is None:
222 - return Response(message=f"Invalid datetime: {item}", break_loop=False)
223 - todo.append(dt)
224 -
225 - # Create task plan with todo list
226 - task_plan = TaskPlan.create(
227 - todo=todo,
228 - in_progress=None,
229 - done=[]
230 - )
379 + task_plan, err = _task_plan_from_input(plan)
380 + if err:
381 + return Response(message=err, break_loop=False)
382
383 project_slug, project_color = self._resolve_project_metadata()
384
tools/skills_tool.py
+80 -25
@@ -1,5 +1,6 @@
1 from __future__ import annotations
2
3 +from pathlib import Path
4 from typing import List
5
6 from helpers.tool import Tool, Response
@@ -14,7 +15,7 @@ class SkillsTool(Tool):
15 """
16 Manage and use SKILL.md-based Skills (Anthropic open standard).
17
17 - Methods (tool_args.method):
18 + Actions (tool_args.action):
19 - list
20 - search (query)
21 - load (skill_name)
@@ -23,11 +24,15 @@ class SkillsTool(Tool):
24 Script execution is handled by code_execution_tool directly.
25 """
26
26 - def _current_method(self) -> str:
27 + def _current_action(self) -> str:
28 return (
28 - (self.args.get("method") or self.method or "")
29 + str(
30 + self.args.get("action")
31 + or ""
32 + )
33 .strip()
34 .lower()
35 + .replace("-", "_")
36 )
37
38 @staticmethod
@@ -40,7 +45,7 @@ class SkillsTool(Tool):
45 def get_log_object(self):
46 import uuid
47
43 - if self._current_method() == "load":
48 + if self._current_action() == "load":
49 skill_name = self._normalize_skill_name(
50 str(self.args.get("skill_name") or "")
51 )
@@ -60,14 +65,14 @@ class SkillsTool(Tool):
65 return super().get_log_object()
66
67 async def before_execution(self, **kwargs):
63 - if self._current_method() != "load":
68 + if self._current_action() != "load":
69 await super().before_execution(**kwargs)
70 return
71
72 skill_name = self._normalize_skill_name(
73 str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
74 )
70 - label = f"{self.name}:{self._current_method()}"
75 + label = f"{self.name} action {self._current_action()}"
76 if skill_name:
77 PrintStyle(
78 font_color="#1B4F72",
@@ -85,32 +90,45 @@ class SkillsTool(Tool):
90 self.log = self.get_log_object()
91
92 async def execute(self, **kwargs) -> Response:
88 - method = (
89 - (kwargs.get("method") or self.args.get("method") or self.method or "")
93 + action = (
94 + str(
95 + kwargs.get("action")
96 + or self.args.get("action")
97 + or ""
98 + )
99 .strip()
100 .lower()
101 + .replace("-", "_")
102 )
103
104 try:
95 - if method == "list":
105 + if action == "list":
106 return Response(message=self._list(), break_loop=False)
97 - if method == "search":
98 - query = str(kwargs.get("query") or "").strip()
107 + if action == "search":
108 + query = str(kwargs.get("query") or self.args.get("query") or "").strip()
109 return Response(message=self._search(query), break_loop=False)
100 - if method == "load":
110 + if action == "load":
111 skill_name = self._normalize_skill_name(
102 - str(kwargs.get("skill_name") or "")
112 + str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
113 )
114 return Response(message=self._load(skill_name), break_loop=False)
105 - # if method == "read_file":
106 - # skill_name = str(kwargs.get("skill_name") or "").strip()
107 - # file_path = str(kwargs.get("file_path") or "").strip()
108 - # return Response(
109 - # message=self._read_file(skill_name, file_path), break_loop=False
110 - # )
115 + if action == "read_file":
116 + skill_name = self._normalize_skill_name(
117 + str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
118 + )
119 + file_path = str(
120 + kwargs.get("file_path") or self.args.get("file_path") or ""
121 + ).strip()
122 + return Response(
123 + message=self._read_file(skill_name, file_path),
124 + break_loop=False,
125 + )
126
127 return Response(
113 - message="Error: missing/invalid 'method'. Supported: list, search, load.",
128 + message=(
129 + "Error: missing/invalid 'action'. Supported actions: "
130 + "list, search, load, read_file."
131 + ),
132 break_loop=False,
133 )
134 except (
@@ -139,12 +157,12 @@ class SkillsTool(Tool):
157 desc = desc[:200].rstrip() + "…"
158 lines.append(f"- {s.name}{ver}{tags}: {desc}")
159 lines.append("")
142 - lines.append("Tip: use skills_tool method=search or method=load for details.")
160 + lines.append("Tip: use skills_tool action=search or action=load for details.")
161 return "\n".join(lines)
162
163 def _search(self, query: str) -> str:
164 if not query:
147 - return "Error: 'query' is required for method=search."
165 + return "Error: 'query' is required for action=search."
166
167 results = skills_helper.search_skills(
168 query,
@@ -163,7 +181,7 @@ class SkillsTool(Tool):
181 lines.append(f"- {s.name}: {desc}")
182 lines.append("")
183 lines.append(
166 - "Tip: use skills_tool method=load skill_name=<name> to load full instructions."
184 + "Tip: use skills_tool action=load skill_name=<name> to load full instructions."
185 )
186 return "\n".join(lines)
187
@@ -171,7 +189,7 @@ class SkillsTool(Tool):
189 skill_name = self._normalize_skill_name(skill_name)
190
191 if not skill_name:
174 - return "Error: 'skill_name' is required for method=load."
192 + return "Error: 'skill_name' is required for action=load."
193
194 # Verify skill exists
195 skill = skills_helper.find_skill(
@@ -180,7 +198,7 @@ class SkillsTool(Tool):
198 agent=self.agent,
199 )
200 if not skill:
183 - return f"Error: skill not found: {skill_name!r}. Try skills_tool method=list or method=search."
201 + return f"Error: skill not found: {skill_name!r}. Try skills_tool action=list or action=search."
202
203 # Store skill name for fresh loading each turn
204 if not self.agent.data.get(DATA_NAME_LOADED_SKILLS):
@@ -193,6 +211,43 @@ class SkillsTool(Tool):
211
212 return f"Loaded skill '{skill.name}' into EXTRAS."
213
214 + def _read_file(self, skill_name: str, file_path: str) -> str:
215 + if not skill_name:
216 + return "Error: 'skill_name' is required for action=read_file."
217 + if not file_path:
218 + return "Error: 'file_path' is required for action=read_file."
219 +
220 + skill = skills_helper.find_skill(
221 + skill_name,
222 + include_content=False,
223 + agent=self.agent,
224 + )
225 + if not skill:
226 + return f"Error: skill not found: {skill_name!r}."
227 +
228 + skill_root = skill.path.resolve()
229 + target = Path(file_path)
230 + if not target.is_absolute():
231 + target = skill_root / target
232 +
233 + try:
234 + resolved = target.resolve()
235 + resolved.relative_to(skill_root)
236 + except Exception:
237 + return "Error: file_path must stay inside the skill directory."
238 +
239 + if not resolved.is_file():
240 + return f"Error: skill file not found: {file_path!r}."
241 +
242 + content = resolved.read_text(encoding="utf-8", errors="replace")
243 + if len(content) > 24000:
244 + content = content[:24000].rstrip() + "\n\n[truncated]"
245 +
246 + return (
247 + f"Skill file: {skill.name}/{resolved.relative_to(skill_root)}\n\n"
248 + f"{content}"
249 + )
250 +
251
252 def max_loaded_skills() -> int:
253 return skills_helper.MAX_ACTIVE_SKILLS