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