Agent Zero AI framework
Keep goals active and resolve trailing slash commands
Continue active goals through response attempts until they are completed or genuinely blocked, while directing agents to make safe in-scope choices autonomously. Parse known slash commands in prefix or trailing position for WebUI and backend-originated messages.
Alessandro committed
Jul 16, 2026 at 18:14 UTC
22e76197196a8e9296e031ae0301a8f21f0dbbd4
11 files changed
+235
-8
plugins/_commands/AGENTS.md
+3
-1
@@ -12,7 +12,7 @@
12
- `api/commands.py` owns the Commands API actions used by the WebUI.
13
- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
14
- `commands/` owns bundled read-only slash command definitions shipped by `_commands`.
15
-- `extensions/` owns the chat composer slash picker.
15
+- `extensions/` owns the chat composer slash picker and incoming-message command resolution.
16
- `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
17
- `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
18
- `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
@@ -30,6 +30,8 @@
30
- On startup, `_commands` copies legacy `usr/plugins/commands` command and skill files into `usr/plugins/_commands` without overwriting existing files, copies scoped legacy command folders to `_commands`, and disables the legacy `commands` plugin roots to prevent duplicate WebUI popovers.
31
- Script commands must expose `run(payload)` and return a string or a dict with `text` and optional `effects`; `show_markdown` effects render as auto-dismissing toast notifications.
32
- Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
33
+- Commands accept prefix syntax (`/goal objective`) and exact postfix syntax (`objective /goal`); ordinary mid-sentence mentions are not invocations.
34
+- WebUI sends resolve through the picker effect path, while backend-originated messages resolve before reaching the agent.
35
- Built-in `/computer-use on|off` emits a bounded `computer_use` effect. A
36
Launcher-owned WebUI applies it to that tab's Host access lease; an ordinary
37
WebUI directs the user to run the same command in A0 CLI.
plugins/_commands/README.md
+4
-2
@@ -7,7 +7,7 @@ This plugin lets you define reusable `/commands` as `.command.yaml` files with e
7
- a `.txt` template body
8
- a `.py` script hook
9
10
-Commands are managed from the plugin modal and can be inserted directly from the chat composer when the first token starts with `/`.
10
+Commands are managed from the plugin modal and can be inserted directly from the chat composer with prefix syntax (`/goal objective`) or an exact trailing command (`objective /goal`).
11
12
## Features
13
@@ -15,6 +15,7 @@ Commands are managed from the plugin modal and can be inserted directly from the
15
- Text template commands with `{}` placeholders and parsed args
16
- Python hook commands with parsed args and optional chat history payload
17
- Unified parser for positional args, free-form tail, and flags
18
+- Prefix and postfix command resolution for WebUI and remote/AI-sent messages
19
- Scope-aware command resolution across project and global scopes
20
- Built-in A0 CLI connector command pack for common session, queue, model, project, browser, and connector status commands
21
- Slash picker in the chat composer with keyboard navigation and create-on-empty flow
@@ -75,6 +76,7 @@ def run(payload):
76
The parser supports:
77
78
- Positional input: `/scan https://github.com/org/repo`
79
+- Postfix input: `https://github.com/org/repo /scan`
80
- Long flags: `/scan --git-url https://github.com/org/repo`
81
- Long flags with equals: `/scan --git-url=https://github.com/org/repo`
82
- Short flags and bundles: `/scan -v -q` or `/scan -vq`
@@ -138,7 +140,7 @@ When the built-in `_commands` plugin starts, it migrates files from the older co
140
## UI Surfaces
141
142
- Plugin modal: manage project/global commands and create editable same-name overrides of bundled commands
141
-- Chat composer: type `/` at the start of the inline input to browse commands
143
+- Chat composer: type `/` at the start or as the final token to browse commands
144
145
## Agent Skill
146
plugins/_commands/extensions/python/_functions/agent/AgentContext/_process_chain/start/_10_resolve_slash_command.py
new
+59
@@ -0,0 +1,59 @@
1
+from __future__ import annotations
2
+
3
+from typing import Any
4
+
5
+from helpers.extension import Extension
6
+from plugins._commands.helpers import commands
7
+
8
+
9
+class ResolveSlashCommand(Extension):
10
+ async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any) -> None:
11
+ if not isinstance(data, dict):
12
+ return
13
+
14
+ args = data.get("args")
15
+ if not isinstance(args, tuple) or len(args) < 3:
16
+ return
17
+
18
+ context, message = args[0], args[2]
19
+ raw_message = getattr(message, "message", None)
20
+ if not isinstance(raw_message, str):
21
+ return
22
+
23
+ resolution = await commands.resolve_message_command(
24
+ raw_message,
25
+ context_id=str(getattr(context, "id", "") or ""),
26
+ )
27
+ if not resolution:
28
+ return
29
+
30
+ result = resolution.get("result") or {}
31
+ next_text = str(result.get("text") or "")
32
+ notes: list[str] = []
33
+ unsupported = False
34
+
35
+ for effect in result.get("effects") or []:
36
+ if not isinstance(effect, dict):
37
+ continue
38
+ effect_type = str(effect.get("type") or "").strip().lower()
39
+ if effect_type == "replace_input":
40
+ next_text = str(effect.get("text") or "")
41
+ elif effect_type == "append_input":
42
+ chunk = str(effect.get("text") or "")
43
+ next_text = f"{next_text}\n{chunk}" if next_text else chunk
44
+ elif effect_type == "send_message":
45
+ next_text = str(effect.get("text") or next_text)
46
+ elif effect_type == "toast":
47
+ notes.append(str(effect.get("message") or ""))
48
+ elif effect_type == "show_markdown":
49
+ notes.append(str(effect.get("content") or ""))
50
+ elif effect_type != "goal_changed":
51
+ unsupported = True
52
+
53
+ command_name = str((resolution.get("command") or {}).get("name") or "command")
54
+ if unsupported:
55
+ data["result"] = f"/{command_name} requires the WebUI."
56
+ elif next_text.strip():
57
+ message.message = next_text
58
+ else:
59
+ data["result"] = "\n\n".join(note for note in notes if note) or f"/{command_name} complete."
plugins/_commands/extensions/webui/send_message_before/_10_resolve_slash_command.js
new
+5
@@ -0,0 +1,5 @@
1
+import { store } from "/plugins/_commands/webui/commands-slash-store.js";
2
+
3
+export default async function resolveSlashCommand(sendCtx) {
4
+ await store.resolveBeforeSend(sendCtx);
5
+}
plugins/_commands/helpers/commands.py
+36
@@ -91,12 +91,23 @@ def parse_slash_invocation(raw_message: str, *, fallback_command: str = "") -> d
91
"""
92
text = (raw_message or "").strip()
93
slash_match = re.match(r"^/([^\s]+)(?:\s+([\s\S]*))?$", text)
94
+ postfix_match = (
95
+ None
96
+ if slash_match
97
+ else re.match(r"^([\s\S]*\S)\s+/([^\s]+)\s*$", text)
98
+ )
99
if slash_match:
100
try:
101
command_name = sanitize_command_name(slash_match.group(1))
102
except ValueError:
103
command_name = sanitize_command_name(fallback_command) if fallback_command else ""
104
raw_arguments = (slash_match.group(2) or "").strip()
105
+ elif postfix_match:
106
+ try:
107
+ command_name = sanitize_command_name(postfix_match.group(2))
108
+ except ValueError:
109
+ command_name = sanitize_command_name(fallback_command) if fallback_command else ""
110
+ raw_arguments = postfix_match.group(1).strip()
111
else:
112
command_name = sanitize_command_name(fallback_command) if fallback_command else ""
113
raw_arguments = text
@@ -566,6 +577,31 @@ async def resolve_command_invocation(
577
}
578
579
580
+async def resolve_message_command(
581
+ raw_message: str,
582
+ *,
583
+ context_id: str = "",
584
+) -> dict[str, Any] | None:
585
+ """Resolve a known prefix or postfix slash command from an incoming message."""
586
+ invocation = parse_slash_invocation(raw_message)
587
+ command_name = invocation["command_name"]
588
+ if not command_name:
589
+ return None
590
+
591
+ project_name = get_context_scope(context_id)["project_name"]
592
+ effective, _ = list_effective_commands(project_name)
593
+ command = next((item for item in effective if item["name"] == command_name), None)
594
+ if not command:
595
+ return None
596
+
597
+ return await resolve_command_invocation(
598
+ path=command["path"],
599
+ slash_text=raw_message,
600
+ project_name=project_name,
601
+ context_id=context_id,
602
+ )
603
+
604
+
605
def _build_command_config(
606
*,
607
name: str,
plugins/_commands/tests/test_commands_plugin.py
+34
@@ -5,6 +5,7 @@ import uuid
5
from dataclasses import dataclass, field
6
from pathlib import Path
7
import sys
8
+from types import SimpleNamespace
9
10
import pytest
11
from flask import Flask
@@ -18,6 +19,9 @@ from helpers import files, projects, skills as skills_helper
19
from initialize import initialize_agent
20
from plugins._commands.api.commands import Commands
21
from plugins._commands.commands import connector_commands
22
+from plugins._commands.extensions.python._functions.agent.AgentContext._process_chain.start._10_resolve_slash_command import (
23
+ ResolveSlashCommand,
24
+)
25
from plugins._commands.helpers import commands as commands_helper
26
27
@@ -148,6 +152,36 @@ def test_parse_arguments_and_render_template_support_flags() -> None:
152
invalid_invocation = commands_helper.parse_slash_invocation("/?")
153
assert invalid_invocation["command_name"] == ""
154
155
+ postfix_invocation = commands_helper.parse_slash_invocation(
156
+ "Make goal execution tenacious\n/goal"
157
+ )
158
+ assert postfix_invocation["command_name"] == "goal"
159
+ assert postfix_invocation["raw_arguments"] == "Make goal execution tenacious"
160
+
161
+
162
+@pytest.mark.asyncio
163
+async def test_incoming_postfix_command_is_resolved_before_the_agent(
164
+ scope_fixture: ScopeFixture,
165
+) -> None:
166
+ command = _save_command(
167
+ scope_fixture,
168
+ name=f"Resolve {scope_fixture.prefix}",
169
+ description="Resolve a postfix command.",
170
+ body="Resolved: {raw}",
171
+ )
172
+ message = SimpleNamespace(message=f"from an AI /{command['name']}")
173
+ data = {
174
+ "args": (
175
+ SimpleNamespace(id=f"missing-{uuid.uuid4().hex}"),
176
+ None,
177
+ message,
178
+ )
179
+ }
180
+
181
+ await ResolveSlashCommand(agent=None).execute(data=data)
182
+
183
+ assert message.message == "Resolved: from an AI"
184
+
185
186
def test_list_effective_commands_project_overrides_global(
187
scope_fixture: ScopeFixture,
plugins/_commands/webui/commands-slash-store.js
+24
-4
@@ -24,8 +24,11 @@ function sanitizeCommandName(rawName) {
24
25
function parseSlashInput(message) {
26
const text = String(message || "");
27
- const match = text.match(/^\s*\/([^\s]*)(?:\s+([\s\S]*))?$/);
28
- if (!match) {
27
+ const prefixMatch = text.match(/^\s*\/([^\s]*)(?:\s+([\s\S]*))?$/);
28
+ const postfixMatch = prefixMatch
29
+ ? null
30
+ : text.match(/^([\s\S]*\S)\s+\/([^\s]*)\s*$/);
31
+ if (!prefixMatch && !postfixMatch) {
32
return {
33
active: false,
34
query: "",
@@ -36,8 +39,8 @@ function parseSlashInput(message) {
39
40
return {
41
active: true,
39
- query: (match[1] || "").trim().toLowerCase(),
40
- rawArguments: match[2] || "",
42
+ query: (prefixMatch?.[1] || postfixMatch?.[2] || "").trim().toLowerCase(),
43
+ rawArguments: prefixMatch?.[2] || postfixMatch?.[1]?.trim() || "",
44
rawMessage: text,
45
};
46
}
@@ -251,6 +254,23 @@ const model = {
254
void this.loadCommands();
255
},
256
257
+ async resolveBeforeSend(sendCtx) {
258
+ if (!sendCtx || this.applying) return;
259
+
260
+ const parsed = parseSlashInput(sendCtx.message);
261
+ const commandName = sanitizeCommandName(parsed.query);
262
+ if (!parsed.active || !commandName) return;
263
+
264
+ await this.loadCommands();
265
+ const command = this.commands.find((item) => item.name === commandName);
266
+ if (!command || !this.getInputElement()) return;
267
+
268
+ this.rawMessage = parsed.rawMessage;
269
+ this.rawArguments = parsed.rawArguments;
270
+ sendCtx.cancel = true;
271
+ await this.applySelection(command);
272
+ },
273
+
274
handleKeydown(event) {
275
const input = this.getInputElement();
276
if (!this.menuVisible || !input || document.activeElement !== input) return;
plugins/_goal/AGENTS.md
+2
@@ -13,6 +13,7 @@
13
- `commands/` owns the `/goal` slash command contributed to `_commands`.
14
- `webui/` and `extensions/webui/` own the composer goal strip and inline controls.
15
- `tools/` and `prompts/` own agent-facing goal inspection, creation, and status update behavior.
16
+- `tools/response.py` overrides the core response tool so an active goal continues the current monologue.
17
- `extensions/python/message_loop_prompts_after/` owns injecting the active goal into agent context.
18
19
## Local Contracts
@@ -23,6 +24,7 @@
24
- User controls may pause, resume, edit, or delete a goal; destructive delete uses inline confirmation. Model tools may create goals and mark them complete or blocked.
25
- `/goal <objective>` creates the goal and sends the objective as the user message so the agent starts working immediately.
26
- `/goal auto` fills the composer with a prompt asking the agent to create and manage its own goal instead of silently sending a message.
27
+- While a goal is active, response-tool calls are intermediate updates; only completing or blocking the goal restores normal loop termination.
28
- Goal UI feedback uses toast notifications and inline controls, not modal dialogs.
29
30
## Work Guidance
plugins/_goal/prompts/agent.extras.goal.md
+1
-1
@@ -4,4 +4,4 @@ objective: {{objective}}
4
created by: {{created_by}}
5
updated: {{updated_at}}
6
7
-Follow this goal while it is active. When the work is complete, call `update_goal` with `status="complete"` before your final answer. If progress is genuinely blocked, call `update_goal` with `status="blocked"` and explain what is missing.
7
+Keep working autonomously while this goal is active. Treat ordinary choices, confirmations, and recoverable external gates as yours to resolve safely within the user's scope; do not hand them back to the user. A `response` call is only an intermediate update and will not end the run. Call `update_goal` with `status="complete"` once you judge the objective achieved. Call `update_goal` with `status="blocked"` only after retrying viable alternatives and no safe, in-scope action can continue without unavailable information or an external-state change.
plugins/_goal/tests/test_goal_plugin.py
+36
@@ -11,6 +11,7 @@ from plugins._goal.commands import goal_command
11
from plugins._goal.helpers import goals
12
from plugins._goal.tools.create_goal import CreateGoal
13
from plugins._goal.tools.get_goal import GetGoal
14
+from plugins._goal.tools.response import ResponseTool
15
from plugins._goal.tools.update_goal import UpdateGoal
16
17
@@ -122,3 +123,38 @@ async def test_goal_api_and_agent_tools(context_id: str):
123
create_response = await create_tool.execute(objective="Exercise tool path")
124
assert "Goal created: Exercise tool path" == create_response.message
125
assert goals.get_goal(context_id)["created_by"] == "model"
126
+
127
+
128
+@pytest.mark.asyncio
129
+async def test_active_goal_keeps_response_tool_running(context_id: str):
130
+ goals.create_goal(context_id, "Keep going")
131
+ recorded = []
132
+ fake_agent = SimpleNamespace(
133
+ context=SimpleNamespace(id=context_id),
134
+ hist_add_tool_result=lambda *args, **kwargs: recorded.append((args, kwargs)),
135
+ )
136
+ loop_data = SimpleNamespace(params_temporary={})
137
+ tool = ResponseTool(
138
+ fake_agent,
139
+ "response",
140
+ None,
141
+ {"text": "Can you decide?"},
142
+ "",
143
+ loop_data,
144
+ )
145
+
146
+ response = await tool.execute()
147
+ assert response.break_loop is False
148
+ response.additional["_responses_output_item"] = {"type": "function_call_output"}
149
+ await tool.after_execution(response)
150
+ assert recorded == [
151
+ (
152
+ ("response", response.message),
153
+ {"_responses_output_item": {"type": "function_call_output"}},
154
+ )
155
+ ]
156
+
157
+ goals.update_goal(context_id, status="complete")
158
+ response = await tool.execute()
159
+ assert response.break_loop is True
160
+ assert response.message == "Can you decide?"
plugins/_goal/tools/response.py
new
+31
@@ -0,0 +1,31 @@
1
+from __future__ import annotations
2
+
3
+from helpers.tool import Response
4
+from plugins._goal.helpers import goals
5
+from tools import response as core_response
6
+
7
+
8
+_CONTINUE_MARKER = "_goal_continue"
9
+
10
+
11
+class ResponseTool(core_response.ResponseTool):
12
+ async def execute(self, **kwargs) -> Response:
13
+ response = await super().execute(**kwargs)
14
+ goal = goals.get_goal(self.agent.context.id)
15
+ if not goal or goal.get("status") != "active":
16
+ return response
17
+
18
+ response.break_loop = False
19
+ response.message = (
20
+ "Goal still active. Continue working autonomously and make safe, in-scope "
21
+ "choices yourself. Call update_goal complete when satisfied, or blocked only "
22
+ "when no viable in-scope path remains."
23
+ )
24
+ response.additional = {**(response.additional or {}), _CONTINUE_MARKER: True}
25
+ return response
26
+
27
+ async def after_execution(self, response: Response, **kwargs):
28
+ additional = response.additional or {}
29
+ if additional.pop(_CONTINUE_MARKER, False):
30
+ self.agent.hist_add_tool_result(self.name, response.message, **additional)
31
+ await super().after_execution(response, **kwargs)