Add project-scoped Agent Editor profiles

Reuse the existing project/profile layers so Manage agents can switch between Global and project scope while sparse reads, writes, resets, and policy configs stay bound to the selected layer. Make deletion scope-owned, preserve inherited profiles, and open Save & test chats in the exact selected scope.

Alessandro committed Aug 8, 2026 at 16:56 UTC 027466c873b7d7fbbef63af73977e8e6293ae3c0
8 files changed +529 -190
plugins/_agent_editor/AGENTS.md
+12 -7
@@ -16,16 +16,19 @@
16 ## Local Contracts
17
18 - The editor performs zero model calls.
19 -- Writes are limited to `usr/agents/<profile-id>` and only to paths or config
20 - keys listed in the validated change plan.
19 +- Writes are limited to the selected profile layer — global
20 + `usr/agents/<profile-id>` or project
21 + `usr/projects/<project>/.a0proj/agents/<profile-id>` — and only to paths or
22 + config keys listed in the validated change plan.
23 - Never call `helpers.subagents.save_agent_data`.
24 - Authored profile definitions remain YAML; editor-written plugin configs remain
25 JSON.
24 -- Profile config paths use `helpers.plugins.determine_plugin_asset_path` while
25 - remaining rooted in the editor's validated user-profile boundary.
26 -- Active project tool policy is resolved through the standard plugin asset
27 - provenance and shown as higher priority; the editor still writes only the
28 - user-profile scope.
26 +- Profile config paths use `helpers.plugins.determine_plugin_asset_path` for the
27 + selected Global or project scope and remain rooted in that exact validated
28 + profile boundary.
29 +- Project profiles inherit the existing global, plugin, and bundled layers.
30 + Removing or deleting in project scope never mutates those inherited layers;
31 + only agents created in the selected scope are deletable.
32 - Bundled `agents/` files are read-only.
33 - Advanced prompt text is directly editable; per-file close/check actions
34 discard or accept the current edit checkpoint, while the editor's global save
@@ -37,6 +40,8 @@
40 tools remain absent from the tool catalog.
41 - Model selection reuses `_model_config`'s compact preset dropdown and preset
42 editor; Agent Editor persists only the scoped preset reference.
43 +- Manage agents reuses the plugin-settings project vocabulary: Global or one
44 + existing project. Save & test activates that same scope in the fresh chat.
45 - The WebUI uses the shared modal stack, labeled prompt scroll regions, and
46 24px-or-larger policy and text-action targets.
47
plugins/_agent_editor/README.md
+6 -1
@@ -2,8 +2,13 @@
2
3 Agent Editor provides the deterministic Easy modal and Advanced workspace for
4 Agent Zero profiles. It reads the existing layered profile architecture and
5 -writes only sparse user overrides under `usr/agents/<profile-id>`.
5 +writes only sparse overrides in the selected Global or project profile layer.
6
7 The editor never invokes a model. Tool and skill controls are backed by the
8 central runtime policy owners, and every save is previewed as exact file writes
9 and deletions before the same validated plan is applied.
10 +
11 +Global agents and customizations live under `usr/agents/<profile-id>` and apply
12 +across projects. Project-scoped agents and customizations live under
13 +`usr/projects/<project>/.a0proj/agents/<profile-id>`, inherit the Global layer,
14 +and can be removed without changing it.
plugins/_agent_editor/api/agent_editor.py
+3
@@ -1,5 +1,6 @@
1 from __future__ import annotations
2
3 +from pathlib import Path
4 from typing import Any
5
6 from agent import AgentContext
@@ -76,4 +77,6 @@ def _context(input: dict[str, Any]) -> Any:
77 project_name = str(input.get("project_name") or "").strip()
78 if project_name:
79 project_name = projects.validate_project_name(project_name)
80 + if not Path(projects.get_project_folder(project_name)).is_dir():
81 + raise ValueError("Project not found.")
82 return editor._EditorContext(project_name)
plugins/_agent_editor/helpers/editor.py
+165 -98
@@ -81,6 +81,7 @@ class ChangePlan:
81 warnings: list[str] = field(default_factory=list)
82 staged_tokens: set[str] = field(default_factory=set)
83 profile_id: str = ""
84 + project_name: str = ""
85 remove_empty_root: bool = False
86
87 def write(self, path: Path, content: str | bytes) -> None:
@@ -118,6 +119,34 @@ def validate_profile_id(profile_id: Any) -> str:
119 return value
120
121
122 +def _context_project_name(context: Any | None) -> str:
123 + return str(projects.get_context_project_name(context) or "") if context else ""
124 +
125 +
126 +def _profile_root(profile_id: str, project_name: str = "") -> Path:
127 + return (
128 + Path(projects.get_project_meta(project_name, "agents", profile_id))
129 + if project_name
130 + else USER_AGENTS_ROOT / profile_id
131 + )
132 +
133 +
134 +def _scope_has_files(root: Path) -> bool:
135 + return root.is_dir() and any(
136 + path.is_file() or path.is_symlink() for path in root.rglob("*")
137 + )
138 +
139 +
140 +def _scope_owns_custom_profile(profile_id: str, project_name: str) -> bool:
141 + if not _profile_root(profile_id, project_name).is_dir():
142 + return False
143 + if (Path(files.get_abs_path(subagents.DEFAULT_AGENTS_DIR)) / profile_id).is_dir():
144 + return False
145 + if any(Path(path).is_dir() for path in plugins.get_plugin_paths("agents", profile_id)):
146 + return False
147 + return not project_name or not (USER_AGENTS_ROOT / profile_id).is_dir()
148 +
149 +
150 def profile_exists(profile_id: str, context: Any | None = None) -> bool:
151 agent = EditorAgent(profile_id, context)
152 if (Path(files.get_abs_path(subagents.DEFAULT_AGENTS_DIR)) / profile_id).is_dir():
@@ -126,7 +155,7 @@ def profile_exists(profile_id: str, context: Any | None = None) -> bool:
155 return True
156 if any(Path(path).is_dir() for path in plugins.get_plugin_paths("agents", profile_id)):
157 return True
129 - project_name = projects.get_context_project_name(agent.context) or ""
158 + project_name = _context_project_name(agent.context)
159 return bool(
160 project_name
161 and Path(projects.get_project_meta(project_name, "agents", profile_id)).is_dir()
@@ -134,10 +163,13 @@ def profile_exists(profile_id: str, context: Any | None = None) -> bool:
163
164
165 def list_profiles(context: Any | None = None) -> list[dict[str, Any]]:
137 - project_name = projects.get_context_project_name(context) if context else None
166 + project_name = _context_project_name(context)
167 resolved = subagents.get_agents_dict(project_name)
168 names = set(resolved)
140 - for root in (Path(files.get_abs_path("agents")), USER_AGENTS_ROOT):
169 + roots = [Path(files.get_abs_path("agents")), USER_AGENTS_ROOT]
170 + if project_name:
171 + roots.append(Path(projects.get_project_meta(project_name, "agents")))
172 + for root in roots:
173 if root.is_dir():
174 names.update(path.name for path in root.iterdir() if path.is_dir())
175
@@ -154,10 +186,10 @@ def list_profiles(context: Any | None = None) -> list[dict[str, Any]]:
186 "origin": state["origin"],
187 "origin_chain": state["origin_chain"],
188 "built_in": state["built_in"],
157 - "has_user_overrides": state["has_user_overrides"],
189 + "scope_has_overrides": state["scope_has_overrides"],
190 + "deletable": state["deletable"],
191 "avatar": state["avatar"]["effective"],
192 "avatar_url": effective_avatar_url(profile_id, context),
160 - "project_override_active": state["project_override_active"],
193 "enabled": bool(getattr(resolved.get(profile_id), "enabled", True)),
194 "available": profile_exists(profile_id, context),
195 }
@@ -191,27 +223,21 @@ def build_editor_state(
223 "embedding": _model_identity(resolved.get("embedding_model")),
224 }
225 )
194 - model_path = _profile_config_path(profile_id, "_model_config")
195 - model_user = _read_mapping(model_path)
226 + project_name = _context_project_name(agent.context)
227 + model_path = _profile_config_path(profile_id, "_model_config", project_name)
228 + model_scope = _read_mapping(model_path)
229 selected = model_config.get_configured_preset_name(agent=agent)
230
198 - tool_path = _profile_config_path(profile_id, tool_policy.PLUGIN_NAME)
199 - skill_path = _profile_config_path(profile_id, skills.ACTIVE_SKILLS_PLUGIN_NAME)
200 - tool_user = _read_mapping(tool_path)
201 - project_name = projects.get_context_project_name(agent.context) or ""
202 - tool_source = (
203 - plugins.find_plugin_asset(
204 - tool_policy.PLUGIN_NAME,
205 - plugins.CONFIG_FILE_NAME,
206 - project_name=project_name,
207 - agent_profile=profile_id,
208 - )
209 - if project_name
210 - else None
231 + tool_path = _profile_config_path(profile_id, tool_policy.PLUGIN_NAME, project_name)
232 + skill_path = _profile_config_path(
233 + profile_id,
234 + skills.ACTIVE_SKILLS_PLUGIN_NAME,
235 + project_name,
236 )
212 - skill_user = _read_mapping(skill_path)
237 + tool_scope = _read_mapping(tool_path)
238 + skill_scope = _read_mapping(skill_path)
239 skill_policy = skills.normalize_visibility_policy(
214 - skill_user.get("visibility_policy")
240 + skill_scope.get("visibility_policy")
241 )
242 effective_skill_policy = skills.get_visibility_policy(agent)
243 skill_catalog = [
@@ -255,23 +281,22 @@ def build_editor_state(
281 "profile": build_profile_state(profile_id, context),
282 "prompts": prompt_files,
283 "tools": {
258 - "policy": tool_policy.normalize_policy(tool_user),
259 - "has_override": any(key in tool_user for key in _POLICY_KEYS),
260 - "project_override_active": bool(
261 - tool_source and tool_source.get("project_name")
262 - ),
284 + "policy": tool_policy.normalize_policy(tool_scope),
285 + "effective_policy": tool_policy.get_policy(agent),
286 + "has_override": any(key in tool_scope for key in _POLICY_KEYS),
287 "catalog": tool_catalog,
288 },
289 "skills": {
290 "policy": skill_policy,
267 - "has_override": "visibility_policy" in skill_user,
291 + "effective_policy": effective_skill_policy,
292 + "has_override": "visibility_policy" in skill_scope,
293 "catalog": skill_catalog,
294 },
295 "model_presets": presets,
296 "model_preset": {
297 "effective": selected,
273 - "override": model_user.get(model_config.MODEL_PRESET_CONFIG_KEY),
274 - "has_override": model_config.MODEL_PRESET_CONFIG_KEY in model_user,
298 + "override": model_scope.get(model_config.MODEL_PRESET_CONFIG_KEY),
299 + "has_override": model_config.MODEL_PRESET_CONFIG_KEY in model_scope,
300 },
301 }
302
@@ -287,8 +312,8 @@ def build_profile_state(
312 "origin": metadata.pop("origin"),
313 "origin_chain": metadata.pop("origin_chain"),
314 "built_in": metadata.pop("built_in"),
290 - "has_user_overrides": metadata.pop("has_user_overrides"),
291 - "project_override_active": metadata.pop("project_override_active"),
315 + "scope_has_overrides": metadata.pop("scope_has_overrides"),
316 + "deletable": metadata.pop("deletable"),
317 "metadata": metadata,
318 "avatar_url": effective_avatar_url(profile_id, context),
319 }
@@ -296,29 +321,28 @@ def build_profile_state(
321
322 def metadata_state(profile_id: str, context: Any | None = None) -> dict[str, Any]:
323 layers = _metadata_layers(profile_id, context)
299 - user_layer = next((layer for layer in layers if layer.kind == "user"), None)
300 - lower_layers = [
301 - layer for layer in layers if layer.kind not in {"user", "project"}
302 - ]
324 + project_name = _context_project_name(context)
325 + scope_kind = "project" if project_name else "user"
326 + scope_layer = next((layer for layer in layers if layer.kind == scope_kind), None)
327 + lower_layers = [layer for layer in layers if layer.kind != scope_kind]
328 state: dict[str, Any] = {}
329 for key in METADATA_KEYS:
305 - effective, source, source_kind = _layer_value(layers, key)
330 + effective, source, _ = _layer_value(layers, key)
331 inherited, inherited_source, _ = _layer_value(lower_layers, key)
332 state[key] = {
333 "effective": effective,
334 "override": (
310 - user_layer.data.get(key)
311 - if user_layer and key in user_layer.data
335 + scope_layer.data.get(key)
336 + if scope_layer and key in scope_layer.data
337 else None
338 ),
314 - "has_override": bool(user_layer and key in user_layer.data),
339 + "has_override": bool(scope_layer and key in scope_layer.data),
340 "source": _relative_source(source),
341 "inherited": inherited,
342 "inherited_source": _relative_source(inherited_source),
318 - "project_override_active": source_kind == "project",
343 }
344
321 - user_dir = USER_AGENTS_ROOT / profile_id
345 + scope_root = _profile_root(profile_id, project_name)
346 built_in = (Path(files.get_abs_path("agents")) / profile_id).is_dir()
347 plugin_origin = any(layer.kind == "plugin" for layer in layers)
348 state.update(
@@ -326,10 +350,8 @@ def metadata_state(profile_id: str, context: Any | None = None) -> dict[str, Any
350 "origin": "Built-in" if built_in else "Plugin" if plugin_origin else "Custom",
351 "origin_chain": list(dict.fromkeys(layer.kind for layer in layers)),
352 "built_in": built_in,
329 - "has_user_overrides": user_dir.is_dir() and any(user_dir.iterdir()),
330 - "project_override_active": any(
331 - layer.kind == "project" for layer in layers
332 - ),
353 + "scope_has_overrides": _scope_has_files(scope_root),
354 + "deletable": _scope_owns_custom_profile(profile_id, project_name),
355 }
356 )
357 return state
@@ -345,8 +367,8 @@ def prompt_catalog(agent: EditorAgent) -> list[dict[str, Any]]:
367 if path.is_file() and path.name not in NON_PROMPT_MARKDOWN
368 }
369 names.add(SPECIFICS_FILE)
348 - user_prompt_dir = USER_AGENTS_ROOT / agent.config.profile / "prompts"
349 - project_name = projects.get_context_project_name(agent.context) or ""
370 + project_name = _context_project_name(agent.context)
371 + scope_prompt_dir = _profile_root(agent.config.profile, project_name) / "prompts"
372 project_root = (
373 Path(projects.get_project_meta(project_name)) if project_name else None
374 )
@@ -356,34 +378,32 @@ def prompt_catalog(agent: EditorAgent) -> list[dict[str, Any]]:
378 for root in roots:
379 path = root / name
380 if path.is_file():
359 - kind, label = _prompt_source(root, user_prompt_dir, project_root)
381 + kind, label = _prompt_source(root, scope_prompt_dir, project_root)
382 occurrences.append((path, kind, label))
383
384 effective = occurrences[0] if occurrences else None
363 - user = next((item for item in occurrences if item[1] == "user"), None)
385 + override = next((item for item in occurrences if item[1] == "scope"), None)
386 inherited = next(
387 (
388 item
389 for item in occurrences
368 - if item[1] not in {"user", "project"}
390 + if item[1] != "scope"
391 ),
392 None,
393 )
394 effective_text, effective_error = _prompt_text(effective[0] if effective else None)
373 - user_text, user_error = _prompt_text(user[0] if user else None)
395 + override_text, override_error = _prompt_text(override[0] if override else None)
396 inherited_text, inherited_error = _prompt_text(
397 inherited[0] if inherited else None
398 )
399 group_number, group_label = _prompt_group(name)
400 source_kind = effective[1] if effective else ""
379 - if effective_error or user_error or inherited_error:
401 + if effective_error or override_error or inherited_error:
402 state = "Conflict"
381 - elif source_kind == "project":
382 - state = "Project override active"
383 - elif user:
403 + elif override:
404 state = (
405 "Overridden here (empty)"
386 - if user_text == ""
406 + if override_text == ""
407 else "Overridden here"
408 )
409 elif source_kind == "plugin":
@@ -402,8 +422,8 @@ def prompt_catalog(agent: EditorAgent) -> list[dict[str, Any]]:
422 "group_label": group_label,
423 "state": state,
424 "effective": effective_text,
405 - "override": user_text if user else None,
406 - "has_override": bool(user),
425 + "override": override_text if override else None,
426 + "has_override": bool(override),
427 "inherited": inherited_text,
428 "source": _relative_source(effective[0] if effective else None),
429 "inherited_source": _relative_source(
@@ -412,9 +432,8 @@ def prompt_catalog(agent: EditorAgent) -> list[dict[str, Any]]:
432 "source_chain": [
433 label for _, _, label in reversed(occurrences)
434 ],
415 - "project_override_active": source_kind == "project",
435 "preview": preview,
417 - "error": effective_error or user_error or inherited_error,
436 + "error": effective_error or override_error or inherited_error,
437 "dynamic_processor": any(
438 (root / f"{Path(name).stem}.py").is_file() for root in roots
439 ),
@@ -443,7 +462,7 @@ def effective_avatar_url(profile_id: str, context: Any | None = None) -> str:
462 "profile_id": profile_id,
463 "v": str(path.stat().st_mtime_ns),
464 }
446 - project_name = projects.get_context_project_name(context) if context else ""
465 + project_name = _context_project_name(context)
466 if project_name:
467 query["project_name"] = project_name
468 return "/api/plugins/_agent_editor/agent_editor_avatar?" + urlencode(query)
@@ -461,7 +480,7 @@ def _metadata_layers(profile_id: str, context: Any | None) -> list[ProfileLayer]
480 directories.append(("plugin", Path(directory)))
481 directories.append(("user", USER_AGENTS_ROOT / profile_id))
482
464 - project_name = projects.get_context_project_name(agent.context) or ""
483 + project_name = _context_project_name(agent.context)
484 if project_name:
485 directories.append(
486 (
@@ -511,10 +530,10 @@ def _layer_value(
530
531
532 def _prompt_source(
514 - root: Path, user_prompt_dir: Path, project_root: Path | None
533 + root: Path, scope_prompt_dir: Path, project_root: Path | None
534 ) -> tuple[str, str]:
516 - if root.resolve() == user_prompt_dir.resolve():
517 - return "user", "Your override"
535 + if root.resolve() == scope_prompt_dir.resolve():
536 + return "scope", "Your override"
537 if project_root and files.is_in_dir(str(root), str(project_root)):
538 return "project", f"Project · {project_root.parent.name}"
539 plugin = plugins.get_plugin_name_from_path(root)
@@ -558,11 +577,15 @@ def _model_identity(value: Any) -> dict[str, str]:
577 }
578
579
561 -def _profile_config_path(profile_id: str, plugin_name: str) -> Path:
580 +def _profile_config_path(
581 + profile_id: str,
582 + plugin_name: str,
583 + project_name: str = "",
584 +) -> Path:
585 return Path(
586 plugins.determine_plugin_asset_path(
587 plugin_name,
565 - "",
588 + project_name,
589 profile_id,
590 plugins.CONFIG_FILE_NAME,
591 )
@@ -639,6 +662,8 @@ def build_change_plan(
662 raise ValueError("The save patch must be an object.")
663
664 profile_id = validate_profile_id(patch.get("profile_id"))
665 + project_name = _context_project_name(context)
666 + profile_root = _profile_root(profile_id, project_name)
667 creating = bool(patch.get("creating"))
668 easy = str(patch.get("editor_mode") or "advanced").lower() == "easy"
669 exists = profile_exists(profile_id, context)
@@ -650,7 +675,7 @@ def build_change_plan(
675 if not creating and not exists:
676 raise ValueError(f'Agent profile "{profile_id}" does not exist.')
677
653 - plan = ChangePlan(profile_id=profile_id)
678 + plan = ChangePlan(profile_id=profile_id, project_name=project_name)
679 if "metadata" in patch:
680 _plan_metadata(plan, patch["metadata"], context, creating=creating)
681 if "prompts" in patch:
@@ -669,10 +694,10 @@ def build_change_plan(
694 _plan_skill_policy(plan, patch["skill_policy"])
695
696 if creating:
672 - metadata = _planned_mapping(plan, USER_AGENTS_ROOT / profile_id / "agent.yaml")
697 + metadata = _planned_mapping(plan, profile_root / "agent.yaml")
698 if not str(metadata.get("title") or "").strip():
699 raise ValueError("Agent name is required.")
675 - specifics = USER_AGENTS_ROOT / profile_id / "prompts" / SPECIFICS_FILE
700 + specifics = profile_root / "prompts" / SPECIFICS_FILE
701 change = plan.changes.get(specifics)
702 if not change or change.action != "write" or not (change.content or b"").strip():
703 raise ValueError("Instructions are required for a new agent.")
@@ -697,8 +722,9 @@ def _plan_metadata(
722 raise ValueError("A metadata field cannot be set and reset in one save.")
723
724 profile_id = plan.profile_id
700 - yaml_path = USER_AGENTS_ROOT / profile_id / "agent.yaml"
701 - legacy_path = USER_AGENTS_ROOT / profile_id / "agent.json"
725 + profile_root = _profile_root(profile_id, plan.project_name)
726 + yaml_path = profile_root / "agent.yaml"
727 + legacy_path = profile_root / "agent.json"
728 data = _read_mapping_strict(
729 yaml_path if yaml_path.is_file() else legacy_path,
730 "profile metadata",
@@ -709,7 +735,7 @@ def _plan_metadata(
735 for key in reset_values:
736 data.pop(key, None)
737 if key == "avatar" and _editor_image_avatar(current_user_avatar):
712 - plan.delete(USER_AGENTS_ROOT / profile_id / "assets" / "avatar.webp")
738 + plan.delete(profile_root / "assets" / "avatar.webp")
739
740 for key, raw in set_values.items():
741 if key in {"title", "description", "context"}:
@@ -723,7 +749,7 @@ def _plan_metadata(
749 if normalized.get("kind") == "color" and _editor_image_avatar(
750 current_user_avatar
751 ):
726 - plan.delete(USER_AGENTS_ROOT / profile_id / "assets" / "avatar.webp")
752 + plan.delete(profile_root / "assets" / "avatar.webp")
753
754 inherited = state[key]["inherited"]
755 if not creating and normalized == inherited:
@@ -773,7 +799,7 @@ def _plan_prompts(
799 "Restore original instructions."
800 )
801 item = catalog[filename]
776 - path = USER_AGENTS_ROOT / plan.profile_id / "prompts" / filename
802 + path = _profile_root(plan.profile_id, plan.project_name) / "prompts" / filename
803 if (
804 not creating
805 and item.get("inherited_source")
@@ -786,13 +812,15 @@ def _plan_prompts(
812 if total > MAX_PROMPTS_BYTES:
813 raise ValueError("The combined prompt changes are too large.")
814 for filename in reset_values:
789 - plan.delete(USER_AGENTS_ROOT / plan.profile_id / "prompts" / filename)
815 + plan.delete(
816 + _profile_root(plan.profile_id, plan.project_name) / "prompts" / filename
817 + )
818
819
820 def _plan_model_preset(plan: ChangePlan, value: Any) -> None:
821 section = _mapping(value, "model_preset")
822 mode = str(section.get("mode") or "inherit").strip().lower()
795 - path = _profile_config_path(plan.profile_id, "_model_config")
823 + path = _profile_config_path(plan.profile_id, "_model_config", plan.project_name)
824 data = _read_mapping_strict(path, "model preset configuration")
825
826 from plugins._model_config.helpers import model_config
@@ -813,7 +841,11 @@ def _plan_model_preset(plan: ChangePlan, value: Any) -> None:
841 def _plan_tool_policy(plan: ChangePlan, value: Any) -> None:
842 section = _mapping(value, "tool_policy")
843 mode = str(section.get("mode") or "inherit").strip().lower()
816 - path = _profile_config_path(plan.profile_id, tool_policy.PLUGIN_NAME)
844 + path = _profile_config_path(
845 + plan.profile_id,
846 + tool_policy.PLUGIN_NAME,
847 + plan.project_name,
848 + )
849 data = _read_mapping_strict(path, "tool policy configuration")
850
851 if mode == "inherit":
@@ -840,7 +872,11 @@ def _plan_tool_policy(plan: ChangePlan, value: Any) -> None:
872 def _plan_skill_policy(plan: ChangePlan, value: Any) -> None:
873 section = _mapping(value, "skill_policy")
874 mode = str(section.get("mode") or "inherit").strip().lower()
843 - path = _profile_config_path(plan.profile_id, skills.ACTIVE_SKILLS_PLUGIN_NAME)
875 + path = _profile_config_path(
876 + plan.profile_id,
877 + skills.ACTIVE_SKILLS_PLUGIN_NAME,
878 + plan.project_name,
879 + )
880 data = _read_mapping_strict(path, "skill policy configuration")
881
882 if mode == "inherit":
@@ -878,7 +914,9 @@ def _normalize_avatar(plan: ChangePlan, value: Any) -> dict[str, str]:
914 if not source.is_file():
915 raise ValueError("The staged avatar has expired. Upload it again.")
916 plan.write(
881 - USER_AGENTS_ROOT / plan.profile_id / "assets" / "avatar.webp",
917 + _profile_root(plan.profile_id, plan.project_name)
918 + / "assets"
919 + / "avatar.webp",
920 source.read_bytes(),
921 )
922 plan.staged_tokens.add(token)
@@ -949,7 +987,15 @@ def _editor_image_avatar(value: Any) -> bool:
987
988
989 def apply_change_plan(plan: ChangePlan) -> dict[str, Any]:
952 - root = USER_AGENTS_ROOT / validate_profile_id(plan.profile_id)
990 + project_name = (
991 + projects.validate_project_name(plan.project_name) if plan.project_name else ""
992 + )
993 + if project_name and not Path(projects.get_project_folder(project_name)).is_dir():
994 + raise ValueError("Project not found.")
995 + root = _profile_root(
996 + validate_profile_id(plan.profile_id),
997 + project_name,
998 + )
999 changes = sorted(plan.changes.values(), key=lambda item: str(item.path))
1000 _validate_plan_paths(root, changes)
1001 receipt = plan.response()
@@ -1011,7 +1057,7 @@ def plan_remove_changes(
1057 if not profile_exists(profile_id, context):
1058 raise ValueError(f'Agent profile "{profile_id}" does not exist.')
1059 if destructive:
1014 - return _full_delete_plan(profile_id)
1060 + return _full_delete_plan(profile_id, context)
1061
1062 catalog = prompt_catalog(EditorAgent(profile_id, context))
1063 prompt_resets = [
@@ -1036,21 +1082,22 @@ def plan_remove_changes(
1082 def plan_delete_custom(profile_id: str, context: Any | None = None) -> ChangePlan:
1083 profile_id = validate_profile_id(profile_id)
1084 state = metadata_state(profile_id, context)
1039 - if state["origin"] != "Custom":
1040 - raise ValueError("Built-in and plugin-provided agents cannot be deleted.")
1041 - return _full_delete_plan(profile_id)
1085 + if not state["deletable"]:
1086 + raise ValueError("Only custom agents created in this scope can be deleted.")
1087 + return _full_delete_plan(profile_id, context)
1088
1089
1090 def delete_impact(profile_id: str, context: Any | None = None) -> dict[str, Any]:
1091 profile_id = validate_profile_id(profile_id)
1092 state = metadata_state(profile_id, context)
1047 - root = USER_AGENTS_ROOT / profile_id
1093 + project_name = _context_project_name(context)
1094 + root = _profile_root(profile_id, project_name)
1095 file_paths = [
1096 _relative_source(path)
1097 for path in sorted(root.rglob("*"))
1098 if path.is_file() or path.is_symlink()
1099 ] if root.is_dir() else []
1053 - references = _profile_reference_paths(profile_id)
1100 + references = _profile_reference_paths(profile_id, project_name)
1101 sessions: list[str] = []
1102 try:
1103 from agent import AgentContext
@@ -1060,15 +1107,22 @@ def delete_impact(profile_id: str, context: Any | None = None) -> dict[str, Any]
1107 for item in AgentContext.all()
1108 if str(getattr(getattr(item.agent0, "config", None), "profile", ""))
1109 == profile_id
1110 + and (
1111 + not project_name
1112 + or projects.get_context_project_name(item) == project_name
1113 + )
1114 ]
1115 except Exception:
1116 pass
1117
1067 - model_config = _read_mapping(_profile_config_path(profile_id, "_model_config"))
1118 + model_config = _read_mapping(
1119 + _profile_config_path(profile_id, "_model_config", project_name)
1120 + )
1121 return {
1122 "profile_id": profile_id,
1070 - "deletable": state["origin"] == "Custom",
1123 + "deletable": state["deletable"],
1124 "origin": state["origin"],
1125 + "project_name": project_name,
1126 "files": file_paths,
1127 "project_references": references,
1128 "active_sessions": sessions,
@@ -1128,9 +1182,17 @@ def staged_avatar_path(token: str) -> Path:
1182 return STAGED_AVATAR_ROOT / f"{token}.webp"
1183
1184
1131 -def _full_delete_plan(profile_id: str) -> ChangePlan:
1132 - root = USER_AGENTS_ROOT / profile_id
1133 - plan = ChangePlan(profile_id=profile_id, remove_empty_root=True)
1185 +def _full_delete_plan(
1186 + profile_id: str,
1187 + context: Any | None = None,
1188 +) -> ChangePlan:
1189 + project_name = _context_project_name(context)
1190 + root = _profile_root(profile_id, project_name)
1191 + plan = ChangePlan(
1192 + profile_id=profile_id,
1193 + project_name=project_name,
1194 + remove_empty_root=True,
1195 + )
1196 if not root.is_dir():
1197 return plan
1198 for path in sorted(root.rglob("*")):
@@ -1150,7 +1212,7 @@ def _validate_plan_paths(root: Path, changes: list[FileChange]) -> None:
1212 raise ValueError("Invalid change-plan action.")
1213 resolved = change.path.resolve(strict=False)
1214 if not files.is_in_dir(str(resolved), str(intended_root)):
1153 - raise ValueError("A planned path is outside the user profile directory.")
1215 + raise ValueError("A planned path is outside the selected profile directory.")
1216 cursor = change.path.parent
1217 while files.is_in_dir(str(cursor), str(intended_root)):
1218 if cursor.is_symlink():
@@ -1248,13 +1310,18 @@ def _cleanup_staged_avatars() -> None:
1310 pass
1311
1312
1251 -def _profile_reference_paths(profile_id: str) -> list[str]:
1252 - roots = Path(files.get_abs_path("usr", "projects"))
1253 - if not roots.is_dir():
1313 +def _profile_reference_paths(profile_id: str, project_name: str = "") -> list[str]:
1314 + root = (
1315 + Path(projects.get_project_meta(project_name))
1316 + if project_name
1317 + else Path(files.get_abs_path("usr", "projects"))
1318 + )
1319 + if not root.is_dir():
1320 return []
1321 references: list[str] = []
1322 for suffix in ("*.json", "*.yaml", "*.yml"):
1257 - for path in roots.glob(f"*/.a0proj/**/{suffix}"):
1323 + pattern = f"**/{suffix}" if project_name else f"*/.a0proj/**/{suffix}"
1324 + for path in root.glob(pattern):
1325 try:
1326 if profile_id in path.read_text(encoding="utf-8"):
1327 references.append(_relative_source(path))
plugins/_agent_editor/webui/agent-editor-store.js
+111 -29
@@ -42,12 +42,24 @@ function policyFromState(value, hasOverride) {
42 }
43
44 function policyAllows(policy, id) {
45 - if (policy.mode !== "custom") return true;
45 + if (!policy || policy.mode !== "custom") return true;
46 if (policy.blocked.includes(id)) return false;
47 if (policy.allowed.includes(id)) return true;
48 return policy.default === "allow";
49 }
50
51 +function policyBehavior(policy) {
52 + const value = policy || {};
53 + if (value.mode !== "custom") {
54 + return { default: "allow", allowed: [], blocked: [] };
55 + }
56 + return {
57 + default: value.default === "block" ? "block" : "allow",
58 + allowed: unique(value.allowed).sort(),
59 + blocked: unique(value.blocked).sort(),
60 + };
61 +}
62 +
63 function movePolicyItem(policy, id, allow) {
64 policy.allowed = policy.allowed.filter((item) => item !== id);
65 policy.blocked = policy.blocked.filter((item) => item !== id);
@@ -63,7 +75,7 @@ function escapeHtml(value) {
75 }
76
77 const model = {
66 - intent: { view: "create", profileId: "", contextId: "" },
78 + intent: { view: "create", profileId: "", contextId: "", projectName: "" },
79 view: "editor",
80 mode: "easy",
81 section: "1",
@@ -72,6 +84,8 @@ const model = {
84 avatarUploading: false,
85 error: "",
86 state: null,
87 + projects: [],
88 + projectName: "",
89 profiles: [],
90 draft: null,
91 initialDraft: null,
@@ -107,10 +121,17 @@ const model = {
121 },
122
123 async open(options = {}) {
124 + const contextProject = chatsStore.selectedContext?.project;
125 + const currentProjectName = typeof contextProject === "object"
126 + ? String(contextProject?.name || "")
127 + : String(contextProject || "");
128 this.intent = {
129 view: options.view || (options.profileId ? "edit" : "create"),
130 profileId: String(options.profileId || ""),
131 contextId: String(options.contextId || chatsStore.selected || ""),
132 + projectName: options.projectName === undefined
133 + ? currentProjectName
134 + : String(options.projectName || ""),
135 };
136 this.suppressClosePrompt = false;
137 return await openModal(MODAL, () => this.beforeClose());
@@ -128,6 +149,7 @@ const model = {
149 this.plan = { written: [], deleted: [], warnings: [] };
150 this.planStatus = "idle";
151 this.view = this.intent.view === "manage" ? "manage" : "editor";
152 + this.projectName = this.intent.projectName || "";
153 const modalTitle =
154 this.view === "manage"
155 ? "Manage agents"
@@ -145,6 +167,12 @@ const model = {
167 this.mode = "easy";
168 this.section = this.savedSection();
169 this.syncSurface();
170 + await this.loadProjects();
171 + if (this.projectName && this.projects.length
172 + && !this.projects.some((project) => project.key === this.projectName)) {
173 + this.projectName = "";
174 + this.intent = { ...this.intent, projectName: "" };
175 + }
176 await this.loadProfiles();
177 if (this.view === "manage") {
178 this.loading = false;
@@ -161,7 +189,7 @@ const model = {
189 try {
190 const data = await callJsonApi(API, {
191 action: "list",
164 - context_id: this.intent.contextId,
192 + ...this.scopeInput(),
193 });
194 this.profiles = data.profiles || [];
195 } catch (error) {
@@ -170,6 +198,40 @@ const model = {
198 }
199 },
200
201 + async loadProjects() {
202 + try {
203 + const data = await callJsonApi("/projects", { action: "list_options" });
204 + this.projects = data.ok ? (data.data || []) : [];
205 + } catch {
206 + this.projects = [];
207 + }
208 + },
209 +
210 + scopeInput() {
211 + return { project_name: this.projectName || "" };
212 + },
213 +
214 + get scopeLabel() {
215 + if (!this.projectName) return "Global";
216 + return this.projects.find((project) => project.key === this.projectName)?.label
217 + || this.projectName;
218 + },
219 +
220 + get inheritedLabel() {
221 + return this.projectName ? "Inherited" : "Default";
222 + },
223 +
224 + async onScopeChanged() {
225 + this.intent = { ...this.intent, projectName: this.projectName || "" };
226 + this.loading = true;
227 + this.error = "";
228 + try {
229 + await this.loadProfiles();
230 + } finally {
231 + this.loading = false;
232 + }
233 + },
234 +
235 async loadEditor(profileId, creating = false) {
236 this.loading = true;
237 this.error = "";
@@ -177,7 +239,7 @@ const model = {
239 const data = await callJsonApi(API, {
240 action: "load",
241 profile_id: profileId,
180 - context_id: this.intent.contextId,
242 + ...this.scopeInput(),
243 });
244 this.state = data.state;
245 this.intent = { ...this.intent, view: creating ? "create" : "edit", profileId };
@@ -468,6 +530,7 @@ const model = {
530 metadataProvenance(key) {
531 const metadata = this.state?.profile?.metadata?.[key] || {};
532 if (metadata.has_override && !this.metadataResetPending(key)) return "Customized by you";
533 + if (this.projectName) return "Inherited from Global";
534 const match = String(metadata.inherited_source || metadata.source || "")
535 .match(/(?:^|\/)agents\/([^/]+)/);
536 const sourceId = match?.[1] || "";
@@ -536,13 +599,15 @@ const model = {
599 },
600
601 promptDisplayState(prompt) {
539 - if (prompt?.reset) return "Will use the default";
602 + if (prompt?.reset) return this.projectName ? "Will use inherited" : "Will use the default";
603 if (prompt?.has_override || this.promptDirty(prompt)) return "Customized by you";
541 - return prompt?.state === "Unavailable" ? "Unavailable" : "Default";
604 + if (prompt?.state === "Unavailable") return "Unavailable";
605 + return this.projectName ? "Inherited" : "Default";
606 },
607
608 promptSourceChain(prompt) {
609 if (!prompt?.reset && (prompt?.has_override || this.promptDirty(prompt))) return "Customized by you";
610 + if (this.projectName) return "Inherited from Global";
611 const source = [...(prompt?.source_chain || [])]
612 .filter((item) => item !== "Your override")
613 .at(-1);
@@ -596,7 +661,10 @@ const model = {
661 },
662
663 copyPromptPath() {
599 - const path = `usr/agents/${this.draft.profileId}/prompts/${this.selectedPrompt}`;
664 + const root = this.projectName
665 + ? `usr/projects/${this.projectName}/.a0proj/agents`
666 + : "usr/agents";
667 + const path = `${root}/${this.draft.profileId}/prompts/${this.selectedPrompt}`;
668 navigator.clipboard?.writeText(path);
669 globalThis.justToast?.("Path copied", "success", 1200, "agent-editor-copy");
670 },
@@ -607,21 +675,26 @@ const model = {
675 this.selectedBlockedTools = [];
676 },
677
678 + customizePolicy(kind) {
679 + const key = kind === "tool" ? "toolPolicy" : "skillPolicy";
680 + if (this.draft[key].mode === "custom") return;
681 + const state = kind === "tool" ? this.state.tools : this.state.skills;
682 + this.draft[key] = { mode: "custom", ...policyBehavior(state.effective_policy) };
683 + },
684 +
685 chooseTools() {
611 - if (this.draft.toolPolicy.mode !== "custom") {
612 - this.draft.toolPolicy = { mode: "custom", default: "allow", allowed: [], blocked: [] };
613 - }
686 + this.customizePolicy("tool");
687 this.setMode("advanced", "3");
688 },
689
690 setEasyToolAllowed(id, allow) {
618 - if (this.draft.toolPolicy.mode !== "custom") {
619 - this.draft.toolPolicy = { mode: "custom", default: "allow", allowed: [], blocked: [] };
620 - }
691 + this.customizePolicy("tool");
692 this.moveTools([id], allow);
693 const policy = this.draft.toolPolicy;
623 - if (this.initialDraft?.toolPolicy.mode !== "custom" && policy.default === "allow"
624 - && !policy.allowed.length && !policy.blocked.length) this.useStandardTools();
694 + if (this.initialDraft?.toolPolicy.mode !== "custom"
695 + && same(policyBehavior(policy), policyBehavior(this.state.tools.effective_policy))) {
696 + this.useStandardTools();
697 + }
698 },
699
700 useStandardSkills() {
@@ -631,9 +704,7 @@ const model = {
704 },
705
706 chooseSkills() {
634 - if (this.draft.skillPolicy.mode !== "custom") {
635 - this.draft.skillPolicy = { mode: "custom", default: "allow", allowed: [], blocked: [] };
636 - }
707 + this.customizePolicy("skill");
708 },
709
710 setPolicyDefault(kind, nextDefault) {
@@ -650,11 +721,17 @@ const model = {
721 },
722
723 isToolAllowed(item) {
653 - return policyAllows(this.draft.toolPolicy, item.id);
724 + const policy = this.draft.toolPolicy.mode === "custom"
725 + ? this.draft.toolPolicy
726 + : this.state?.tools?.effective_policy;
727 + return policyAllows(policy, item.id);
728 },
729
730 isSkillAllowed(item) {
657 - return policyAllows(this.draft.skillPolicy, item.name);
731 + const policy = this.draft.skillPolicy.mode === "custom"
732 + ? this.draft.skillPolicy
733 + : this.state?.skills?.effective_policy;
734 + return policyAllows(policy, item.name);
735 },
736
737 filteredTools(allowed) {
@@ -739,7 +816,7 @@ const model = {
816 const data = await callJsonApi(API, {
817 action: "load",
818 profile_id: this.draft.profileId || "new-agent",
742 - context_id: this.intent.contextId,
819 + ...this.scopeInput(),
820 });
821 this.state.model_presets = data.state.model_presets;
822 } catch (error) {
@@ -854,7 +931,7 @@ const model = {
931 const data = await callJsonApi(API, {
932 action: "plan",
933 patch: this.buildPatch(),
857 - context_id: this.intent.contextId,
934 + ...this.scopeInput(),
935 });
936 this.plan = data;
937 this.planStatus = "ready";
@@ -883,7 +960,7 @@ const model = {
960 const data = await callJsonApi(API, {
961 action: "save",
962 patch: this.buildPatch(),
886 - context_id: this.intent.contextId,
963 + ...this.scopeInput(),
964 });
965 this.plan = data;
966 this.initialDraft = clone(this.draft);
@@ -913,6 +990,11 @@ const model = {
990 const created = await callJsonApi("/chat_create", {
991 current_context: this.intent.contextId || chatsStore.selected || "",
992 });
993 + await callJsonApi("/projects", {
994 + action: this.projectName ? "activate" : "deactivate",
995 + context_id: created.ctxid,
996 + ...(this.projectName ? { name: this.projectName } : {}),
997 + });
998 await callJsonApi("/agent_profile_set", {
999 context_id: created.ctxid,
1000 agent_profile: profileId,
@@ -951,7 +1033,7 @@ const model = {
1033 action: "plan_remove_changes",
1034 profile_id: this.draft.profileId,
1035 destructive,
954 - context_id: this.intent.contextId,
1036 + ...this.scopeInput(),
1037 });
1038 this.plan = data;
1039 this.planStatus = "ready";
@@ -985,7 +1067,7 @@ const model = {
1067 action: "remove_changes",
1068 profile_id: this.draft.profileId,
1069 destructive: this.pendingMutation.destructive,
988 - context_id: this.intent.contextId,
1070 + ...this.scopeInput(),
1071 });
1072 this.pendingMutation = null;
1073 await this.loadEditor(this.draft.profileId);
@@ -1000,11 +1082,11 @@ const model = {
1082 const data = await callJsonApi(API, {
1083 action: "plan_delete",
1084 profile_id: profileId,
1003 - context_id: this.intent.contextId,
1085 + ...this.scopeInput(),
1086 });
1087 const confirmed = await showConfirmDialog({
1088 title: `Delete ${escapeHtml(profileId)}?`,
1007 - message: `${this.deletionImpactHtml(data)}<p>This permanently removes this custom agent.</p>`,
1089 + message: `${this.deletionImpactHtml(data)}<p>This permanently removes this custom agent from ${escapeHtml(this.scopeLabel)}.</p>`,
1090 confirmText: "Delete agent",
1091 type: "danger",
1092 });
@@ -1013,13 +1095,13 @@ const model = {
1095 action: "delete",
1096 profile_id: profileId,
1097 confirm: true,
1016 - context_id: this.intent.contextId,
1098 + ...this.scopeInput(),
1099 });
1100 await this.loadProfiles();
1101 await modelConfigStore.loadAgentProfiles(true);
1102 this.view = "manage";
1103 this.setModalTitle("Manage agents");
1022 - globalThis.justToast?.("Agent deleted.", "success", 1800);
1104 + globalThis.justToast?.(`Agent deleted from ${this.scopeLabel}.`, "success", 1800);
1105 } catch (error) {
1106 this.error = error.message || String(error);
1107 }
plugins/_agent_editor/webui/main.html
+55 -25
@@ -26,7 +26,28 @@
26
27 <template x-if="!$store.agentEditor.loading && $store.agentEditor.view === 'manage'">
28 <section class="agent-manager" aria-label="Manage agents">
29 - <p class="agent-manager-intro">Edits to built-in agents are saved as your own changes — originals are never modified.</p>
29 + <div class="agent-scope-selector">
30 + <div class="agent-scope-header">
31 + <div class="agent-scope-copy">
32 + <strong>Agent scope</strong>
33 + <p>Choose whether agents and customizations apply globally or only to one project.</p>
34 + </div>
35 + </div>
36 + <div class="agent-scope-toolbar">
37 + <label class="agent-scope-field">
38 + <span>Project</span>
39 + <select x-model="$store.agentEditor.projectName"
40 + x-init="$nextTick(() => $el.value = $store.agentEditor.projectName)"
41 + @change="$store.agentEditor.onScopeChanged()">
42 + <option value="">Global</option>
43 + <template x-for="project in $store.agentEditor.projects" :key="project.key">
44 + <option :value="project.key" x-text="project.label"></option>
45 + </template>
46 + </select>
47 + </label>
48 + </div>
49 + </div>
50 + <p class="agent-manager-intro" x-text="$store.agentEditor.projectName ? `Create agents and customize inherited profiles only for ${$store.agentEditor.scopeLabel}. Global and original files are never modified.` : 'Create agents and customize inherited profiles for every project. Originals are never modified.'"></p>
51 <div class="agent-manager-list">
52 <template x-for="profile in $store.agentEditor.profiles" :key="profile.id">
53 <article class="agent-manager-card">
@@ -38,17 +59,16 @@
59 <div class="agent-manager-name">
60 <strong x-text="profile.title || profile.id"></strong>
61 <span class="agent-origin" x-text="profile.origin"></span>
41 - <span class="agent-status-badge is-customized" x-show="profile.has_user_overrides">
62 + <span class="agent-status-badge is-customized" x-show="profile.scope_has_overrides">
63 <x-icon name="edit_note"></x-icon><span>Customized by you</span>
64 </span>
65 </div>
66 <p x-text="profile.description || 'No description'"></p>
67 <code x-text="profile.id"></code>
47 - <div class="agent-project-notice" x-show="profile.project_override_active">This project’s customization takes priority here.</div>
68 </div>
69 <div class="agent-manager-actions">
70 <button type="button" class="button" @click="$store.agentEditor.loadEditor(profile.id, false)"><x-icon name="edit"></x-icon>Edit</button>
51 - <button type="button" class="button danger" x-show="profile.origin === 'Custom'" @click="$store.agentEditor.deleteProfile(profile.id)"><x-icon name="delete"></x-icon>Delete</button>
71 + <button type="button" class="button danger" x-show="profile.deletable" @click="$store.agentEditor.deleteProfile(profile.id)"><x-icon name="delete"></x-icon>Delete</button>
72 </div>
73 </article>
74 </template>
@@ -59,8 +79,9 @@
79 <template x-if="!$store.agentEditor.loading && $store.agentEditor.view === 'editor' && $store.agentEditor.draft">
80 <div class="agent-editor-workspace">
81 <header class="agent-editor-topbar">
62 - <div class="agent-editor-heading" x-show="$store.agentEditor.intent.view === 'manage' || (!$store.agentEditor.draft.creating && $store.agentEditor.dirty)">
82 + <div class="agent-editor-heading">
83 <button type="button" class="button icon" x-show="$store.agentEditor.intent.view === 'manage'" aria-label="Back to agents" @click="$store.agentEditor.showManager()"><x-icon name="arrow_back"></x-icon></button>
84 + <span class="agent-scope-indicator"><x-icon :name="$store.agentEditor.projectName ? 'folder' : 'public'"></x-icon><span>Scope:</span><strong x-text="$store.agentEditor.scopeLabel"></strong></span>
85 <div class="agent-editor-subtitle" x-show="$store.agentEditor.dirty">
86 <span class="agent-status-badge is-unsaved"><x-icon name="edit_note"></x-icon><span>Unsaved changes</span></span>
87 </div>
@@ -102,7 +123,7 @@
123 <div>
124 <textarea id="agent-editor-instructions" rows="9" x-model="$store.agentEditor.instructions.value" @input="$store.agentEditor.markPromptSet('agent.system.main.specifics.md')" placeholder="Research technical topics, verify important claims with reliable sources, and return concise reports with links." :aria-invalid="$store.agentEditor.fieldIssue('instructions') ? 'true' : null" :aria-describedby="$store.agentEditor.fieldIssue('instructions') ? 'agent-editor-instructions-error' : null"></textarea>
125 <span id="agent-editor-instructions-error" class="field-error" role="alert" x-show="$store.agentEditor.fieldIssue('instructions')" x-text="$store.agentEditor.fieldIssue('instructions')?.message"></span>
105 - <button type="button" class="text-button restore-action" x-show="$store.agentEditor.instructions.has_override && !$store.agentEditor.instructions.reset" @click="$store.agentEditor.restoreInstructions()"><x-icon name="restart_alt"></x-icon>Use default instructions</button>
126 + <button type="button" class="text-button restore-action" x-show="$store.agentEditor.instructions.has_override && !$store.agentEditor.instructions.reset" @click="$store.agentEditor.restoreInstructions()"><x-icon name="restart_alt"></x-icon><span x-text="$store.agentEditor.projectName ? 'Use inherited instructions' : 'Use default instructions'"></span></button>
127 </div>
128 </section>
129
@@ -117,7 +138,6 @@
138 <p class="policy-empty" x-show="!$store.agentEditor.toolCatalog.length">No configurable tools are available.</p>
139 </div>
140 <p class="easy-skills-hint">To enable or disable skills, click Advanced.</p>
120 - <div class="agent-project-notice" x-show="$store.agentEditor.state.tools.project_override_active">This project has different tool settings. Your changes apply wherever a project has not chosen its own settings.</div>
141 </section>
142 </main>
143
@@ -136,8 +156,7 @@
156 <header class="advanced-section-heading"><h3 id="agent-section-1-title">Identity & models</h3><p>Set the agent’s identity, delegation guidance, and model preset.</p></header>
157 <div class="origin-row">
158 <span class="agent-origin" x-text="$store.agentEditor.state.profile.origin"></span>
139 - <span class="agent-status-badge is-customized" x-show="$store.agentEditor.state.profile.has_user_overrides"><x-icon name="edit_note"></x-icon><span>Customized by you</span></span>
140 - <span class="agent-project-notice" x-show="$store.agentEditor.state.profile.project_override_active">This project’s customization takes priority here.</span>
159 + <span class="agent-status-badge is-customized" x-show="$store.agentEditor.state.profile.scope_has_overrides"><x-icon name="edit_note"></x-icon><span>Customized by you</span></span>
160 </div>
161 <p class="built-in-note" x-show="$store.agentEditor.state.profile.built_in">Your changes override the built-in profile. The original files stay unchanged.</p>
162 <div class="advanced-identity-grid">
@@ -174,7 +193,7 @@
193 </section>
194
195 <section x-show="$store.agentEditor.section === '2'" data-agent-editor-section="2" tabindex="-1" aria-labelledby="agent-section-2-title">
177 - <header class="advanced-section-heading"><h3 id="agent-section-2-title">Prompt files</h3><p>Customize this agent’s prompt files. You always see the default next to your version.</p></header>
196 + <header class="advanced-section-heading"><h3 id="agent-section-2-title">Prompt files</h3><p>Customize this agent’s prompt files. You always see the inherited version next to your version.</p></header>
197 <div class="prompt-workspace">
198 <aside class="prompt-browser">
199 <label class="compact-search"><span class="sr-only">Search prompt files</span><x-icon name="search"></x-icon><input type="search" x-model="$store.agentEditor.promptFileSearch" placeholder="Search files"></label>
@@ -198,21 +217,20 @@
217 <div class="prompt-actions">
218 <button type="button" class="btn btn-action-header cancel" x-show="$store.agentEditor.promptEditPending($store.agentEditor.selectedPromptDraft)" title="Discard current edit" aria-label="Discard current edit" @click="$store.agentEditor.discardPromptEdit($store.agentEditor.selectedPrompt)"><x-icon name="close"></x-icon></button>
219 <button type="button" class="btn btn-action-header confirm" x-show="$store.agentEditor.promptEditPending($store.agentEditor.selectedPromptDraft)" title="Accept current edit" aria-label="Accept current edit" @click="$store.agentEditor.acceptPromptEdit($store.agentEditor.selectedPrompt)"><x-icon name="check"></x-icon></button>
201 - <button type="button" class="button" x-show="$store.agentEditor.selectedPromptDraft.has_override || $store.agentEditor.promptDirty($store.agentEditor.selectedPromptDraft)" @click="$store.agentEditor.resetPrompt($store.agentEditor.selectedPrompt)">Use default</button>
220 + <button type="button" class="button" x-show="$store.agentEditor.selectedPromptDraft.has_override || $store.agentEditor.promptDirty($store.agentEditor.selectedPromptDraft)" @click="$store.agentEditor.resetPrompt($store.agentEditor.selectedPrompt)" x-text="`Use ${$store.agentEditor.inheritedLabel.toLowerCase()}`"></button>
221 <button type="button" class="button icon" title="Copy customization path" aria-label="Copy customization path" @click="$store.agentEditor.copyPromptPath()"><x-icon name="content_copy"></x-icon></button>
222 </div>
223 </div>
205 - <div class="agent-project-notice" x-show="$store.agentEditor.selectedPromptDraft.project_override_active">This project has its own version of this file. Your customization applies wherever a project has not supplied one.</div>
206 - <div class="agent-project-notice" x-show="$store.agentEditor.selectedPromptDraft.dynamic_processor">This file is generated dynamically at runtime. Python processors are read-only and are not executed by the editor.</div>
224 + <div class="agent-editor-note" x-show="$store.agentEditor.selectedPromptDraft.dynamic_processor">This file is generated dynamically at runtime. Python processors are read-only and are not executed by the editor.</div>
225 <div class="prompt-view-tabs" role="tablist" aria-label="Prompt view">
226 <button type="button" role="tab" :aria-selected="$store.agentEditor.comparePrompt === ''" :class="{ active: $store.agentEditor.comparePrompt === '' }" @click="$store.agentEditor.comparePrompt = ''">Your version</button>
209 - <button type="button" role="tab" :aria-selected="$store.agentEditor.comparePrompt === 'inherited'" :class="{ active: $store.agentEditor.comparePrompt === 'inherited' }" @click="$store.agentEditor.comparePrompt = 'inherited'">Default</button>
227 + <button type="button" role="tab" :aria-selected="$store.agentEditor.comparePrompt === 'inherited'" :class="{ active: $store.agentEditor.comparePrompt === 'inherited' }" @click="$store.agentEditor.comparePrompt = 'inherited'" x-text="$store.agentEditor.inheritedLabel"></button>
228 <button type="button" role="tab" :aria-selected="$store.agentEditor.comparePrompt === 'compare'" :class="{ active: $store.agentEditor.comparePrompt === 'compare' }" @click="$store.agentEditor.comparePrompt = 'compare'">Compare</button>
229 </div>
230 <div class="prompt-find"><label><span class="sr-only">Search within prompt</span><input type="search" x-model="$store.agentEditor.promptTextSearch" placeholder="Find in file" @keydown.enter.prevent="$store.agentEditor.findInPrompt($event.shiftKey ? -1 : 1)"></label><span x-text="`${$store.agentEditor.promptMatchCount()} matches`"></span><button type="button" class="button icon" aria-label="Previous match" @click="$store.agentEditor.findInPrompt(-1)"><x-icon name="keyboard_arrow_up"></x-icon></button><button type="button" class="button icon" aria-label="Next match" @click="$store.agentEditor.findInPrompt(1)"><x-icon name="keyboard_arrow_down"></x-icon></button></div>
231 <div class="prompt-panes" :class="{ compare: $store.agentEditor.comparePrompt === 'compare' }">
232 <div class="prompt-pane" x-show="$store.agentEditor.comparePrompt !== 'inherited'"><label for="agent-editor-prompt-text">Your version</label><textarea id="agent-editor-prompt-text" spellcheck="false" x-model="$store.agentEditor.selectedPromptDraft.value" @input="$store.agentEditor.onPromptInput($store.agentEditor.selectedPrompt)" aria-label="Prompt Markdown" :aria-invalid="$store.agentEditor.selectedPrompt === 'agent.system.main.specifics.md' && $store.agentEditor.fieldIssue('instructions') ? 'true' : null" :aria-describedby="$store.agentEditor.selectedPrompt === 'agent.system.main.specifics.md' && $store.agentEditor.fieldIssue('instructions') ? 'agent-editor-prompt-error' : null"></textarea><span id="agent-editor-prompt-error" class="field-error" role="alert" x-show="$store.agentEditor.selectedPrompt === 'agent.system.main.specifics.md' && $store.agentEditor.fieldIssue('instructions')" x-text="$store.agentEditor.fieldIssue('instructions')?.message"></span></div>
215 - <div class="prompt-pane inherited" x-show="$store.agentEditor.comparePrompt"><div class="prompt-pane-title">Default</div><pre x-text="$store.agentEditor.selectedPromptDraft.inherited || '(empty)'" tabindex="0"></pre></div>
233 + <div class="prompt-pane inherited" x-show="$store.agentEditor.comparePrompt"><div class="prompt-pane-title" x-text="$store.agentEditor.inheritedLabel"></div><pre x-text="$store.agentEditor.selectedPromptDraft.inherited || '(empty)'" tabindex="0"></pre></div>
234 </div>
235 <details class="effective-preview"><summary>Preview combined prompt</summary><p>Static preview only. Runtime variables, projects, skills, tools, secrets, time, and dynamic extensions can differ.</p><pre x-text="$store.agentEditor.selectedPromptDraft.preview || '(empty)'" tabindex="0"></pre></details>
236 </div>
@@ -221,8 +239,7 @@
239
240 <section x-show="$store.agentEditor.section === '3'" data-agent-editor-section="3" tabindex="-1" aria-labelledby="agent-section-3-title">
241 <header class="advanced-section-heading"><h3 id="agent-section-3-title">Tools</h3><p>Choose which tools this agent can use.</p></header>
224 - <div class="agent-project-notice" x-show="$store.agentEditor.state.tools.project_override_active">This project has different tool settings. Your changes apply wherever a project has not chosen its own settings.</div>
225 - <div class="policy-mode-row"><label><input type="radio" name="tool-policy-mode" :checked="$store.agentEditor.draft.toolPolicy.mode === 'inherit'" @change="$store.agentEditor.useStandardTools()"><span>Use standard tool access <small x-text="`(${$store.agentEditor.toolCatalog.length} tools)`"></small></span></label><label><input type="radio" name="tool-policy-mode" :checked="$store.agentEditor.draft.toolPolicy.mode === 'custom'" @change="$store.agentEditor.chooseTools()">Choose tools</label></div>
242 + <div class="policy-mode-row"><label><input type="radio" name="tool-policy-mode" :checked="$store.agentEditor.draft.toolPolicy.mode === 'inherit'" @change="$store.agentEditor.useStandardTools()"><span><span x-text="$store.agentEditor.projectName ? 'Use inherited tool access' : 'Use standard tool access'"></span> <small x-text="`(${$store.agentEditor.toolCatalog.length} tools)`"></small></span></label><label><input type="radio" name="tool-policy-mode" :checked="$store.agentEditor.draft.toolPolicy.mode === 'custom'" @change="$store.agentEditor.chooseTools()">Choose tools</label></div>
243 <fieldset class="policy-editor" :disabled="$store.agentEditor.draft.toolPolicy.mode !== 'custom'">
244 <legend class="sr-only">Tool access selection</legend>
245 <div class="policy-filters tools"><label class="policy-search"><x-icon name="search"></x-icon><span class="sr-only">Search tools</span><input type="search" x-model="$store.agentEditor.toolSearch" placeholder="Search tools"></label><label>Category <select x-model="$store.agentEditor.toolCategory"><option value="all">All</option><option value="local">Local</option><option value="plugin">Plugin</option><option value="mcp">MCP</option></select></label><label>Origin <select x-model="$store.agentEditor.toolOrigin"><option value="all">All</option><template x-for="origin in $store.agentEditor.toolOrigins" :key="origin"><option :value="origin" x-text="origin"></option></template></select></label></div>
@@ -237,7 +254,7 @@
254
255 <section x-show="$store.agentEditor.section === '4'" data-agent-editor-section="4" tabindex="-1" aria-labelledby="agent-section-4-title">
256 <header class="advanced-section-heading"><h3 id="agent-section-4-title">Skills</h3><p>Choose which skills this agent can find and use.</p></header>
240 - <div class="policy-mode-row"><label><input type="radio" name="skill-policy-mode" :checked="$store.agentEditor.draft.skillPolicy.mode === 'inherit'" @change="$store.agentEditor.useStandardSkills()"><span>Use standard skill access <small x-text="`(${$store.agentEditor.skillCatalog.length} skills)`"></small></span></label><label><input type="radio" name="skill-policy-mode" :checked="$store.agentEditor.draft.skillPolicy.mode === 'custom'" @change="$store.agentEditor.chooseSkills()">Choose skills</label></div>
257 + <div class="policy-mode-row"><label><input type="radio" name="skill-policy-mode" :checked="$store.agentEditor.draft.skillPolicy.mode === 'inherit'" @change="$store.agentEditor.useStandardSkills()"><span><span x-text="$store.agentEditor.projectName ? 'Use inherited skill access' : 'Use standard skill access'"></span> <small x-text="`(${$store.agentEditor.skillCatalog.length} skills)`"></small></span></label><label><input type="radio" name="skill-policy-mode" :checked="$store.agentEditor.draft.skillPolicy.mode === 'custom'" @change="$store.agentEditor.chooseSkills()">Choose skills</label></div>
258 <fieldset class="policy-editor" :disabled="$store.agentEditor.draft.skillPolicy.mode !== 'custom'">
259 <legend class="sr-only">Skill access selection</legend>
260 <div class="policy-filters skills"><label class="policy-search"><x-icon name="search"></x-icon><span class="sr-only">Search skills</span><input type="search" x-model="$store.agentEditor.skillSearch" placeholder="Search skills"></label><label>Origin <select x-model="$store.agentEditor.skillOrigin"><option value="all">All</option><template x-for="origin in $store.agentEditor.skillOrigins" :key="origin"><option :value="origin" x-text="origin"></option></template></select></label></div>
@@ -266,11 +283,11 @@
283 <template x-if="$store.agentEditor.plan.warnings?.length"><section><h4>Notices</h4><ul><template x-for="warning in $store.agentEditor.plan.warnings" :key="warning"><li x-text="warning"></li></template></ul></section></template>
284 </div>
285 <div class="review-actions" x-show="$store.agentEditor.pendingMutation"><button type="button" class="button danger" @click="$store.agentEditor.applyPendingMutation()"><x-icon name="delete"></x-icon>Apply removal plan</button><button type="button" class="text-button" @click="$store.agentEditor.pendingMutation = null; $store.agentEditor.previewPlan()">Cancel removal</button></div>
269 - <div class="profile-maintenance" x-show="!$store.agentEditor.draft.creating && $store.agentEditor.state.profile.origin !== 'Custom'">
270 - <h4>Built-in or plugin profile</h4><p>Remove only the customizations shown in this editor. Other files stay in place.</p><button type="button" class="button danger" @click="$store.agentEditor.planRemoval(false)"><x-icon name="delete_sweep"></x-icon>Remove my changes</button>
271 - <details><summary>Delete all customizations for this profile</summary><p>This removes every customization saved for this profile after showing each affected file. Agent Zero’s defaults remain untouched.</p><button type="button" class="button danger" @click="$store.agentEditor.planRemoval(true)"><x-icon name="delete_forever"></x-icon>Review files to delete</button></details>
286 + <div class="profile-maintenance" x-show="!$store.agentEditor.draft.creating && $store.agentEditor.state.profile.scope_has_overrides && !$store.agentEditor.state.profile.deletable">
287 + <h4>Customized by you</h4><p x-text="`Remove only the customizations shown in this editor from ${$store.agentEditor.scopeLabel}. Other files stay in place.`"></p><button type="button" class="button danger" @click="$store.agentEditor.planRemoval(false)"><x-icon name="delete_sweep"></x-icon>Remove my changes</button>
288 + <details><summary x-text="`Delete all customizations in ${$store.agentEditor.scopeLabel}`"></summary><p>This removes every customization saved in this scope after showing each affected file. Inherited files remain untouched.</p><button type="button" class="button danger" @click="$store.agentEditor.planRemoval(true)"><x-icon name="delete_forever"></x-icon>Review files to delete</button></details>
289 </div>
273 - <div class="profile-maintenance" x-show="!$store.agentEditor.draft.creating && $store.agentEditor.state.profile.origin === 'Custom'"><h4>Delete custom agent</h4><p>Projects and open chats that use this agent are shown before confirmation, together with its files and settings.</p><button type="button" class="button danger" @click="$store.agentEditor.deleteProfile($store.agentEditor.draft.profileId)">Delete agent</button></div>
290 + <div class="profile-maintenance" x-show="!$store.agentEditor.draft.creating && $store.agentEditor.state.profile.deletable"><h4>Delete custom agent</h4><p>Projects and open chats that use this agent are shown before confirmation, together with its files and settings.</p><button type="button" class="button danger" @click="$store.agentEditor.deleteProfile($store.agentEditor.draft.profileId)">Delete agent</button></div>
291 </section>
292 </div>
293 </div>
@@ -285,8 +302,8 @@
302 </div>
303 <div class="footer-actions">
304 <button type="button" class="btn btn-ok" x-show="$store.agentEditor.view === 'manage'" @click="$store.agentEditor.loadEditor('new-agent', true)">Create agent</button>
288 - <button type="button" class="btn agent-editor-secondary-action" x-show="$store.agentEditor.view === 'editor' && !$store.agentEditor.draft?.creating" @click="$store.agentEditor.save(true)" :disabled="$store.agentEditor.saving || $store.agentEditor.validationIssues().length">Save & test</button>
289 - <button type="button" class="btn btn-ok" x-show="$store.agentEditor.view === 'editor'" @click="$store.agentEditor.save(false)" :disabled="$store.agentEditor.saving || $store.agentEditor.avatarUploading || $store.agentEditor.validationIssues().length" :title="$store.agentEditor.validationIssues().length ? 'Fix the highlighted issues before saving' : ''"><span x-text="$store.agentEditor.saving ? 'Saving…' : $store.agentEditor.draft?.creating ? 'Create agent' : 'Save changes'"></span></button>
305 + <button type="button" class="btn agent-editor-secondary-action" x-show="$store.agentEditor.view === 'editor' && !$store.agentEditor.draft?.creating" @click="$store.agentEditor.save(true)" :disabled="$store.agentEditor.saving || $store.agentEditor.validationIssues().length > 0">Save & test</button>
306 + <button type="button" class="btn btn-ok" x-show="$store.agentEditor.view === 'editor'" @click="$store.agentEditor.save(false)" :disabled="$store.agentEditor.saving || $store.agentEditor.avatarUploading || $store.agentEditor.validationIssues().length > 0" :title="$store.agentEditor.validationIssues().length ? 'Fix the highlighted issues before saving' : ''"><span x-text="$store.agentEditor.saving ? 'Saving…' : $store.agentEditor.draft?.creating ? 'Create agent' : 'Save changes'"></span></button>
307 </div>
308 </div>
309 </div>
@@ -318,6 +335,9 @@
335 .agent-editor-loading { min-height:20rem; display:grid; place-content:center; justify-items:center; gap:.65rem; color:var(--color-text-secondary); }
336 .agent-editor-topbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:1rem; }
337 .agent-editor-heading { display:flex; align-items:center; gap:.6rem; }
338 + .agent-scope-indicator { display:inline-flex; align-items:center; gap:.3rem; min-width:0; color:var(--color-text-secondary); font-size:.78rem; }
339 + .agent-scope-indicator strong { max-width:18rem; overflow:hidden; color:var(--color-text); text-overflow:ellipsis; white-space:nowrap; }
340 + .agent-scope-indicator x-icon { font-size:1rem; }
341 .agent-editor-subtitle { margin-top:.2rem; font-size:.78rem; color:var(--color-text-secondary); }
342 .agent-status-badge { display:inline-flex; align-items:center; gap:.25rem; max-width:100%; padding:.2rem .45rem; border:1px solid var(--color-border); border-radius:999px; font-size:.7rem; font-weight:500; line-height:1.2; white-space:normal; }
343 .agent-status-badge x-icon { flex:0 0 auto; font-size:.85rem; }
@@ -367,7 +387,7 @@
387 .origin-row { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem; }
388 .built-in-note,.field-provenance { color:var(--color-text-secondary); font-size:.76rem; }
389 .agent-origin { padding:.2rem .45rem; border:1px solid var(--color-border); border-radius:999px; font-size:.72rem; color:var(--color-text-secondary); }
370 - .agent-project-notice { padding:.55rem .7rem; border-left:3px solid var(--color-warning); background:color-mix(in srgb,var(--color-warning) 8%,transparent); color:var(--color-text-secondary); font-size:.8rem; }
390 + .agent-editor-note { padding:.55rem .7rem; border-left:3px solid var(--color-warning); background:color-mix(in srgb,var(--color-warning) 8%,transparent); color:var(--color-text-secondary); font-size:.8rem; }
391 .advanced-identity-grid { display:grid; grid-template-columns:9rem minmax(0,1fr); gap:1.2rem; }
392 .advanced-avatar { align-self:start; }
393 .identity-fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.9rem; }
@@ -462,6 +482,15 @@
482 .agent-editor .button.danger { display:inline-flex; align-items:center; gap:.35rem; border-color:color-mix(in srgb,var(--agent-editor-danger) 60%,var(--color-border)); color:var(--agent-editor-danger); background:color-mix(in srgb,var(--agent-editor-danger) 7%,var(--color-panel)); }
483 .agent-editor .button.danger:hover { background:color-mix(in srgb,var(--agent-editor-danger) 15%,var(--color-panel)); }
484 .agent-manager { display:flex; flex-direction:column; gap:1rem; }
485 + .agent-scope-selector { overflow:hidden; border:1px solid var(--color-border); border-radius:4px; }
486 + .agent-scope-header { padding:.75rem 1rem; border-bottom:1px solid var(--color-border); background:var(--color-bg-secondary); }
487 + .agent-scope-copy { min-width:0; }
488 + .agent-scope-copy strong { color:var(--color-text-primary); font-size:var(--font-size-normal); font-weight:600; }
489 + .agent-scope-copy p { margin-top:.25rem; color:var(--color-text-secondary); font-size:var(--font-size-small); }
490 + .agent-scope-toolbar { display:flex; padding:1rem; }
491 + .agent-scope-field { display:flex; flex:1 1 0; align-items:center; gap:.5rem; min-width:12rem; margin:0; }
492 + .agent-scope-field span { color:var(--color-text-secondary); font-weight:600; white-space:nowrap; }
493 + .agent-scope-field select { flex:1 1 auto; min-width:0; }
494 .agent-manager-intro,.agent-manager-copy p { color:var(--color-text-secondary); font-size:.82rem; }
495 .agent-manager-list { display:flex; flex-direction:column; gap:.55rem; }
496 .agent-manager-card { display:grid; grid-template-columns:3rem minmax(0,1fr) auto; gap:.75rem; align-items:center; padding:.75rem; border:1px solid var(--color-border); border-radius:10px; }
@@ -499,6 +528,7 @@
528 .policy-list { min-height:20rem; }
529 .agent-manager-card { grid-template-columns:3rem minmax(0,1fr); }
530 .agent-manager-actions { grid-column:1/-1; justify-content:flex-end; }
531 + .agent-scope-field { flex-basis:100%; min-width:0; }
532 .agent-editor-footer { flex-wrap:wrap; }
533 .footer-actions { width:100%; }
534 .footer-actions .btn { flex:1; }
tests/test_agent_editor.py
+139 -22
@@ -10,6 +10,7 @@ from werkzeug.datastructures import FileStorage
10
11 from helpers import yaml as yaml_helper
12 from plugins._agent_editor.api.agent_editor import AgentEditor
13 +from plugins._agent_editor.api.agent_editor import _context as editor_context
14 from plugins._agent_editor.api.agent_editor_avatar import AgentEditorAvatar
15 from plugins._agent_editor.helpers import editor
16
@@ -46,6 +47,34 @@ def user_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
47 return root
48
49
50 +@pytest.fixture
51 +def project_scope(
52 + user_root: Path,
53 + tmp_path: Path,
54 + monkeypatch: pytest.MonkeyPatch,
55 +) -> tuple[editor._EditorContext, Path]:
56 + project_folder = tmp_path / "usr" / "projects" / "demo"
57 + project_meta = project_folder / ".a0proj"
58 + project_meta.mkdir(parents=True)
59 + real_folder = editor.projects.get_project_folder
60 + real_meta = editor.projects.get_project_meta
61 + monkeypatch.setattr(
62 + editor.projects,
63 + "get_project_folder",
64 + lambda name: str(project_folder) if name == "demo" else real_folder(name),
65 + )
66 + monkeypatch.setattr(
67 + editor.projects,
68 + "get_project_meta",
69 + lambda name, *parts: (
70 + str(project_meta.joinpath(*parts))
71 + if name == "demo"
72 + else real_meta(name, *parts)
73 + ),
74 + )
75 + return editor._EditorContext("demo"), project_meta / "agents"
76 +
77 +
78 def _write_manual_files(root: Path) -> dict[Path, bytes]:
79 manual_files = {
80 root / "prompts" / "manual.md": b"prompt",
@@ -339,25 +368,20 @@ def test_model_and_off_tool_choices_write_only_their_json_contracts(
368 }
369
370
342 -def test_project_tool_policy_is_visible_but_editor_plan_stays_in_user_profile(
343 - user_root: Path,
371 +def test_project_tool_policy_reads_effective_access_and_writes_project_scope(
372 + project_scope: tuple[editor._EditorContext, Path],
373 monkeypatch: pytest.MonkeyPatch,
374 ) -> None:
346 - real_find = editor.plugins.find_plugin_asset
347 -
348 - def find_plugin_asset(plugin_name, *parts, **scope):
349 - if plugin_name == editor.tool_policy.PLUGIN_NAME:
350 - return {
351 - "path": "/project/.a0proj/plugins/_tool_access/config.json",
352 - "project_name": "demo",
353 - "agent_profile": "",
354 - }
355 - return real_find(plugin_name, *parts, **scope)
356 -
357 - monkeypatch.setattr(editor.plugins, "find_plugin_asset", find_plugin_asset)
375 monkeypatch.setattr(editor.tool_policy, "get_tool_catalog", lambda _agent: [])
376 + effective_policy = {
377 + "mode": "custom",
378 + "default": "allow",
379 + "allowed": [],
380 + "blocked": ["local:shell"],
381 + }
382 + monkeypatch.setattr(editor.tool_policy, "get_policy", lambda _agent: effective_policy)
383 monkeypatch.setattr(editor.skills, "list_skill_catalog", lambda agent=None: [])
360 - context = editor._EditorContext("demo")
384 + context, project_agents = project_scope
385
386 state = editor.build_editor_state("researcher", context)
387 plan = editor.build_change_plan(
@@ -368,12 +392,106 @@ def test_project_tool_policy_is_visible_but_editor_plan_stays_in_user_profile(
392 context,
393 )
394
371 - assert state["tools"]["project_override_active"] is True
395 + assert state["tools"]["has_override"] is False
396 + assert state["tools"]["effective_policy"] == effective_policy
397 assert list(plan.changes) == [
373 - user_root / "researcher" / "plugins" / "_tool_access" / "config.json"
398 + project_agents
399 + / "researcher"
400 + / "plugins"
401 + / "_tool_access"
402 + / "config.json"
403 ]
404
405
406 +def test_project_agents_are_scope_owned_and_never_leak_global_writes(
407 + user_root: Path,
408 + project_scope: tuple[editor._EditorContext, Path],
409 +) -> None:
410 + context, project_agents = project_scope
411 + profile_id = "project-helper"
412 + plan = editor.build_change_plan(
413 + {
414 + "profile_id": profile_id,
415 + "creating": True,
416 + "editor_mode": "easy",
417 + "metadata": {"set": {"title": "Project Helper"}, "reset": []},
418 + "prompts": {
419 + "set": {editor.SPECIFICS_FILE: "Help only this project."},
420 + "reset": [],
421 + },
422 + },
423 + context,
424 + )
425 +
426 + assert plan.project_name == "demo"
427 + assert all(path.is_relative_to(project_agents / profile_id) for path in plan.changes)
428 + assert not (user_root / profile_id).exists()
429 + editor.apply_change_plan(plan)
430 +
431 + state = editor.build_profile_state(profile_id, context)
432 + assert state["scope_has_overrides"] is True
433 + assert state["deletable"] is True
434 + assert any(item["id"] == profile_id for item in editor.list_profiles(context))
435 + assert all(item["id"] != profile_id for item in editor.list_profiles())
436 +
437 + delete = editor.plan_delete_custom(profile_id, context)
438 + assert delete.project_name == "demo"
439 + editor.apply_change_plan(delete)
440 + assert not (project_agents / profile_id).exists()
441 +
442 +
443 +def test_project_customizations_inherit_global_agent_and_remove_only_project_files(
444 + user_root: Path,
445 + project_scope: tuple[editor._EditorContext, Path],
446 +) -> None:
447 + context, project_agents = project_scope
448 + global_profile = user_root / "shared-helper"
449 + global_profile.mkdir(parents=True)
450 + (global_profile / "agent.yaml").write_text(
451 + "title: Shared Helper\ndescription: Global description\n",
452 + encoding="utf-8",
453 + )
454 +
455 + inherited = editor.build_profile_state("shared-helper", context)
456 + assert inherited["metadata"]["description"]["effective"] == "Global description"
457 + assert inherited["scope_has_overrides"] is False
458 + assert inherited["deletable"] is False
459 + with pytest.raises(ValueError, match="created in this scope"):
460 + editor.plan_delete_custom("shared-helper", context)
461 +
462 + plan = editor.build_change_plan(
463 + {
464 + "profile_id": "shared-helper",
465 + "metadata": {"set": {"description": "Project description"}, "reset": []},
466 + },
467 + context,
468 + )
469 + project_yaml = project_agents / "shared-helper" / "agent.yaml"
470 + assert list(plan.changes) == [project_yaml]
471 + editor.apply_change_plan(plan)
472 + assert editor.build_profile_state("shared-helper", context)["deletable"] is False
473 +
474 + reset = editor.plan_remove_changes("shared-helper", context)
475 + assert list(reset.changes) == [project_yaml]
476 + editor.apply_change_plan(reset)
477 + assert yaml_helper.loads((global_profile / "agent.yaml").read_text())["description"] == "Global description"
478 +
479 +
480 +def test_project_scope_is_validated_at_api_and_apply_boundaries(
481 + user_root: Path,
482 + project_scope: tuple[editor._EditorContext, Path],
483 +) -> None:
484 + context, _project_agents = project_scope
485 + assert editor.projects.get_context_project_name(editor_context({"project_name": "demo"})) == "demo"
486 + with pytest.raises(ValueError, match="Project not found"):
487 + editor_context({"project_name": "missing-agent-editor-project"})
488 +
489 + forged = editor.ChangePlan(profile_id="researcher", project_name="demo")
490 + forged.write(user_root / "researcher" / "agent.yaml", "title: Wrong scope\n")
491 + with pytest.raises(ValueError, match="outside the selected profile directory"):
492 + editor.apply_change_plan(forged)
493 +
494 +
495 def test_unavailable_skill_policy_ids_are_retained_in_editor_state(
496 user_root: Path,
497 monkeypatch: pytest.MonkeyPatch,
@@ -425,7 +543,7 @@ def test_unavailable_skill_policy_ids_are_retained_in_editor_state(
543 def test_display_title_change_keeps_profile_id_and_builtin_delete_is_rejected(
544 user_root: Path,
545 ) -> None:
428 - with pytest.raises(ValueError, match="cannot be deleted"):
546 + with pytest.raises(ValueError, match="created in this scope"):
547 editor.plan_delete_custom("researcher")
548
549 plan = editor.build_change_plan(
@@ -584,7 +702,7 @@ def test_mixed_save_matches_plan_preserves_every_unrelated_family_and_refreshes_
702 assert all(path.read_bytes() == payload for path, payload in manual_files.items())
703
704
587 -def test_empty_prompt_override_and_project_precedence_are_distinct(
705 +def test_empty_prompt_override_and_selected_project_scope_are_distinct(
706 user_root: Path,
707 tmp_path: Path,
708 monkeypatch: pytest.MonkeyPatch,
@@ -637,10 +755,9 @@ def test_empty_prompt_override_and_project_precedence_are_distinct(
755 assert empty["state"] == "Overridden here (empty)"
756 assert empty["has_override"] is True
757 assert empty["effective"] == ""
640 - assert project["state"] == "Project override active"
641 - assert project["project_override_active"] is True
758 + assert project["state"] == "Overridden here"
759 assert project["effective"] == "project"
643 - assert project["source_chain"][-2:] == ["Your override", "Project · project"]
760 + assert project["source_chain"][-2:] == ["Global", "Your override"]
761
762 reset = editor.build_change_plan(
763 {
tests/test_agent_editor_webui.py
+38 -8
@@ -93,7 +93,16 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
93 assert tool_section.count('class="policy-item-description"') == 2
94 assert skill_section.count('class="policy-item-description"') == 2
95 assert "Your changes override the built-in profile. The original files stay unchanged." in modal
96 - assert "This project has different tool settings." in modal
96 + assert 'x-model="$store.agentEditor.projectName"' in modal
97 + assert 'x-init="$nextTick(() => $el.value = $store.agentEditor.projectName)"' in modal
98 + assert '@change="$store.agentEditor.onScopeChanged()"' in modal
99 + assert '<option value="">Global</option>' in modal
100 + assert 'x-for="project in $store.agentEditor.projects"' in modal
101 + assert 'x-show="profile.deletable"' in modal
102 + assert 'x-show="profile.scope_has_overrides"' in modal
103 + assert "project_override_active" not in modal
104 + assert "profile.origin === 'Custom'" not in modal
105 + assert "Scope:" in modal
106 assert "Unavailable — kept in your settings" in modal
107 assert "Customize this file" not in modal
108 assert 'role="tablist" aria-label="Prompt view"' in modal
@@ -118,11 +127,12 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
127 assert 'class="prompt-editor" role="region" aria-label="Selected prompt file" tabindex="0"' in modal
128 assert 'width:1.5rem; height:1.5rem' in modal
129 assert "moveAllVisibleTools(false)" in modal and "moveAllVisibleSkills(false)" in modal
121 - assert "!$store.agentEditor.draft.creating && $store.agentEditor.dirty" in modal
130 + assert 'class="agent-editor-heading"' in modal
131 assert ':aria-invalid=' in modal
132 assert modal.count('role="alert"') >= 4
133 assert "Fix ${$store.agentEditor.validationIssues().length}" in modal
125 - assert "Delete all customizations for this profile" in modal
134 + assert modal.count("$store.agentEditor.validationIssues().length > 0") == 2
135 + assert "Delete all customizations in" in modal
136 assert 'input[type="checkbox"]' in modal and "appearance:none" in modal
137 assert 'promptDisplayState(prompt)' in modal
138 assert 'promptSourceChain($store.agentEditor.selectedPromptDraft)' in modal
@@ -240,8 +250,8 @@ store.state = {
250 { filename: "agent.system.main.communication.md", group: "2.4", group_label: "Communication", effective: "Inherited comm", inherited: "Inherited comm", source_chain: ["Framework", "Researcher"], state: "Inherited", has_override: false },
251 ],
252 model_preset: { has_override: false },
243 - tools: { policy: { mode: "inherit" }, has_override: false, catalog: [] },
244 - skills: { policy: { mode: "inherit" }, has_override: false, catalog: [] },
253 + tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [] },
254 + skills: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [] },
255 };
256 store.makeDraft(true);
257 if (await store.previewPlan()) throw new Error("invalid plan unexpectedly succeeded");
@@ -283,6 +293,20 @@ if (store.isToolAllowed(store.state.tools.catalog[0]) || store.draft.toolPolicy.
293 store.useStandardTools();
294 store.chooseTools();
295 if (store.draft.toolPolicy.mode !== "custom" || store.draft.toolPolicy.default !== "allow" || store.section !== "3") throw new Error("custom tool editor did not open");
296 +store.useStandardTools();
297 +store.state.tools.effective_policy = { mode: "inherit", default: "block", allowed: [], blocked: ["local:shell"] };
298 +store.chooseTools();
299 +if (store.draft.toolPolicy.default !== "allow" || store.draft.toolPolicy.blocked.length) throw new Error("inactive inherited exceptions leaked into custom policy");
300 +store.projectName = "demo";
301 +store.state.tools.effective_policy = { mode: "custom", default: "allow", allowed: [], blocked: ["local:shell"] };
302 +store.useStandardTools();
303 +if (store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("project scope ignored inherited tool restriction");
304 +store.setEasyToolAllowed("local:shell", true);
305 +if (store.draft.toolPolicy.mode !== "custom" || !store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("project scope did not customize inherited policy");
306 +store.setEasyToolAllowed("local:shell", false);
307 +if (store.draft.toolPolicy.mode !== "inherit" || store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("project scope did not restore inherited policy");
308 +store.projectName = "";
309 +store.state.tools.effective_policy = { mode: "inherit", default: "allow", allowed: [], blocked: [] };
310 store.state.skills.catalog = [
311 { name: "Research", path: "skills/research/SKILL.md", origin: "Agent Zero", description: "Research sources", available: true, tags: [], allowed_tools: [] },
312 { name: "Gone", path: "skills/gone/SKILL.md", origin: "Unavailable", description: "Missing skill", available: false, tags: [], allowed_tools: [] },
@@ -338,14 +362,20 @@ calls.length = 0;
362 store.intent = { contextId: "source-chat" };
363 await store.openFreshChat("researcher", true);
364 const endpoints = calls.map((item) => item.endpoint);
341 -const expected = ["/chat_create", "/agent_profile_set", "/plugins/_model_config/model_override", "selectChat", "event"];
365 +const expected = ["/chat_create", "/projects", "/agent_profile_set", "/plugins/_model_config/model_override", "selectChat", "event"];
366 if (JSON.stringify(endpoints) !== JSON.stringify(expected)) throw new Error(JSON.stringify(calls));
343 -if (calls[1].payload.agent_profile !== "researcher") throw new Error("profile not selected");
344 -if (calls[2].payload.action !== "clear") throw new Error("chat preset override not cleared");
367 +if (calls[1].payload.action !== "deactivate") throw new Error("global test chat kept a project");
368 +if (calls[2].payload.agent_profile !== "researcher") throw new Error("profile not selected");
369 +if (calls[3].payload.action !== "clear") throw new Error("chat preset override not cleared");
370 if (store.readyNoteContext !== "fresh-chat") throw new Error("ready note missing");
371 +calls.length = 0;
372 +store.projectName = "demo";
373 +await store.openFreshChat("researcher", false);
374 +if (calls[1].endpoint !== "/projects" || calls[1].payload.action !== "activate" || calls[1].payload.name !== "demo") throw new Error("project test chat did not activate selected scope");
375 await store.planRemoval(true);
376 if (!store.pendingMutation?.destructive || store.section !== "5" || store.planStatus !== "ready") throw new Error("removal plan was replaced");
377 if (calls.at(-1).payload.action !== "plan_remove_changes") throw new Error("removal plan request missing");
378 +if (calls.at(-1).payload.project_name !== "demo") throw new Error("removal request lost selected scope");
379 store.plan = { written: ["usr/agents/researcher/agent.yaml"], deleted: ["usr/agents/researcher/prompts/old.md"], warnings: [] };
380 await store.applyPendingMutation();
381 if (confirmations.length !== 1 || confirmations[0].type !== "danger") throw new Error("danger confirmation missing");