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");