From fe24597a6af431f6fa8a6b4dbcb89522b63393e2 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:42:58 +0200 Subject: [PATCH] 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. --- extensions/python/agent_init/AGENTS.md | 2 + .../agent_init/_15_load_profile_settings.py | 22 +++++- tests/test_plugin_asset_precedence.py | 78 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/extensions/python/agent_init/AGENTS.md b/extensions/python/agent_init/AGENTS.md index e958fa34c..cedff7368 100644 --- a/extensions/python/agent_init/AGENTS.md +++ b/extensions/python/agent_init/AGENTS.md @@ -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 diff --git a/extensions/python/agent_init/_15_load_profile_settings.py b/extensions/python/agent_init/_15_load_profile_settings.py index a03913b5f..e6440f2d9 100644 --- a/extensions/python/agent_init/_15_load_profile_settings.py +++ b/extensions/python/agent_init/_15_load_profile_settings.py @@ -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): diff --git a/tests/test_plugin_asset_precedence.py b/tests/test_plugin_asset_precedence.py index 8eb7bc012..2c2e3853c 100644 --- a/tests/test_plugin_asset_precedence.py +++ b/tests/test_plugin_asset_precedence.py @@ -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 ):