Fix plugin precedence in subordinate profile settings

The profile-settings hook merged a priority-first lookup list with
last-value-wins updates, allowing bundled settings to overwrite community
plugins and higher user/project scopes.

Merge plugin-wide settings, bundled profile, plugin profiles, user profile,
project settings and project profile from low to high priority. Use the
existing stable root-priority sort within each plugin layer, preserving
same-root discovery order, activation scope and cached lookup lists.

Add a failing-before/passing-after regression for both plugin layers,
scoped overrides, fallback, malformed settings and profile/MCP inheritance.
Strengthen metadata override tests with community plugins enabled and
update the agent-init contract.

Validation: 207 focused local tests and 18 precedence tests in the Docker
framework runtime passed. Two isolated fresh-context initialization smoke
checks passed in Docker. git diff --check passed.
This commit is contained in:
Alessandro 2026-10-08 16:42:58 +02:00
parent a4e65d4721
commit fe24597a6a
3 changed files with 100 additions and 2 deletions

View file

@ -12,6 +12,7 @@
- Keep initialization idempotent for contexts that may be restored or reloaded.
- Preserve ordering between initial message creation and profile settings loading.
- `_15_load_profile_settings.py` merges settings from low to high priority: plugin-wide files, bundled profile, plugin profiles, user profile, project-wide file, then project profile. Each plugin layer applies bundled roots before user roots with a stable root-priority sort, preserving same-root discovery order and cached lookup lists. Global framework and `usr/settings.json` files remain excluded; malformed layers log an error and leave other layers usable. Profile and MCP configuration inherit when not explicitly overridden.
- Keep a placeholder user turn (`fw.initial_user_message.md`) ahead of the AI greeting (`fw.initial_message.md`) so `output_langchain` never pops the greeting as a leading `AIMessage`; do not remove either prompt without replacing the turn-order guarantee.
- Minify the initial AI message JSON before storage; preserve raw text as the displayed greeting when JSON parsing fails.
- `_10_initial_message.py` passes `LLMResult.non_llm()` to `hist_add_ai_response` so the Responses-API state seam runs uniformly; the sentinel carries no `response_id` and marks `mode=""`/`state="off"` so stored metadata does not claim a Responses-API turn.
@ -24,6 +25,7 @@
## Verification
- Smoke-test new chat/context initialization after changes.
- Run `tests/test_plugin_asset_precedence.py` and `tests/test_subagent_profiles.py` for profile-settings changes.
## Child DOX Index

View file

@ -1,5 +1,5 @@
from initialize import initialize_agent
from helpers import dirty_json, files, subagents, projects
from helpers import dirty_json, files, subagents, plugins
from helpers.extension import Extension
@ -10,7 +10,25 @@ class LoadProfileSettings(Extension):
if not self.agent or not self.agent.config.profile:
return
config_files = subagents.get_paths(self.agent, "settings.json", include_default=False, include_user=False)
profile = self.agent.config.profile
scoped_files = subagents.get_paths(
self.agent, "settings.json", include_default=False,
include_user=False, include_plugins=False,
)
default_settings = files.get_abs_path(subagents.DEFAULT_AGENTS_DIR, profile, "settings.json")
# Merge low-priority layers first, keeping discovery order within plugin roots.
config_files = [
*sorted(
plugins.get_enabled_plugin_paths(self.agent, "settings.json"),
key=plugins.get_plugin_root_priority, reverse=True,
),
default_settings,
*sorted(
plugins.get_enabled_plugin_paths(self.agent, "agents", profile, "settings.json"),
key=plugins.get_plugin_root_priority, reverse=True,
),
*(path for path in reversed(scoped_files) if path != default_settings),
]
settings_override = {}
for settings_path in config_files:
if files.exists(settings_path):

View file

@ -181,6 +181,7 @@ def test_empty_asset_results_are_cached(plugin_tree, monkeypatch):
monkeypatch.setattr(plugins, "get_enabled_plugins", unexpected_lookup)
assert plugins.get_enabled_plugin_paths(None, "missing") == []
def test_provider_merges_apply_user_roots_last_and_preserve_same_root_order(plugin_tree):
roots = [
plugin_tree("_alpha", bundled=True), plugin_tree("_zulu", bundled=True),
@ -240,6 +241,7 @@ def test_profile_merges_preserve_root_order_and_higher_scope_overrides(
state = editor.metadata_state("researcher", editor._EditorContext(project_name or ""))
assert state["title"]["effective"] == state["context"]["effective"] == title
else:
# The global catalog includes profiles from every project.
assert subagents.get_all_agents_list() == [{"key": "researcher", "label": title}]
for root in roots:
@ -254,12 +256,88 @@ def test_profile_merges_preserve_root_order_and_higher_scope_overrides(
cache.clear("*(plugins)*")
assert_profile("_zulu")
for root in roots[2:]:
(root / ".toggle-0").unlink()
cache.clear("*(plugins)*")
write_profile(tmp_path / "usr", "User profile")
assert_profile("User profile")
write_profile(tmp_path / "usr/projects/demo/.a0proj", "Project profile")
assert_profile("Project profile", "demo")
def test_profile_settings_merge_preserves_root_and_scope_priorities(
plugin_tree, tmp_path, monkeypatch
):
from extensions.python.agent_init import _15_load_profile_settings as hook
roots = [
plugin_tree("_alpha", bundled=True), plugin_tree("_zulu", bundled=True),
plugin_tree("alpha"), plugin_tree("zulu"),
]
def write_settings(root, **values):
root.mkdir(parents=True, exist_ok=True)
path = root / "settings.json"
path.write_text(json.dumps(values), encoding="utf-8")
return path
for root in roots:
write_settings(root, root_value=root.name, winner=root.name)
write_settings(
root / "agents/researcher", profile_value=root.name, winner=root.name
)
write_settings(tmp_path / "agents/researcher", default_only="inherited", winner="default")
write_settings(tmp_path, excluded_default=True)
write_settings(tmp_path / "usr", excluded_user=True)
received = []
errors = []
monkeypatch.setattr(
hook, "initialize_agent",
lambda override_settings: received.append(override_settings)
or SimpleNamespace(profile="default", mcp_servers=""),
)
agent = SimpleNamespace(
config=SimpleNamespace(profile="researcher", mcp_servers="inherited MCP"),
context=SimpleNamespace(
get_data=lambda *_args: "demo",
log=SimpleNamespace(log=lambda **entry: errors.append(entry)),
),
)
def assert_settings(winner, plugin_winner="zulu"):
hook.LoadProfileSettings(agent).execute()
values = received[-1]
assert values["winner"] == winner
assert values["root_value"] == values["profile_value"] == plugin_winner
assert values["default_only"] == "inherited"
assert "excluded_default" not in values and "excluded_user" not in values
assert agent.config.profile == "researcher"
assert agent.config.mcp_servers == "inherited MCP"
assert_settings("zulu")
assert plugins.get_enabled_plugin_paths(agent, "settings.json") == [
str(root / "settings.json") for root in [*roots[2:], *roots[:2]]
]
write_settings(tmp_path / "usr/agents/researcher", winner="user")
assert_settings("user")
project = tmp_path / "usr/projects/demo/.a0proj"
write_settings(project, winner="project")
assert_settings("project")
project_profile = write_settings(project / "agents/researcher", winner="")
assert_settings("")
for root in roots[2:]:
(root / ".toggle-0").touch()
cache.clear("*(plugins)*")
assert_settings("", "_zulu")
project_profile.write_text("[]", encoding="utf-8")
assert_settings("project", "_zulu")
assert len(errors) == 1 and errors[0]["type"] == "error"
assert str(project_profile) in errors[0]["content"]
def test_model_prompt_native_schema_and_dispatch_use_the_same_plugin(
plugin_tree, monkeypatch, tmp_path
):