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)