Add reusable agent profile commands
Expose sparse Easy-mode profile creation through the Agent Editor and Connector API capability, while preserving existing profile selection behavior. Reuse /profile in the WebUI for management and fast creation, then open a fresh chat with the new profile in the selected project scope. Add focused backend, command, and WebUI regressions for the shared flow.
Alessandro committed
Aug 10, 2026 at 16:25 UTC
2a2890d89298d228571f812b57c6c49b25388ebf
15 files changed
+212
-11
plugins/_a0_connector/AGENTS.md
+2
@@ -54,6 +54,8 @@
54
after all chunks for the `op_id` are assembled.
55
- Host browser status metadata may advertise `available_browsers` entries with browser ids, labels, CDP endpoints, status, and enabled state; keep older CLI payloads without those fields compatible.
56
- Model preset definitions exposed through v1 are global; project arguments select scope but never create project-owned definitions. Model switcher state reports the effective main, utility, and embedding models and preserves embedding-change notifications.
57
+- The protected v1 `agent_editor` route delegates to the bundled Agent Editor
58
+ API and must not define another profile schema or write profile files itself.
59
- Computer Use receipts describe transport success unless the connector returns explicit effect evidence. Linux target-bound typing requires a verified active/focused `window_id`; window activation uses focus, never a press action on an application or window node. Do not retry an identical failed Computer Use call.
60
61
## Work Guidance
plugins/_a0_connector/api/v1/agent_editor.py
new
+13
@@ -0,0 +1,13 @@
1
+"""POST /api/plugins/_a0_connector/v1/agent_editor."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class AgentEditor(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from plugins._agent_editor.api.agent_editor import AgentEditor as CoreAgentEditor
11
+
12
+ handler = CoreAgentEditor(self.app, self.thread_lock)
13
+ return await handler.process(input, request)
plugins/_a0_connector/api/v1/capabilities.py
+1
@@ -36,6 +36,7 @@ _OPTIONAL_FEATURES: dict[str, tuple[str, ...]] = {
36
"settings_get": ("helpers.settings", "helpers.subagents"),
37
"settings_set": ("helpers.settings", "helpers.subagents"),
38
"agent_profile_set": ("api.agent_profile_set",),
39
+ "agent_editor": ("plugins._agent_editor.api.agent_editor",),
40
"agents_list": ("helpers.subagents",),
41
"skills_list": ("helpers.skills", "helpers.files", "helpers.projects", "helpers.runtime"),
42
"skills_activate": ("helpers.skills", "helpers.persist_chat"),
plugins/_agent_editor/AGENTS.md
+3
@@ -32,6 +32,9 @@
32
`agents.json` and changes only the requested profile entry through the existing
33
project storage owner.
34
- Never call `helpers.subagents.save_agent_data`.
35
+- Fast creation from slash commands and connector clients calls
36
+ `helpers/editor.py:save_easy_profile`, so profile IDs, validation, sparse
37
+ writes, and the mutation boundary stay identical to Easy mode.
38
- Authored profile definitions remain YAML; editor-written plugin configs remain
39
JSON.
40
- Profile config paths use `helpers.plugins.determine_plugin_asset_path` for the
plugins/_agent_editor/api/agent_editor.py
+13
@@ -23,6 +23,19 @@ class AgentEditor(ApiHandler):
23
"ok": True,
24
"state": editor.build_editor_state(profile_id, context),
25
}
26
+ if action == "quick_create":
27
+ profile_id, receipt = editor.save_easy_profile(
28
+ input.get("title"),
29
+ input.get("instructions"),
30
+ context,
31
+ tool_policy=input.get("tool_policy"),
32
+ )
33
+ return {
34
+ "ok": True,
35
+ **receipt,
36
+ "profile_id": profile_id,
37
+ "effective_profile": editor.build_profile_state(profile_id, context),
38
+ }
39
if action in {"plan", "save"}:
40
patch = input.get("patch")
41
plan = editor.build_change_plan(patch, context)
plugins/_agent_editor/extensions/webui/initFw_end/agent-editor.js
+3
-1
@@ -6,5 +6,7 @@ export default async function initAgentEditor() {
6
if (initialized) return;
7
initialized = true;
8
globalThis.openAgentEditor = (options = {}) => store.open(options);
9
- globalThis.testAgentProfile = (profileId) => store.openFreshChat(String(profileId || ""), false);
9
+ globalThis.testAgentProfile = (profileId, projectName) => (
10
+ store.openFreshChat(String(profileId || ""), false, projectName)
11
+ );
12
}
plugins/_agent_editor/helpers/editor.py
+34
@@ -11,6 +11,7 @@ import threading
11
import time
12
from types import SimpleNamespace
13
from typing import Any
14
+import unicodedata
15
from urllib.parse import urlencode
16
from uuid import uuid4
17
@@ -120,6 +121,39 @@ def validate_profile_id(profile_id: Any) -> str:
121
return value
122
123
124
+def profile_id_from_title(title: Any) -> str:
125
+ if not isinstance(title, str) or not title.strip():
126
+ raise ValueError("Agent name is required.")
127
+ value = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
128
+ value = re.sub(r"[^a-z0-9_-]+", "-", value.lower())
129
+ value = re.sub(r"[-_]{2,}", "-", value).strip("-_")[:64].rstrip("-_")
130
+ if not value:
131
+ raise ValueError("Agent name must contain at least one letter or number.")
132
+ return validate_profile_id(value)
133
+
134
+
135
+def save_easy_profile(
136
+ title: Any,
137
+ instructions: Any,
138
+ context: Any | None = None,
139
+ *,
140
+ tool_policy: Any | None = None,
141
+) -> tuple[str, dict[str, Any]]:
142
+ if not isinstance(instructions, str) or not instructions.strip():
143
+ raise ValueError("Instructions are required for a new agent.")
144
+ profile_id = profile_id_from_title(title)
145
+ patch: dict[str, Any] = {
146
+ "profile_id": profile_id,
147
+ "creating": True,
148
+ "editor_mode": "easy",
149
+ "metadata": {"set": {"title": title}, "reset": []},
150
+ "prompts": {"set": {SPECIFICS_FILE: instructions}, "reset": []},
151
+ }
152
+ if tool_policy is not None:
153
+ patch["tool_policy"] = _mapping(tool_policy, "tool_policy")
154
+ return profile_id, apply_change_plan(build_change_plan(patch, context))
155
+
156
+
157
def _context_project_name(context: Any | None) -> str:
158
return str(projects.get_context_project_name(context) or "") if context else ""
159
plugins/_agent_editor/webui/agent-editor-store.js
+4
-3
@@ -1238,15 +1238,16 @@ const model = {
1238
}
1239
},
1240
1241
- async openFreshChat(profileId, showReadyNote = false) {
1241
+ async openFreshChat(profileId, showReadyNote = false, projectName = this.projectName) {
1242
+ const scopeProject = String(projectName || "");
1243
try {
1244
const created = await callJsonApi("/chat_create", {
1245
current_context: this.intent.contextId || chatsStore.selected || "",
1246
});
1247
await callJsonApi("/projects", {
1247
- action: this.projectName ? "activate" : "deactivate",
1248
+ action: scopeProject ? "activate" : "deactivate",
1249
context_id: created.ctxid,
1249
- ...(this.projectName ? { name: this.projectName } : {}),
1250
+ ...(scopeProject ? { name: scopeProject } : {}),
1251
});
1252
await callJsonApi("/agent_profile_set", {
1253
context_id: created.ctxid,
plugins/_commands/AGENTS.md
+2
@@ -33,6 +33,8 @@
33
- Commands accept prefix syntax (`/goal objective`) and exact postfix syntax (`objective /goal`); ordinary mid-sentence mentions are not invocations. The composer picker opens only for prefix syntax, while postfix commands resolve when sent.
34
- WebUI sends resolve through the picker effect path, while backend-originated messages resolve before reaching the agent.
35
- `/stop` uses the same shared cancellation operation as the composer Stop button, including progress cleanup and terminal logging.
36
+- `/profile` opens Manage agents without arguments, keeps existing profile
37
+ selection, and creates through Agent Editor when given a name and instructions.
38
- Built-in `/computer-use on|off` emits a bounded `computer_use` effect. WebUI
39
only directs the user to Host access in A0 Launcher or the same command in A0
40
CLI; it never changes a Launcher gateway lease from Agent Zero page content.
plugins/_commands/commands/connector_commands.py
+35
-5
@@ -5,7 +5,7 @@ from typing import Any
5
from agent import AgentContext
6
from api.stop import stop_context
7
from helpers import message_queue as mq
8
-from helpers import plugins, projects
8
+from helpers import plugins, projects, subagents
9
from helpers.integration_commands import try_handle_command
10
from helpers.state_monitor_integration import mark_dirty_for_context
11
@@ -34,7 +34,7 @@ def run(payload: dict[str, Any]) -> dict[str, Any]:
34
if command == "project":
35
return _handle_project(context, raw_args)
36
if command == "profile":
37
- return _handle_profile(context, raw_args)
37
+ return _handle_profile(context, raw_args, arguments)
38
if command == "plugins":
39
return _effects({"type": "open_modal", "path": "/components/plugins/list/plugin-list.html"})
40
if command == "compact":
@@ -113,13 +113,43 @@ def _handle_project(context: AgentContext | None, raw_args: str) -> dict[str, An
113
return _show_markdown("Project", try_handle_command(context, f"/project {raw_args}") or "")
114
115
116
-def _handle_profile(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
116
+def _handle_profile(
117
+ context: AgentContext | None,
118
+ raw_args: str,
119
+ arguments: dict[str, Any],
120
+) -> dict[str, Any]:
121
if not raw_args:
118
- return _effects({"type": "open_modal", "path": "/components/settings/settings.html"})
122
+ return _effects({"type": "open_agent_editor", "view": "manage"})
123
error = _require_context(context)
124
if error:
125
return _effects(_toast(error, level="error"))
122
- return _show_markdown("Agent Profile", try_handle_command(context, f"/agent {raw_args}") or "")
126
+
127
+ positional = [str(value) for value in arguments.get("positional") or []]
128
+ project_name = projects.get_context_project_name(context)
129
+ desired = raw_args.strip().strip("\"'").casefold()
130
+ profiles = subagents.get_available_agents_dict(project_name or None)
131
+ if len(positional) < 2 or any(
132
+ desired in {profile_id.casefold(), (item.title or profile_id).casefold()}
133
+ for profile_id, item in profiles.items()
134
+ ):
135
+ return _show_markdown("Agent Profile", try_handle_command(context, f"/agent {raw_args}") or "")
136
+
137
+ from plugins._agent_editor.helpers import editor
138
+
139
+ title = positional[0]
140
+ instructions = " ".join(positional[1:])
141
+ try:
142
+ profile_id, _ = editor.save_easy_profile(title, instructions, context)
143
+ except ValueError as exc:
144
+ return _effects(_toast(str(exc), level="error"))
145
+ return _effects(
146
+ _toast(f"Created agent {title}."),
147
+ {
148
+ "type": "test_agent_profile",
149
+ "profile_id": profile_id,
150
+ "project_name": project_name or "",
151
+ },
152
+ )
153
154
155
def _handle_models(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
plugins/_commands/commands/profile.command.yaml
+2
-2
@@ -1,5 +1,5 @@
1
name: profile
2
-description: Pick or set the active Agent Zero Core profile.
3
-argument_hint: "[profile]"
2
+description: Manage, select, or quickly create an agent profile.
3
+argument_hint: '[profile | "name" "instructions"]'
4
type: script
5
script_path: connector_commands.py
plugins/_commands/tests/test_commands_plugin.py
+57
@@ -296,6 +296,63 @@ def test_models_command_always_opens_modal():
296
}
297
298
299
+def test_profile_command_opens_agent_manager() -> None:
300
+ result = connector_commands.run(
301
+ {
302
+ "invocation": {"command_name": "profile", "raw_arguments": ""},
303
+ "context": {"context_id": ""},
304
+ }
305
+ )
306
+
307
+ assert result == {
308
+ "text": "",
309
+ "effects": [{"type": "open_agent_editor", "view": "manage"}],
310
+ }
311
+
312
+
313
+def test_profile_command_quick_creates_with_the_agent_editor(
314
+ monkeypatch: pytest.MonkeyPatch,
315
+) -> None:
316
+ from plugins._agent_editor.helpers import editor
317
+
318
+ context = SimpleNamespace()
319
+ saved: list[tuple[str, str, object]] = []
320
+ monkeypatch.setattr(connector_commands, "_context", lambda _context_id: context)
321
+ monkeypatch.setattr(connector_commands.projects, "get_context_project_name", lambda _context: "")
322
+ monkeypatch.setattr(connector_commands.subagents, "get_available_agents_dict", lambda _project: {})
323
+ monkeypatch.setattr(
324
+ editor,
325
+ "save_easy_profile",
326
+ lambda title, instructions, profile_context: (
327
+ saved.append((title, instructions, profile_context)) or "source-scout",
328
+ {},
329
+ ),
330
+ )
331
+
332
+ result = connector_commands.run(
333
+ {
334
+ "invocation": {
335
+ "command_name": "profile",
336
+ "raw_arguments": '"Source Scout" "Verify every claim"',
337
+ "arguments": {
338
+ "positional": ["Source Scout", "Verify every claim"],
339
+ },
340
+ },
341
+ "context": {"context_id": "ctx-1"},
342
+ }
343
+ )
344
+
345
+ assert saved == [("Source Scout", "Verify every claim", context)]
346
+ assert result["effects"] == [
347
+ {"type": "toast", "message": "Created agent Source Scout.", "level": "success"},
348
+ {
349
+ "type": "test_agent_profile",
350
+ "profile_id": "source-scout",
351
+ "project_name": "",
352
+ },
353
+ ]
354
+
355
+
356
def test_stop_command_uses_the_composer_stop_operation(monkeypatch):
357
class Log:
358
def __init__(self):
plugins/_commands/webui/commands-slash-store.js
+17
@@ -431,6 +431,23 @@ const model = {
431
if (path) await window.openModal?.(path);
432
continue;
433
}
434
+ if (type === "open_agent_editor") {
435
+ await globalThis.openAgentEditor?.({
436
+ view: String(effect.view || "manage"),
437
+ profileId: String(effect.profile_id || ""),
438
+ });
439
+ continue;
440
+ }
441
+ if (type === "test_agent_profile") {
442
+ const profileId = String(effect.profile_id || "").trim();
443
+ if (profileId) {
444
+ await globalThis.testAgentProfile?.(
445
+ profileId,
446
+ String(effect.project_name || ""),
447
+ );
448
+ }
449
+ continue;
450
+ }
451
if (type === "show_markdown") {
452
hadToast = true;
453
notifyInfo(
tests/test_agent_editor.py
+14
@@ -127,6 +127,20 @@ def test_new_easy_profile_writes_only_minimum_exact_files(user_root: Path) -> No
127
)
128
129
130
+def test_quick_create_uses_the_easy_sparse_save_path(user_root: Path) -> None:
131
+ profile_id, receipt = editor.save_easy_profile(
132
+ "Café Research",
133
+ "Verify sources and return concise citations.",
134
+ )
135
+
136
+ assert profile_id == "cafe-research"
137
+ assert len(receipt["written"]) == 2
138
+ assert yaml_helper.loads(
139
+ (user_root / profile_id / "agent.yaml").read_text(encoding="utf-8")
140
+ ) == {"title": "Café Research"}
141
+ assert not list((user_root / profile_id).rglob("*.json"))
142
+
143
+
144
def test_editor_lifecycle_needs_no_model_or_utility_configuration(
145
user_root: Path,
146
monkeypatch: pytest.MonkeyPatch,
tests/test_agent_editor_webui.py
+12
@@ -723,3 +723,15 @@ if (pending.length !== requestCount) throw new Error("cached profile catalog une
723
check=True,
724
text=True,
725
)
726
+
727
+
728
+def test_profile_slash_effects_reuse_the_agent_editor_entry_points() -> None:
729
+ slash_store = (
730
+ ROOT / "plugins" / "_commands" / "webui" / "commands-slash-store.js"
731
+ ).read_text(encoding="utf-8")
732
+
733
+ assert 'type === "open_agent_editor"' in slash_store
734
+ assert "globalThis.openAgentEditor?." in slash_store
735
+ assert 'type === "test_agent_profile"' in slash_store
736
+ assert "globalThis.testAgentProfile?." in slash_store
737
+ assert "String(effect.project_name || \"\")" in slash_store