Make agent profile overrides presence-aware

Merge layered profile metadata by authored field presence so missing keys inherit while explicit empty values clear inherited values. Move the default specifics prompt to the canonical prompts directory and cover sparse metadata and prompt-only overrides.

Alessandro committed Aug 5, 2026 at 10:52 UTC 641df2286818f7771920a31aadb35085f2e0eaf8
6 files changed +160 -65
agents/default/AGENTS.md
+1 -1
@@ -8,7 +8,7 @@
8 ## Ownership
9
10 - `agent.yaml` owns default profile metadata.
11 -- `agent.system.main.specifics.md` owns default profile-specific system prompt content.
11 +- `prompts/agent.system.main.specifics.md` owns default profile-specific system prompt content.
12 - Additional prompt overrides under this directory become shared defaults unless a child profile overrides them.
13
14 ## Local Contracts
agents/default/prompts/agent.system.main.specifics.md renamed
helpers/subagents.py
+54 -60
@@ -28,11 +28,12 @@ class SubAgentListItem(BaseModel):
28 path: str = ""
29 origin: list[Origin] = []
30 enabled: bool = True
31 + avatar: dict[str, str] | None = None
32
33 @model_validator(mode="after")
34 def post_validator(self):
34 - if self.title == "":
35 - self.title = self.name
35 + if "title" not in self.model_fields_set and self.name:
36 + object.__setattr__(self, "title", self.name)
37 return self
38
39
@@ -55,7 +56,7 @@ def get_agents_dict(
56 for name, override in overrides.items():
57 base_agent = merged.get(name)
58 merged[name] = (
58 - _merge_agent_list_items(base_agent, override)
59 + _merge_agent_list_item(base_agent, override)
60 if base_agent
61 else override
62 )
@@ -92,15 +93,15 @@ def _get_agents_list_from_dir(dir: str, origin: Origin) -> dict[str, SubAgentLis
93
94 for subdir in subdirs:
95 try:
95 - agent_yaml_path = files.get_abs_path(dir, subdir, "agent.yaml")
96 - if files.exists(agent_yaml_path):
97 - agent_yaml = files.read_file(agent_yaml_path)
98 - agent_data = SubAgentListItem.model_validate(yaml_helper.loads(agent_yaml) or {})
99 - else:
100 - agent_json = files.read_file(files.get_abs_path(dir, subdir, "agent.json"))
101 - agent_data = SubAgentListItem.model_validate_json(agent_json)
96 + try:
97 + raw = _read_agent_definition(dir, subdir)
98 + except FileNotFoundError:
99 + raw = {}
100 + agent_data = SubAgentListItem.model_validate(raw)
101 name = agent_data.name or subdir
102 agent_data.name = name
103 + if "title" not in agent_data.model_fields_set:
104 + object.__setattr__(agent_data, "title", name)
105 agent_data.path = files.get_abs_path(dir, subdir)
106 agent_data.origin = [origin]
107 result[name] = agent_data
@@ -111,15 +112,6 @@ def _get_agents_list_from_dir(dir: str, origin: Origin) -> dict[str, SubAgentLis
112
113
114 def load_agent_data(name: str, project_name: str | None = None) -> SubAgent:
114 - def _merge_agent(
115 - original: SubAgent | None, override: SubAgent | None = None
116 - ) -> SubAgent | None:
117 - if original and override:
118 - return _merge_agents(original, override)
119 - elif original:
120 - return original
121 - return override
122 -
115 from helpers import plugins
116
117 # load default, plugin, and user agents and merge
@@ -184,31 +176,26 @@ def delete_agent_data(name: str) -> None:
176
177
178 def _load_agent_data_from_dir(dir: str, name: str, origin: Origin) -> SubAgent | None:
179 + agent_dir = files.get_abs_path(dir, name)
180 + if not os.path.isdir(agent_dir):
181 + return None
182 +
183 try:
188 - agent_yaml_path = files.get_abs_path(dir, name, "agent.yaml")
189 - if files.exists(agent_yaml_path):
190 - agent_yaml = files.read_file(agent_yaml_path)
191 - subagent = SubAgent.model_validate(yaml_helper.loads(agent_yaml) or {})
192 - else:
193 - subagent_json = files.read_file(files.get_abs_path(dir, name, "agent.json"))
194 - subagent = SubAgent.model_validate_json(subagent_json)
184 + subagent = SubAgent.model_validate(_read_agent_definition(dir, name))
185 except Exception:
186 # backward compatibility (before agent.json existed)
187 try:
198 - context_file = files.read_file(files.get_abs_path(dir, name, "_context.md"))
188 + subagent = SubAgent(
189 + context=files.read_file(files.get_abs_path(dir, name, "_context.md"))
190 + )
191 except Exception:
200 - context_file = ""
201 - subagent = SubAgent(
202 - name=name,
203 - title=name,
204 - description="",
205 - context=context_file,
206 - origin=[origin],
207 - prompts={},
208 - )
192 + subagent = SubAgent()
193
194 # non-stored fields
195 subagent.name = name
196 + if "title" not in subagent.model_fields_set:
197 + object.__setattr__(subagent, "title", name)
198 + subagent.path = agent_dir
199 subagent.origin = [origin]
200
201 prompts_dir = f"{dir}/{name}/prompts"
@@ -221,37 +208,48 @@ def _load_agent_data_from_dir(dir: str, name: str, origin: Origin) -> SubAgent |
208 return subagent
209
210
224 -def _merge_agents(base: SubAgent | None, override: SubAgent | None) -> SubAgent | None:
211 +def _read_agent_definition(dir: str, name: str) -> dict:
212 + yaml_path = files.get_abs_path(dir, name, "agent.yaml")
213 + if files.exists(yaml_path):
214 + return yaml_helper.loads(files.read_file(yaml_path)) or {}
215 + json_path = files.get_abs_path(dir, name, "agent.json")
216 + if files.exists(json_path):
217 + return json.loads(files.read_file(json_path)) or {}
218 + raise FileNotFoundError
219 +
220 +
221 +def _merge_agent(base: SubAgent | None, override: SubAgent | None) -> SubAgent | None:
222 if base is None:
223 return override
224 if override is None:
225 return base
226
230 - merged_prompts: dict[str, str] = {}
231 - merged_prompts.update(base.prompts or {})
232 - merged_prompts.update(override.prompts or {})
233 -
234 - return SubAgent(
235 - name=override.name,
236 - title=override.title,
237 - description=override.description,
238 - context=override.context,
239 - origin=_merge_origins(base.origin, override.origin),
240 - prompts=merged_prompts,
241 - )
227 + data = _merge_agent_metadata(base, override)
228 + data["prompts"] = {**(base.prompts or {}), **(override.prompts or {})}
229 + return SubAgent.model_validate(data)
230
231
244 -def _merge_agent_list_items(
232 +def _merge_agent_list_item(
233 base: SubAgentListItem, override: SubAgentListItem
234 ) -> SubAgentListItem:
247 - return SubAgentListItem(
235 + return SubAgentListItem.model_validate(_merge_agent_metadata(base, override))
236 +
237 +
238 +def _merge_agent_metadata(
239 + base: SubAgentListItem, override: SubAgentListItem
240 +) -> dict:
241 + data = base.model_dump()
242 + data.update(
243 + override.model_dump(
244 + exclude_unset=True, exclude={"name", "path", "origin", "prompts"}
245 + )
246 + )
247 + data.update(
248 name=override.name or base.name,
249 - title=override.title or base.title,
250 - description=override.description or base.description,
251 - context=override.context or base.context,
249 path=override.path or base.path,
253 - origin=_merge_origins(base.origin, override.origin),
250 + origin=[*base.origin, *override.origin],
251 )
252 + return data
253
254
255 def get_agents_roots() -> list[str]:
@@ -296,7 +294,7 @@ def get_all_agents_list() -> list[dict[str, str]]:
294 items = _get_agents_list_from_dir(root, origin=origin)
295 for name, item in items.items():
296 if name in merged:
299 - merged[name] = _merge_agent_list_items(merged[name], item)
297 + merged[name] = _merge_agent_list_item(merged[name], item)
298 else:
299 merged[name] = item
300
@@ -307,10 +305,6 @@ def get_all_agents_list() -> list[dict[str, str]]:
305 return result
306
307
310 -def _merge_origins(base: list[Origin], override: list[Origin]) -> list[Origin]:
311 - return base + override
312 -
313 -
308 def get_default_promp_file_names() -> list[str]:
309 return files.list_files("prompts", filter="*.md")
310
helpers/subagents.py.dox.md
+12 -3
@@ -14,6 +14,8 @@
14 - `SubAgentListItem` (`BaseModel`)
15 - `post_validator(self)`
16 - `SubAgent` (`SubAgentListItem`)
17 +- Profile metadata includes optional avatar metadata and merges by key presence:
18 + a missing key inherits, while a present empty value explicitly clears it.
19 - Top-level functions:
20 - `get_agents_list(project_name: str | None=...) -> list[SubAgentListItem]`
21 - `get_agents_dict(project_name: str | None=...) -> dict[str, SubAgentListItem]`
@@ -22,11 +24,12 @@
24 - `save_agent_data(name: str, subagent: SubAgent) -> None`
25 - `delete_agent_data(name: str) -> None`
26 - `_load_agent_data_from_dir(dir: str, name: str, origin: Origin) -> SubAgent | None`
25 -- `_merge_agents(base: SubAgent | None, override: SubAgent | None) -> SubAgent | None`
26 -- `_merge_agent_list_items(base: SubAgentListItem, override: SubAgentListItem) -> SubAgentListItem`
27 +- `_read_agent_definition(dir: str, name: str) -> dict`
28 +- `_merge_agent(base: SubAgent | None, override: SubAgent | None) -> SubAgent | None`
29 +- `_merge_agent_list_item(base: SubAgentListItem, override: SubAgentListItem) -> SubAgentListItem`
30 +- `_merge_agent_metadata(base: SubAgentListItem, override: SubAgentListItem) -> dict`
31 - `get_agents_roots() -> list[str]`
32 - `get_all_agents_list() -> list[dict[str, str]]`
29 -- `_merge_origins(base: list[Origin], override: list[Origin]) -> list[Origin]`
33 - `get_default_promp_file_names() -> list[str]`
34 - `get_available_agents_dict(project_name: str | None) -> dict[str, SubAgentListItem]`
35 - `get_paths(agent: 'Agent|None', *subpaths, must_exist_completely: bool=..., include_project: bool=..., include_user: bool=..., include_default: bool=..., include_plugins: bool=..., default_root: str=...) -> list[str]`: Returns list of file paths for the given agent and subpaths, searched in order of priority:
@@ -37,10 +40,15 @@
40 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
41 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
42 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence.
43 +- Nonexistent profile layers return `None` instead of synthesizing empty overrides.
44 - Imported dependency areas include: `helpers`, `json`, `os`, `pydantic`, `typing`.
45
46 ## Key Concepts
47
48 +- Presence-aware merging uses Pydantic's `model_dump(exclude_unset=True)` so
49 + missing keys inherit while explicitly empty values remain overrides. Runtime
50 + `name`, `path`, `origin`, and `prompts` fields are handled separately; derived
51 + title fallbacks do not become authored overrides.
52 - Important called helpers/classes observed in the source: `cache.toggle_area`, `model_validator`, `_get_agents_list_from_dir`, `plugins.get_enabled_plugin_paths`, `_merge_agent_dicts`, `files.get_subdirectories`, `_load_agent_data_from_dir`, `_merge_agent`, `files.write_file`, `files.delete_dir`, `SubAgent`, `SubAgentListItem`, `files.find_existing_paths_by_pattern`, `get_agents_roots`, `files.list_files`, `get_agents_dict`, `cache.determine_cache_key`, `cache.add`, `projects.get_project_meta`, `FileNotFoundError`.
53 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
54
@@ -55,6 +63,7 @@
63 - Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
64 - Related tests observed by source search:
65 - `tests/test_skills_runtime.py`
66 + - `tests/test_subagent_metadata_merge.py`
67
68 ## Child DOX Index
69
skills/a0-create-agent/SKILL.md
+1 -1
@@ -265,7 +265,7 @@ Profiles inherit all prompts from `/a0/prompts/` and from `/a0/agents/default/`.
265
266 ### The canonical override: `agent.system.main.specifics.md`
267
268 -This is the designated extension slot for profile-specific role, identity, and behavior instructions. The file ships **empty** in both `/a0/prompts/agent.system.main.specifics.md` and `/a0/agents/default/agent.system.main.specifics.md` precisely so profiles can fill it in without fighting the base prompt. It is included from `agent.system.main.md` right after `agent.system.main.role.md`, so whatever you put here layers on top of the inherited role.
268 +This is the designated extension slot for profile-specific role, identity, and behavior instructions. The file ships **empty** in both `/a0/prompts/agent.system.main.specifics.md` and `/a0/agents/default/prompts/agent.system.main.specifics.md` precisely so profiles can fill it in without fighting the base prompt. It is included from `agent.system.main.md` right after `agent.system.main.role.md`, so whatever you put here layers on top of the inherited role.
269
270 **Every shipped profile in `/a0/agents/` overrides this file** — a good sanity check that this is the right place for your specialization. Look at the existing profiles for concrete shape:
271
tests/test_subagent_metadata_merge.py new
+92
@@ -0,0 +1,92 @@
1 +from pathlib import Path
2 +
3 +from helpers import subagents
4 +
5 +
6 +def _write_profile(root: Path, name: str, metadata: str = "") -> Path:
7 + profile = root / name
8 + profile.mkdir(parents=True)
9 + if metadata:
10 + (profile / "agent.yaml").write_text(metadata, encoding="utf-8")
11 + return profile
12 +
13 +
14 +def test_missing_metadata_inherits_and_present_empty_clears(tmp_path: Path) -> None:
15 + bundled_root = tmp_path / "agents"
16 + user_root = tmp_path / "usr-agents"
17 + _write_profile(
18 + bundled_root,
19 + "researcher",
20 + "title: Researcher\ndescription: Source heavy\ncontext: Delegate research\n",
21 + )
22 + _write_profile(user_root, "researcher", "description: ''\ncontext: ''\n")
23 +
24 + bundled = subagents._load_agent_data_from_dir(
25 + str(bundled_root), "researcher", "default"
26 + )
27 + user = subagents._load_agent_data_from_dir(
28 + str(user_root), "researcher", "user"
29 + )
30 + merged = subagents._merge_agent(bundled, user)
31 +
32 + assert merged is not None
33 + assert merged.title == "Researcher"
34 + assert merged.description == ""
35 + assert merged.context == ""
36 + assert merged.origin == ["default", "user"]
37 +
38 +
39 +def test_prompt_only_override_does_not_clear_metadata(tmp_path: Path) -> None:
40 + bundled_root = tmp_path / "agents"
41 + user_root = tmp_path / "usr-agents"
42 + _write_profile(
43 + bundled_root,
44 + "researcher",
45 + "title: Researcher\ndescription: Source heavy\ncontext: Delegate research\n",
46 + )
47 + profile = _write_profile(user_root, "researcher")
48 + prompts = profile / "prompts"
49 + prompts.mkdir()
50 + (prompts / "agent.system.main.specifics.md").write_text(
51 + "Only this changes.\n", encoding="utf-8"
52 + )
53 +
54 + merged = subagents._merge_agent(
55 + subagents._load_agent_data_from_dir(
56 + str(bundled_root), "researcher", "default"
57 + ),
58 + subagents._load_agent_data_from_dir(
59 + str(user_root), "researcher", "user"
60 + ),
61 + )
62 +
63 + assert merged is not None
64 + assert merged.title == "Researcher"
65 + assert merged.description == "Source heavy"
66 + assert merged.context == "Delegate research"
67 + assert merged.prompts == {
68 + "agent.system.main.specifics.md": "Only this changes.\n"
69 + }
70 +
71 +
72 +def test_nonexistent_layer_is_not_an_override(tmp_path: Path) -> None:
73 + assert (
74 + subagents._load_agent_data_from_dir(str(tmp_path), "missing", "user")
75 + is None
76 + )
77 +
78 +
79 +def test_default_specifics_uses_only_the_canonical_prompt_path() -> None:
80 + root = Path(__file__).resolve().parents[1]
81 + legacy = root / "agents" / "default" / "agent.system.main.specifics.md"
82 + canonical = (
83 + root
84 + / "agents"
85 + / "default"
86 + / "prompts"
87 + / "agent.system.main.specifics.md"
88 + )
89 +
90 + assert not legacy.exists()
91 + assert canonical.is_file()
92 + assert canonical.read_bytes() == b""