mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
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:
parent
489acfe8c6
commit
51f3ed00a1
7 changed files with 438 additions and 50 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue