Add project extension data hooks

Introduce generic project edit-data extension hooks so plugins can contribute named project settings sections without coupling helpers.projects to plugin-specific storage.

Move _model_config project LLM payload handling behind those hooks and preserve inherited global model settings unless a project override already exists or is explicitly selected.

Cover the inheritance regression, existing scoped overrides, multiple plugin sections, core-field collision protection, and extension payload filtering in focused project/model-config tests.
This commit is contained in:
Alessandro 2026-06-23 07:34:21 +02:00
parent 489acfe8c6
commit 51f3ed00a1
7 changed files with 438 additions and 50 deletions

View file

@ -1,7 +1,7 @@
import os
from typing import Literal, NotRequired, TypedDict, TYPE_CHECKING, cast
from helpers import files, dirty_json, persist_chat, file_tree
from helpers import files, dirty_json, persist_chat, file_tree, extension
from helpers.print_style import PrintStyle
@ -62,6 +62,12 @@ class EditProjectData(BasicProjectData):
git_status: GitStatusData
ProjectExtendedData = dict[str, object]
_PROJECT_CORE_EDIT_KEYS = frozenset(BasicProjectData.__annotations__) | frozenset(
EditProjectData.__annotations__
)
_PROJECT_TRANSIENT_INPUT_KEYS = frozenset({"git_token"})
def get_projects_parent_folder():
return files.get_abs_path(PROJECTS_PARENT_DIR)
@ -94,7 +100,7 @@ def delete_project(name: str):
def create_project(name: str, data: BasicProjectData):
llm_data = data.get("llm") if isinstance(data, dict) else None
extended_data = _project_extended_data_for_save(data)
mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None
abs_path = files.create_dir_safe(
files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}"
@ -103,7 +109,7 @@ def create_project(name: str, data: BasicProjectData):
data = _normalizeBasicData(data)
save_project_header(name, data)
save_project_mcp_servers(name, mcp_servers or DEFAULT_MCP_SERVERS_CONFIG)
save_project_llm_settings(name, llm_data)
save_project_extended_data(name, extended_data)
return name
@ -111,7 +117,7 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec
"""Clone a git repository as a new A0 project. Token is used only for cloning via http header."""
from helpers import git
llm_data = data.get("llm") if isinstance(data, dict) else None
extended_data = _project_extended_data_for_save(data)
mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None
abs_path = files.create_dir_safe(
@ -142,7 +148,7 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec
if mcp_servers:
save_project_mcp_servers(actual_name, mcp_servers)
save_project_llm_settings(actual_name, llm_data)
save_project_extended_data(actual_name, extended_data)
return actual_name
except Exception as e:
@ -242,7 +248,7 @@ def _basic_data_to_edit_data(data: BasicProjectData) -> EditProjectData:
def update_project(name: str, data: EditProjectData):
llm_data = data.get("llm") if isinstance(data, dict) else None
extended_data = _project_extended_data_for_save(data)
# merge with current state
current = load_edit_project_data(name)
@ -258,7 +264,7 @@ def update_project(name: str, data: EditProjectData):
save_project_secrets(name, current["secrets"])
save_project_mcp_servers(name, current["mcp_servers"])
save_project_subagents(name, current["subagents"])
save_project_llm_settings(name, llm_data)
save_project_extended_data(name, extended_data)
reactivate_project_in_chats(name)
return name
@ -298,7 +304,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
},
)
data = _normalizeEditData(data)
data["llm"] = load_project_llm_data(name) # type: ignore[typeddict-unknown-key]
_merge_project_extended_data(data, load_project_extended_data(name))
return data
@ -312,53 +318,42 @@ def save_project_header(name: str, data: BasicProjectData):
files.write_file(abs_path, header)
def load_project_llm_data(name: str) -> dict:
from plugins._model_config.helpers import model_config
@extension.extensible
def load_project_extended_data(name: str) -> ProjectExtendedData:
return {}
config = model_config.normalize_config_for_save(
model_config.get_config(project_name=name) or {}
)
@extension.extensible
def save_project_extended_data(name: str, project_data: ProjectExtendedData):
return None
def _project_extended_data_for_save(data: object) -> ProjectExtendedData:
if not isinstance(data, dict):
return {}
return {
"config": config,
"selected_preset": {
"scope": "current",
"project_name": name,
"name": "Current config",
},
"presets": model_config.get_combined_presets(name),
"global_presets": model_config.get_presets(),
"project_presets": model_config.get_project_presets(name),
str(key): value
for key, value in data.items()
if str(key) not in _PROJECT_CORE_EDIT_KEYS
and str(key) not in _PROJECT_TRANSIENT_INPUT_KEYS
}
def save_project_llm_settings(name: str, llm_data: object):
if not isinstance(llm_data, dict):
def _merge_project_extended_data(
data: EditProjectData,
extended_data: object,
) -> None:
if not isinstance(extended_data, dict):
return
from helpers import plugins
from plugins._model_config.helpers import model_config
conflicts = sorted(str(key) for key in extended_data if key in _PROJECT_CORE_EDIT_KEYS)
if conflicts:
raise ValueError(
"Project extension data cannot overwrite core project fields: "
+ ", ".join(conflicts)
)
project_presets = llm_data.get("project_presets")
if isinstance(project_presets, list):
model_config.save_presets(project_presets, project_name=name)
config_to_save = None
selected_preset = llm_data.get("selected_preset")
if isinstance(selected_preset, dict) and selected_preset.get("scope") in {
"global",
"project",
}:
preset = model_config.resolve_preset_selection(selected_preset, project_name=name)
if preset:
base_config = model_config.get_config(project_name=name) or {}
config_to_save = model_config.build_config_from_preset(preset, base_config)
config = llm_data.get("config")
if config_to_save is None and isinstance(config, dict):
config_to_save = model_config.normalize_config_for_save(config)
if config_to_save is not None:
plugins.save_plugin_config("_model_config", name, "", config_to_save)
data.update(extended_data) # type: ignore[typeddict-item]
def load_project_mcp_servers(name: str) -> str:

View file

@ -34,8 +34,10 @@
- `load_basic_project_data(name: str) -> BasicProjectData`
- `load_edit_project_data(name: str) -> EditProjectData`
- `save_project_header(name: str, data: BasicProjectData)`
- `load_project_llm_data(name: str) -> dict`
- `save_project_llm_settings(name: str, llm_data: object)`
- `load_project_extended_data(name: str) -> ProjectExtendedData`
- `save_project_extended_data(name: str, project_data: ProjectExtendedData)`
- `_project_extended_data_for_save(data: object) -> ProjectExtendedData`
- `_merge_project_extended_data(data: EditProjectData, extended_data: object) -> None`
- `load_project_mcp_servers(name: str) -> str`
- `save_project_mcp_servers(name: str, mcp_servers: str)`
- `get_active_projects_list()`
@ -57,6 +59,9 @@
- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
- Per-project MCP server configuration is persisted as `.a0proj/mcp_servers.json`, exposed through `load_edit_project_data(...)`, and saved during project create/clone/update flows.
- Additional project-edit payload sections are delegated through extensible `load_project_extended_data(...)` and `save_project_extended_data(...)`; the project helper must stay storage-agnostic and plugin-specific config rules belong to the owning plugin.
- Project extension data may add named top-level sections such as `llm`, but it must not overwrite core project fields owned by `EditProjectData`.
- Project extension save payloads exclude core project fields and transient inputs such as `git_token`; plugins needing core metadata should load it by project name.
- Project metadata setup creates and repairs `.a0proj/instructions`, `.a0proj/knowledge`, and `.a0proj/skills` so settings surfaces can open those folders consistently.
- Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`.
- Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`.
@ -65,7 +70,7 @@
## Key Concepts
- Important called helpers/classes observed in the source: `files.get_abs_path`, `files.delete_dir`, `deactivate_project_in_chats`, `files.create_dir_safe`, `create_project_meta_folders`, `_normalizeBasicData`, `save_project_header`, `save_project_mcp_servers`, `load_project_mcp_servers`, `save_project_llm_settings`, `files.basename`, `dirty_json.parse`, `FileStructureInjectionSettings`, `cast`, `_normalizeEditData`, `load_edit_project_data`, `_edit_data_to_basic_data`, `save_project_variables`, `save_project_secrets`, `save_project_subagents`, `reactivate_project_in_chats`, `load_basic_project_data`.
- Important called helpers/classes observed in the source: `files.get_abs_path`, `files.delete_dir`, `deactivate_project_in_chats`, `files.create_dir_safe`, `create_project_meta_folders`, `_normalizeBasicData`, `save_project_header`, `save_project_mcp_servers`, `load_project_mcp_servers`, `save_project_extended_data`, `load_project_extended_data`, `_project_extended_data_for_save`, `_merge_project_extended_data`, `_PROJECT_CORE_EDIT_KEYS`, `_PROJECT_TRANSIENT_INPUT_KEYS`, `extension.extensible`, `files.basename`, `dirty_json.parse`, `FileStructureInjectionSettings`, `cast`, `_normalizeEditData`, `load_edit_project_data`, `_edit_data_to_basic_data`, `save_project_variables`, `save_project_secrets`, `save_project_subagents`, `reactivate_project_in_chats`, `load_basic_project_data`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

View file

@ -14,6 +14,7 @@
## Local Contracts
- Preserve global, project, agent, and chat override resolution order.
- Project Settings `llm` payloads are owned here through the generic `helpers.projects` project extension-data hooks; keep project helper code agnostic to `_model_config` paths, presets, and inheritance rules.
- Keep provider metadata and API-key checks safe around secrets.
- Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here.
- Applying a model preset may inherit durable tuning such as context windows and rate limits, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.

View file

@ -0,0 +1,14 @@
from helpers.extension import Extension
from plugins._model_config.helpers import model_config
class ProjectLlmDataProvider(Extension):
def execute(self, data: dict = {}, **kwargs):
result = data.get("result")
if not isinstance(result, dict):
result = {}
data["result"] = result
args = data.get("args") or ()
project_name = args[0] if args else data.get("kwargs", {}).get("name", "")
result["llm"] = model_config.load_project_llm_data(str(project_name or ""))

View file

@ -0,0 +1,12 @@
from helpers.extension import Extension
from plugins._model_config.helpers import model_config
class ProjectLlmSettingsSaver(Extension):
def execute(self, data: dict = {}, **kwargs):
args = data.get("args") or ()
call_kwargs = data.get("kwargs") or {}
project_name = args[0] if args else call_kwargs.get("name", "")
project_data = args[1] if len(args) > 1 else call_kwargs.get("project_data")
llm_data = project_data.get("llm") if isinstance(project_data, dict) else None
model_config.save_project_llm_settings(str(project_name or ""), llm_data)

View file

@ -99,6 +99,76 @@ def get_config(agent=None, project_name=None, agent_profile=None):
) or {}
def has_project_config(project_name: str) -> bool:
path = plugins.determine_plugin_asset_path(
"_model_config", project_name, "", plugins.CONFIG_FILE_NAME
)
return files.exists(path)
def load_project_llm_data(project_name: str) -> dict:
"""Build the model-settings payload shown in Project Settings."""
project_config_exists = has_project_config(project_name)
config = normalize_config_for_save(get_config(project_name=project_name) or {})
return {
"config": config,
"config_scope": "project" if project_config_exists else "inherited",
"has_project_config": project_config_exists,
"selected_preset": {
"scope": "current",
"project_name": project_name,
"name": "Current config",
},
"presets": get_combined_presets(project_name),
"global_presets": get_presets(),
"project_presets": get_project_presets(project_name),
}
def save_project_llm_settings(project_name: str, llm_data: object) -> None:
"""Persist Project Settings model data without freezing inherited globals."""
if not isinstance(llm_data, dict):
return
project_config_exists = has_project_config(project_name)
project_presets = llm_data.get("project_presets")
presets_path = _get_presets_path(project_name)
if isinstance(project_presets, list) and (
project_presets or files.exists(presets_path)
):
save_presets(project_presets, project_name=project_name)
config_to_save = None
selected_preset = llm_data.get("selected_preset")
if isinstance(selected_preset, dict) and selected_preset.get("scope") in {
PRESET_SCOPE_GLOBAL,
PRESET_SCOPE_PROJECT,
}:
preset = resolve_preset_selection(selected_preset, project_name=project_name)
if preset:
base_config = get_config(project_name=project_name) or {}
config_to_save = build_config_from_preset(preset, base_config)
config = llm_data.get("config")
config_scope = str(llm_data.get("config_scope") or "")
config_is_inherited = config_scope == "inherited" and not project_config_exists
should_save_config = (
project_config_exists
or config_scope == "project"
or not config_scope
)
if (
config_to_save is None
and isinstance(config, dict)
and should_save_config
and not config_is_inherited
):
config_to_save = normalize_config_for_save(config)
if config_to_save is not None:
plugins.save_plugin_config("_model_config", project_name, "", config_to_save)
def _load_presets_from_path(path: str) -> list | None:
if files.exists(path):
data = yaml_helper.loads(files.read_file(path))

View file

@ -48,6 +48,21 @@ sys.modules.setdefault("watchdog.observers", watchdog.observers)
sys.modules.setdefault("watchdog.events", watchdog.events)
def _copy_extension_fixture(plugin_dir: Path, relative_path: str) -> None:
source = PROJECT_ROOT / "plugins" / "_model_config" / relative_path
target = plugin_dir / relative_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
def _clear_runtime_caches():
from helpers import cache, modules
cache.clear("*(extensions)*")
cache.clear("*(plugins)*")
modules.purge_namespace("usr.plugins")
def _prepare_a0_tree(monkeypatch, tmp_path: Path):
from helpers import files, plugins
@ -91,7 +106,140 @@ embedding_model:
""".lstrip(),
encoding="utf-8",
)
_copy_extension_fixture(
plugin_dir,
"extensions/python/_functions/helpers/projects/load_project_extended_data/end/_10_model_config.py",
)
_copy_extension_fixture(
plugin_dir,
"extensions/python/_functions/helpers/projects/save_project_extended_data/start/_10_model_config.py",
)
(tmp_path / "usr" / "plugins").mkdir(parents=True)
(tmp_path / "usr" / "projects").mkdir(parents=True)
_clear_runtime_caches()
def _add_project_extra_plugin(tmp_path: Path):
plugin_dir = tmp_path / "plugins" / "_project_extra"
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(
"name: _project_extra\n",
encoding="utf-8",
)
load_ext = (
plugin_dir
/ "extensions"
/ "python"
/ "_functions"
/ "helpers"
/ "projects"
/ "load_project_extended_data"
/ "end"
/ "_20_project_extra.py"
)
load_ext.parent.mkdir(parents=True, exist_ok=True)
load_ext.write_text(
"""
from helpers.extension import Extension
class ProjectExtraLoader(Extension):
def execute(self, data: dict = {}, **kwargs):
result = data.get("result")
if not isinstance(result, dict):
result = {}
data["result"] = result
args = data.get("args") or ()
project_name = args[0] if args else data.get("kwargs", {}).get("name", "")
result["extra"] = {"loaded_for": str(project_name or ""), "enabled": True}
""".lstrip(),
encoding="utf-8",
)
save_ext = (
plugin_dir
/ "extensions"
/ "python"
/ "_functions"
/ "helpers"
/ "projects"
/ "save_project_extended_data"
/ "start"
/ "_20_project_extra.py"
)
save_ext.parent.mkdir(parents=True, exist_ok=True)
save_ext.write_text(
"""
import json
from helpers import files
from helpers.extension import Extension
class ProjectExtraSaver(Extension):
def execute(self, data: dict = {}, **kwargs):
args = data.get("args") or ()
call_kwargs = data.get("kwargs") or {}
project_name = args[0] if args else call_kwargs.get("name", "")
project_data = args[1] if len(args) > 1 else call_kwargs.get("project_data")
if not isinstance(project_data, dict) or "extra" not in project_data:
return
forbidden = {"title", "mcp_servers", "git_token"} & set(project_data)
if forbidden:
raise AssertionError(f"core/transient keys leaked to extension save: {sorted(forbidden)}")
path = files.get_abs_path(
"usr",
"projects",
str(project_name or ""),
".a0proj",
"extra_saved.json",
)
files.write_file(
path,
json.dumps(
{"project": str(project_name or ""), "extra": project_data["extra"]},
sort_keys=True,
),
)
""".lstrip(),
encoding="utf-8",
)
_clear_runtime_caches()
def _add_project_conflict_plugin(tmp_path: Path):
plugin_dir = tmp_path / "plugins" / "_project_conflict"
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(
"name: _project_conflict\n",
encoding="utf-8",
)
load_ext = (
plugin_dir
/ "extensions"
/ "python"
/ "_functions"
/ "helpers"
/ "projects"
/ "load_project_extended_data"
/ "end"
/ "_30_project_conflict.py"
)
load_ext.parent.mkdir(parents=True, exist_ok=True)
load_ext.write_text(
"""
from helpers.extension import Extension
class ProjectConflictLoader(Extension):
def execute(self, data: dict = {}, **kwargs):
result = data.get("result")
if not isinstance(result, dict):
result = {}
data["result"] = result
result["title"] = "Plugin-owned title"
""".lstrip(),
encoding="utf-8",
)
_clear_runtime_caches()
def test_global_presets_keep_legacy_default_and_save_behavior(monkeypatch, tmp_path):
@ -247,6 +395,149 @@ def test_project_save_copies_selected_preset_to_scoped_model_config(monkeypatch,
assert "_model_config" not in project_json
def test_project_save_does_not_freeze_inherited_global_model_config(
monkeypatch,
tmp_path,
):
_prepare_a0_tree(monkeypatch, tmp_path)
from helpers import plugins, projects
projects.create_project("demo", {"title": "Demo"})
config_path = (
tmp_path
/ "usr"
/ "projects"
/ "demo"
/ ".a0proj"
/ "plugins"
/ "_model_config"
/ "config.json"
)
project_data = projects.load_edit_project_data("demo")
assert project_data["llm"]["config_scope"] == "inherited"
assert project_data["llm"]["has_project_config"] is False
projects.update_project("demo", project_data)
assert not config_path.exists()
plugins.save_plugin_config(
"_model_config",
"",
"",
{
"chat_model": {"provider": "openrouter", "name": "new-global-chat"},
"utility_model": {"provider": "openrouter", "name": "new-global-utility"},
"embedding_model": {
"provider": "huggingface",
"name": "new-global-embedding",
},
},
)
reloaded_data = projects.load_edit_project_data("demo")
assert reloaded_data["llm"]["config_scope"] == "inherited"
assert reloaded_data["llm"]["config"]["chat_model"]["name"] == "new-global-chat"
def test_project_save_updates_existing_scoped_model_config(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)
from helpers import plugins, projects
projects.create_project("demo", {"title": "Demo"})
plugins.save_plugin_config(
"_model_config",
"demo",
"",
{
"chat_model": {"provider": "openrouter", "name": "project-chat"},
"utility_model": {"provider": "openrouter", "name": "project-utility"},
"embedding_model": {"provider": "huggingface", "name": "project-embedding"},
},
)
project_data = projects.load_edit_project_data("demo")
assert project_data["llm"]["config_scope"] == "project"
project_data["llm"]["config"]["chat_model"]["name"] = "project-chat-updated"
projects.update_project("demo", project_data)
config_path = (
tmp_path
/ "usr"
/ "projects"
/ "demo"
/ ".a0proj"
/ "plugins"
/ "_model_config"
/ "config.json"
)
config = json.loads(config_path.read_text(encoding="utf-8"))
assert config["chat_model"]["name"] == "project-chat-updated"
def test_project_extended_data_supports_multiple_plugin_sections(
monkeypatch,
tmp_path,
):
_prepare_a0_tree(monkeypatch, tmp_path)
_add_project_extra_plugin(tmp_path)
from helpers import projects
projects.create_project(
"demo",
{
"title": "Demo",
"git_token": "secret-token",
"extra": {"enabled": False, "note": "created"},
},
)
saved_path = (
tmp_path
/ "usr"
/ "projects"
/ "demo"
/ ".a0proj"
/ "extra_saved.json"
)
assert json.loads(saved_path.read_text(encoding="utf-8")) == {
"project": "demo",
"extra": {"enabled": False, "note": "created"},
}
project_data = projects.load_edit_project_data("demo")
assert project_data["llm"]["config_scope"] == "inherited"
assert project_data["extra"] == {"loaded_for": "demo", "enabled": True}
project_data["extra"] = {"enabled": True, "note": "updated"}
projects.update_project("demo", project_data)
assert json.loads(saved_path.read_text(encoding="utf-8")) == {
"project": "demo",
"extra": {"enabled": True, "note": "updated"},
}
def test_project_extended_data_cannot_overwrite_core_fields(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)
_add_project_conflict_plugin(tmp_path)
from helpers import projects
projects.create_project("demo", {"title": "Demo"})
with pytest.raises(
ValueError,
match="Project extension data cannot overwrite core project fields: title",
):
projects.load_edit_project_data("demo")
def test_preset_application_preserves_tuning_but_replaces_kwargs(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)