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)