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