From e0f2ce2af160b4089a61b0b506ccdc153c35fc69 Mon Sep 17 00:00:00 2001 From: wgnrai Date: Tue, 18 Aug 2026 14:13:33 -0400 Subject: [PATCH 1/4] feat(projects): per-project default agent selection Add get_project_default_agent() reading opt-in .a0proj/default_agent.json, and extend reconcile_agent_profile() to switch to the project default when the context still runs the global default profile. Manual per-chat selections are never overridden. The fallback branch also prefers the project default over agent0/first-in-dict. Projects without the config file behave exactly as before. --- helpers/projects.py | 40 ++++++- helpers/projects.py.dox.md | 12 +++ tests/test_project_default_agent.py | 157 ++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 tests/test_project_default_agent.py diff --git a/helpers/projects.py b/helpers/projects.py index 11f4079527..293d329644 100644 --- a/helpers/projects.py +++ b/helpers/projects.py @@ -399,6 +399,19 @@ def _get_projects_list(parent_dir): return projects +def get_project_default_agent(name: str | None) -> str | None: + """Return the project's preferred default agent profile, or None.""" + if not name: + return None + try: + abs_path = files.get_abs_path(get_project_meta(name), "default_agent.json") + data = dirty_json.parse(files.read_file(abs_path)) + agent = str(data.get("agent", "") or "").strip() if isinstance(data, dict) else "" + return agent or None + except Exception: + return None + + def reconcile_agent_profile( context: "AgentContext", project_name: str | None, available: dict | None = None ) -> bool: @@ -408,11 +421,36 @@ def reconcile_agent_profile( if available is None: available = subagents.get_available_agents_dict(project_name) if getattr(context.config, "profile", "") in available: + # project default agent: switch only when the context still runs the + # global default (i.e. no explicit per-chat selection has been made) + default_agent = get_project_default_agent(project_name) + if ( + default_agent + and default_agent in available + and default_agent != getattr(context.config, "profile", "") + ): + from helpers import settings as settings_helper + + global_default = settings_helper.get_settings().get("agent_profile", "") + if getattr(context.config, "profile", "") == global_default: + config = initialize_agent( + override_settings={"agent_profile": default_agent} + ) + context.config = config + context.agent0.config = config + return True return False config = initialize_agent() if config.profile not in available: - fallback = "agent0" if "agent0" in available else next(iter(available), "agent0") + default_agent = get_project_default_agent(project_name) + fallback = ( + default_agent + if default_agent in available + else "agent0" + if "agent0" in available + else next(iter(available), "agent0") + ) config = initialize_agent(override_settings={"agent_profile": fallback}) context.config = config context.agent0.config = config diff --git a/helpers/projects.py.dox.md b/helpers/projects.py.dox.md index 4de0b93387..dcf7004099 100644 --- a/helpers/projects.py.dox.md +++ b/helpers/projects.py.dox.md @@ -42,6 +42,7 @@ - `save_project_mcp_servers(name: str, mcp_servers: str)` - `get_active_projects_list()` - `_get_projects_list(parent_dir)` +- `get_project_default_agent(name: str | None) -> str | None`: Read the project's preferred default agent profile from `.a0proj/default_agent.json`; returns None when absent, unreadable, or malformed. - `reconcile_agent_profile(context: AgentContext, project_name: str | None) -> bool` - `reconcile_agent_profiles(project_name: str | None, *, all_scopes: bool=...) -> None` - `activate_project(context_id: str, name: str, mark_dirty: bool=...)` @@ -91,6 +92,16 @@ scope; only chats whose active profile actually changes are persisted and marked dirty. Context creation uses the same reconciliation after resolving its scope, so a disabled configured profile cannot become invisibly active. +- `reconcile_agent_profile(...)` additionally applies the per-project default + agent from `.a0proj/default_agent.json` (read via + `get_project_default_agent(...)`): when the context still runs the global + default profile and the configured default agent is available in the current + scope, the context switches to that agent. Contexts running any other + profile (manual per-chat selections) are never overridden, and projects + without the file behave exactly as before. The fallback branch (active + profile missing from the available catalog) also prefers the project + default agent over `agent0`/first-in-dict when the default is available; + fallback ordering is: project default, then `agent0`, then first available. - Project updates and deletion refresh only chats assigned to that project and persist each affected chat once; unrelated chats are never rewritten. - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling. @@ -112,6 +123,7 @@ - Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers. - Related tests observed by source search: + - `tests/test_project_default_agent.py` - `tests/test_model_config_project_presets.py` - `tests/test_office_document_store.py` - `tests/test_plugin_activation_ui.py` diff --git a/tests/test_project_default_agent.py b/tests/test_project_default_agent.py new file mode 100644 index 0000000000..ca9632bad7 --- /dev/null +++ b/tests/test_project_default_agent.py @@ -0,0 +1,157 @@ +import json +from pathlib import Path + +import initialize +from agent import AgentConfig, AgentContext +from helpers import projects, settings, subagents + +from tests.test_projects import _prepare_project_tree + + +GLOBAL_DEFAULT = "global-default" +CAPTAIN = "captain" + + +def _make_context(context_id: str, profile: str) -> AgentContext: + AgentContext.remove(context_id) + return AgentContext( + config=AgentConfig(mcp_servers="", profile=profile), + id=context_id, + set_current=False, + ) + + +def _stub_reconcile_deps( + monkeypatch, + available_profiles: dict[str, str], +) -> None: + monkeypatch.setattr( + subagents, + "get_available_agents_dict", + lambda _project_name: { + name: subagents.SubAgentListItem(name=name) + for name in available_profiles + }, + ) + monkeypatch.setattr( + initialize, + "initialize_agent", + lambda override_settings=None: AgentConfig( + mcp_servers="", + profile=(override_settings or {}).get("agent_profile", GLOBAL_DEFAULT), + ), + ) + monkeypatch.setattr( + settings, + "get_settings", + lambda: {"agent_profile": GLOBAL_DEFAULT}, + ) + + +def _write_default_agent_file(tmp_path: Path, payload) -> None: + meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj" + meta.mkdir(parents=True, exist_ok=True) + target = meta / "default_agent.json" + if isinstance(payload, bytes): + target.write_bytes(payload) + else: + target.write_text(json.dumps(payload), encoding="utf-8") + + +def test_no_default_file_keeps_global_default_profile(monkeypatch, tmp_path: Path) -> None: + _prepare_project_tree(monkeypatch, tmp_path) + _stub_reconcile_deps(monkeypatch, {GLOBAL_DEFAULT: GLOBAL_DEFAULT, "other": "other"}) + context = _make_context("ctx-default-agent-no-file", GLOBAL_DEFAULT) + + try: + assert projects.get_project_default_agent("demo") is None + assert projects.reconcile_agent_profile(context, "demo") is False + assert context.config.profile == GLOBAL_DEFAULT + assert context.agent0.config.profile == GLOBAL_DEFAULT + finally: + AgentContext.remove(context.id) + + +def test_default_file_switches_context_on_global_default(monkeypatch, tmp_path: Path) -> None: + _prepare_project_tree(monkeypatch, tmp_path) + _write_default_agent_file(tmp_path, {"agent": CAPTAIN}) + _stub_reconcile_deps( + monkeypatch, {GLOBAL_DEFAULT: GLOBAL_DEFAULT, CAPTAIN: CAPTAIN} + ) + context = _make_context("ctx-default-agent-switch", GLOBAL_DEFAULT) + + try: + assert projects.get_project_default_agent("demo") == CAPTAIN + assert projects.reconcile_agent_profile(context, "demo") is True + assert context.config.profile == CAPTAIN + assert context.agent0.config.profile == CAPTAIN + finally: + AgentContext.remove(context.id) + + +def test_default_file_never_overrides_manual_selection(monkeypatch, tmp_path: Path) -> None: + _prepare_project_tree(monkeypatch, tmp_path) + _write_default_agent_file(tmp_path, {"agent": CAPTAIN}) + _stub_reconcile_deps( + monkeypatch, + {GLOBAL_DEFAULT: GLOBAL_DEFAULT, CAPTAIN: CAPTAIN, "manual-pick": "manual-pick"}, + ) + context = _make_context("ctx-default-agent-manual", "manual-pick") + + try: + assert projects.reconcile_agent_profile(context, "demo") is False + assert context.config.profile == "manual-pick" + assert context.agent0.config.profile == "manual-pick" + finally: + AgentContext.remove(context.id) + + +def test_default_naming_unavailable_agent_is_ignored(monkeypatch, tmp_path: Path) -> None: + _prepare_project_tree(monkeypatch, tmp_path) + _write_default_agent_file(tmp_path, {"agent": CAPTAIN}) + _stub_reconcile_deps(monkeypatch, {GLOBAL_DEFAULT: GLOBAL_DEFAULT}) + context = _make_context("ctx-default-agent-unavailable", GLOBAL_DEFAULT) + + try: + assert projects.reconcile_agent_profile(context, "demo") is False + assert context.config.profile == GLOBAL_DEFAULT + assert context.agent0.config.profile == GLOBAL_DEFAULT + finally: + AgentContext.remove(context.id) + + +def test_malformed_default_file_is_treated_as_absent(monkeypatch, tmp_path: Path) -> None: + _prepare_project_tree(monkeypatch, tmp_path) + _write_default_agent_file(tmp_path, b"not json at all") + _stub_reconcile_deps( + monkeypatch, {GLOBAL_DEFAULT: GLOBAL_DEFAULT, CAPTAIN: CAPTAIN} + ) + context = _make_context("ctx-default-agent-malformed", GLOBAL_DEFAULT) + + try: + assert projects.get_project_default_agent("demo") is None + assert projects.reconcile_agent_profile(context, "demo") is False + assert context.config.profile == GLOBAL_DEFAULT + assert context.agent0.config.profile == GLOBAL_DEFAULT + finally: + AgentContext.remove(context.id) + + +def test_disabled_global_default_falls_back_to_project_default(monkeypatch, tmp_path: Path) -> None: + """Sysop pilot case: global default profile is disabled in the project; + reconcile's fallback branch must prefer the project default agent over + agent0/first-in-dict.""" + _prepare_project_tree(monkeypatch, tmp_path) + _write_default_agent_file(tmp_path, {"agent": CAPTAIN}) + _stub_reconcile_deps( + monkeypatch, + {CAPTAIN: CAPTAIN, "agent0": "agent0", "someother": "someother"}, + ) + context = _make_context("ctx-default-agent-fallback", GLOBAL_DEFAULT) + + try: + assert projects.reconcile_agent_profile(context, "demo") is True + assert context.config.profile == CAPTAIN + assert context.agent0.config.profile == CAPTAIN + finally: + AgentContext.remove(context.id) From 5235d4d0fc8bae4293cc4cb5839523c6e67363c7 Mon Sep 17 00:00:00 2001 From: wgnrai Date: Tue, 18 Aug 2026 14:37:10 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(projects):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20preserve=20manual=20selections,=20prefer=20project?= =?UTF-8?q?=20default=20in=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent_profile_set now records agent_profile_manually_set on the context; reconcile honors it so explicit per-chat selections (including picking the global profile) are never overridden by later reconciliation sweeps. - fallback branch now prefers the configured project default even when the global default is still available; ordering: project default, then agent0, then first available. - tests 7-8 added covering both cases (8/8 green, 20/20 regression). --- api/agent_profile_set.py | 1 + api/agent_profile_set.py.dox.md | 1 + helpers/projects.py | 12 ++++++--- helpers/projects.py.dox.md | 12 +++++---- tests/test_project_default_agent.py | 40 +++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/api/agent_profile_set.py b/api/agent_profile_set.py index d268bf1379..ba0ad17475 100644 --- a/api/agent_profile_set.py +++ b/api/agent_profile_set.py @@ -35,6 +35,7 @@ async def process(self, input: dict, request: Request) -> dict | Response: config = initialize_agent(override_settings={"agent_profile": profile}) context.config = config context.agent0.config = config + context.set_data("agent_profile_manually_set", True) save_tmp_chat(context) mark_dirty_for_context(context.id, reason="agent_profile_change") diff --git a/api/agent_profile_set.py.dox.md b/api/agent_profile_set.py.dox.md index 25e87a33b5..877b3ec228 100644 --- a/api/agent_profile_set.py.dox.md +++ b/api/agent_profile_set.py.dox.md @@ -22,6 +22,7 @@ - `SetAgentProfile` defines `process(...)`. - Observed side-effect areas: filesystem writes, settings/state persistence. - Switching a chat profile updates the context and top-level agent profile only; existing subordinate agents keep their own profile configs. +- Explicit profile selection sets the `agent_profile_manually_set` context data flag (persisted via `save_tmp_chat` because non-underscore `context.data` keys are serialized); `helpers/projects.py` `reconcile_agent_profile(...)` skips project-default-agent overrides for flagged contexts, so explicit selections survive reconcile sweeps. - A profile can be selected only when it is available in the chat's active project scope; profiles owned by other projects are not valid candidates. - Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.persist_chat`, `helpers.state_monitor_integration`. diff --git a/helpers/projects.py b/helpers/projects.py index 293d329644..089181d90a 100644 --- a/helpers/projects.py +++ b/helpers/projects.py @@ -423,9 +423,11 @@ def reconcile_agent_profile( if getattr(context.config, "profile", "") in available: # project default agent: switch only when the context still runs the # global default (i.e. no explicit per-chat selection has been made) + manually_set = context.get_data("agent_profile_manually_set") default_agent = get_project_default_agent(project_name) if ( - default_agent + not manually_set + and default_agent and default_agent in available and default_agent != getattr(context.config, "profile", "") ): @@ -442,8 +444,12 @@ def reconcile_agent_profile( return False config = initialize_agent() - if config.profile not in available: - default_agent = get_project_default_agent(project_name) + default_agent = get_project_default_agent(project_name) + if config.profile not in available or ( + default_agent + and default_agent in available + and config.profile != default_agent + ): fallback = ( default_agent if default_agent in available diff --git a/helpers/projects.py.dox.md b/helpers/projects.py.dox.md index dcf7004099..728677034c 100644 --- a/helpers/projects.py.dox.md +++ b/helpers/projects.py.dox.md @@ -96,12 +96,14 @@ agent from `.a0proj/default_agent.json` (read via `get_project_default_agent(...)`): when the context still runs the global default profile and the configured default agent is available in the current - scope, the context switches to that agent. Contexts running any other - profile (manual per-chat selections) are never overridden, and projects + scope, the context switches to that agent. The switch is skipped entirely + when the context carries a truthy `agent_profile_manually_set` data flag + (set by `api/agent_profile_set.py` on explicit per-chat selection), so + manual selections are never overridden by reconcile sweeps. Projects without the file behave exactly as before. The fallback branch (active - profile missing from the available catalog) also prefers the project - default agent over `agent0`/first-in-dict when the default is available; - fallback ordering is: project default, then `agent0`, then first available. + profile missing from the available catalog) prefers the project default + agent even over an available global default; fallback ordering is: project + default, then `agent0`, then first available. - Project updates and deletion refresh only chats assigned to that project and persist each affected chat once; unrelated chats are never rewritten. - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling. diff --git a/tests/test_project_default_agent.py b/tests/test_project_default_agent.py index ca9632bad7..3824432649 100644 --- a/tests/test_project_default_agent.py +++ b/tests/test_project_default_agent.py @@ -155,3 +155,43 @@ def test_disabled_global_default_falls_back_to_project_default(monkeypatch, tmp_ assert context.agent0.config.profile == CAPTAIN finally: AgentContext.remove(context.id) + + +def test_manually_set_flag_blocks_default_agent_switch(monkeypatch, tmp_path: Path) -> None: + """Explicit per-chat selection (agent_profile_set) sets a manual flag; + reconcile must never override it with the project default.""" + _prepare_project_tree(monkeypatch, tmp_path) + _write_default_agent_file(tmp_path, {"agent": CAPTAIN}) + _stub_reconcile_deps( + monkeypatch, {GLOBAL_DEFAULT: GLOBAL_DEFAULT, CAPTAIN: CAPTAIN} + ) + context = _make_context("ctx-default-agent-manual-flag", GLOBAL_DEFAULT) + context.set_data("agent_profile_manually_set", True) + + try: + assert projects.reconcile_agent_profile(context, "demo") is False + assert context.config.profile == GLOBAL_DEFAULT + assert context.agent0.config.profile == GLOBAL_DEFAULT + finally: + AgentContext.remove(context.id) + + +def test_unavailable_profile_with_available_global_default_switches_to_project_default( + monkeypatch, tmp_path: Path +) -> None: + """Context runs an unavailable profile X while the global default IS + available; a configured+available project default must still win in the + fallback branch.""" + _prepare_project_tree(monkeypatch, tmp_path) + _write_default_agent_file(tmp_path, {"agent": CAPTAIN}) + _stub_reconcile_deps( + monkeypatch, {GLOBAL_DEFAULT: GLOBAL_DEFAULT, CAPTAIN: CAPTAIN} + ) + context = _make_context("ctx-default-agent-unavailable-x", "profile-x") + + try: + assert projects.reconcile_agent_profile(context, "demo") is True + assert context.config.profile == CAPTAIN + assert context.agent0.config.profile == CAPTAIN + finally: + AgentContext.remove(context.id) From d9123ccd39a64255b1ebcaafbba497a84c40374d Mon Sep 17 00:00:00 2001 From: wgnrai Date: Tue, 18 Aug 2026 20:47:08 -0400 Subject: [PATCH 3/4] fix(projects): record manual-selection flag on all explicit profile paths The /agent integration command and external API context creation with an explicit agent_profile now record agent_profile_manually_set, matching agent_profile_set. Without this, explicit selections through those paths could still be overridden by project-default reconciliation. The api_message flag is set before activate_project so the flag exists before reconcile fires. Test 9 covers the /agent wiring end-to-end. --- api/api_message.py | 2 ++ api/api_message.py.dox.md | 1 + helpers/integration_commands.py | 1 + helpers/integration_commands.py.dox.md | 3 ++ tests/test_project_default_agent.py | 41 ++++++++++++++++++++++++++ 5 files changed, 48 insertions(+) diff --git a/api/api_message.py b/api/api_message.py index 1deb595899..9bc6a449fb 100644 --- a/api/api_message.py +++ b/api/api_message.py @@ -100,6 +100,8 @@ async def process(self, input: dict, request: Request) -> dict | Response: context = AgentContext(config=config, type=AgentContextType.USER) AgentContext.use(context.id) context_id = context.id + if agent_profile: + context.set_data("agent_profile_manually_set", True) # Activate project if provided if project_name: try: diff --git a/api/api_message.py.dox.md b/api/api_message.py.dox.md index 646939cadf..cad556864d 100644 --- a/api/api_message.py.dox.md +++ b/api/api_message.py.dox.md @@ -27,6 +27,7 @@ - `ApiMessage` defines `requires_csrf(...)`. - `ApiMessage` defines `requires_api_key(...)`. - Observed side-effect areas: filesystem reads, filesystem writes, settings/state persistence, secret handling, scheduler state. +- New-context creation with an explicit `agent_profile` records the `agent_profile_manually_set` context data flag (before project activation triggers reconciliation) so manual selections are protected from project-default reconciliation. - Imported dependency areas include: `agent`, `base64`, `datetime`, `helpers`, `helpers.api`, `helpers.print_style`, `helpers.projects`, `helpers.security`, `initialize`, `os`, `uuid`. ## Key Concepts diff --git a/helpers/integration_commands.py b/helpers/integration_commands.py index 34eb9fbfad..08c0b254f9 100644 --- a/helpers/integration_commands.py +++ b/helpers/integration_commands.py @@ -469,6 +469,7 @@ def _handle_agent(context: "AgentContext", args: str) -> str: config = initialize_agent(override_settings={"agent_profile": profile}) context.config = config context.agent0.config = config + context.set_data("agent_profile_manually_set", True) save_tmp_chat(context) mark_dirty_for_context(context.id, reason="integration_commands.agent_set") return f"Switched agent to {match.get('label') or profile}." diff --git a/helpers/integration_commands.py.dox.md b/helpers/integration_commands.py.dox.md index 86795ec28d..4dcf331d8a 100644 --- a/helpers/integration_commands.py.dox.md +++ b/helpers/integration_commands.py.dox.md @@ -23,6 +23,9 @@ - `/agent` switches the top-level chat profile and preserves existing subordinate agent profiles. Choices come from the shared presentation catalog, though status may report an existing chat using the utility profile. +- Explicit `/agent` selection records the `agent_profile_manually_set` context + data flag so manual selections are protected from project-default + reconciliation in `helpers/projects.py`. - `/model ` stores a per-chat global preset reference, `/model inherit` clears it, and status always reports the effective scoped-or-chat preset. `Default` is a real selectable preset, not an alias for clearing the chat selection. ## Key Concepts diff --git a/tests/test_project_default_agent.py b/tests/test_project_default_agent.py index 3824432649..8b17deffd7 100644 --- a/tests/test_project_default_agent.py +++ b/tests/test_project_default_agent.py @@ -195,3 +195,44 @@ def test_unavailable_profile_with_available_global_default_switches_to_project_d assert context.agent0.config.profile == CAPTAIN finally: AgentContext.remove(context.id) + + +def test_agent_command_sets_manual_flag_and_blocks_default_switch( + monkeypatch, tmp_path: Path +) -> None: + """The /agent integration command is an explicit selection path: it must + set agent_profile_manually_set so a later reconcile sweep cannot override + the selection with the project default agent.""" + from helpers import integration_commands + + _prepare_project_tree(monkeypatch, tmp_path) + _write_default_agent_file(tmp_path, {"agent": CAPTAIN}) + _stub_reconcile_deps( + monkeypatch, {GLOBAL_DEFAULT: GLOBAL_DEFAULT, CAPTAIN: CAPTAIN} + ) + monkeypatch.setattr( + subagents, + "get_all_agents_list", + lambda: [{"key": GLOBAL_DEFAULT, "label": "Global"}], + ) + monkeypatch.setattr( + integration_commands, "save_tmp_chat", lambda _context: None + ) + monkeypatch.setattr( + integration_commands, + "mark_dirty_for_context", + lambda _context_id, **_kwargs: None, + ) + context = _make_context("ctx-default-agent-slash-command", CAPTAIN) + + try: + result = integration_commands._handle_agent(context, GLOBAL_DEFAULT) + assert "Global" in result + assert context.config.profile == GLOBAL_DEFAULT + assert context.agent0.config.profile == GLOBAL_DEFAULT + assert context.get_data("agent_profile_manually_set") is True + + assert projects.reconcile_agent_profile(context, "demo") is False + assert context.config.profile == GLOBAL_DEFAULT + finally: + AgentContext.remove(context.id) From 84faa3f73ce74a92948a6254e05d736dca638e80 Mon Sep 17 00:00:00 2001 From: wgnrai Date: Tue, 18 Aug 2026 21:18:35 -0400 Subject: [PATCH 4/4] fix(plugins): record manual-selection flag on Telegram picker and connector chat creation Completes manual-selection coverage across all five explicit profile selection paths: agent_profile_set, /agent command, external API, Telegram inline agent picker, and connector create_context. The connector flag is set before activate_project so it exists before the first reconcile. Test 11 covers the connector ordering end-to-end. --- plugins/_a0_connector/AGENTS.md | 1 + plugins/_a0_connector/helpers/chat_context.py | 3 ++ plugins/_telegram_integration/AGENTS.md | 2 + .../helpers/command_ui.py | 1 + tests/test_project_default_agent.py | 47 +++++++++++++++++++ 5 files changed, 54 insertions(+) diff --git a/plugins/_a0_connector/AGENTS.md b/plugins/_a0_connector/AGENTS.md index a2d4cca8af..c8af1e5ab5 100644 --- a/plugins/_a0_connector/AGENTS.md +++ b/plugins/_a0_connector/AGENTS.md @@ -54,6 +54,7 @@ after all chunks for the `op_id` are assembled. - Host browser status metadata may advertise `available_browsers` entries with browser ids, labels, CDP endpoints, status, and enabled state; keep older CLI payloads without those fields compatible. - Model preset definitions exposed through v1 are global; project arguments select scope but never create project-owned definitions. Model switcher state reports the effective main, utility, and embedding models and preserves embedding-change notifications. +- Context creation with an explicit `agent_profile` records the `agent_profile_manually_set` context flag before project activation so project-default reconciliation cannot override the selection. - The protected v1 `agent_editor` route delegates to the bundled Agent Editor API and must not define another profile schema or write profile files itself. - The protected v1 `agents_list` response uses the shared agent presentation diff --git a/plugins/_a0_connector/helpers/chat_context.py b/plugins/_a0_connector/helpers/chat_context.py index 3020b8e1d6..a405567a2c 100644 --- a/plugins/_a0_connector/helpers/chat_context.py +++ b/plugins/_a0_connector/helpers/chat_context.py @@ -80,6 +80,9 @@ def create_context( set_current=True, ) + if agent_profile: + context.set_data("agent_profile_manually_set", True) + if current_context and settings.get_settings().get("chat_inherit_project", True): current_project = current_context.get_data(projects.CONTEXT_DATA_KEY_PROJECT) if current_project: diff --git a/plugins/_telegram_integration/AGENTS.md b/plugins/_telegram_integration/AGENTS.md index 256ac1ee6e..f0660880f6 100644 --- a/plugins/_telegram_integration/AGENTS.md +++ b/plugins/_telegram_integration/AGENTS.md @@ -20,6 +20,8 @@ preserve existing subordinate agent profiles. Picker rows and direct matches use the shared presentation catalog while current status may still report an existing chat that uses the utility profile. +- Explicit agent picker selections record the `agent_profile_manually_set` + context flag so project-default reconciliation cannot override them. - Model picker status shows the effective preset; clearing a chat override returns to its scoped preset rather than assuming `Default`. ## Work Guidance diff --git a/plugins/_telegram_integration/helpers/command_ui.py b/plugins/_telegram_integration/helpers/command_ui.py index 48b7e367e7..d0842cb779 100644 --- a/plugins/_telegram_integration/helpers/command_ui.py +++ b/plugins/_telegram_integration/helpers/command_ui.py @@ -423,6 +423,7 @@ async def _select_agent(context: AgentContext, index: int) -> None: config = initialize_agent(override_settings={"agent_profile": profile}) context.config = config context.agent0.config = config + context.set_data("agent_profile_manually_set", True) save_tmp_chat(context) mark_dirty_for_context(context.id, reason="telegram.agent_select") diff --git a/tests/test_project_default_agent.py b/tests/test_project_default_agent.py index 8b17deffd7..f385d0d7e0 100644 --- a/tests/test_project_default_agent.py +++ b/tests/test_project_default_agent.py @@ -4,6 +4,7 @@ import initialize from agent import AgentConfig, AgentContext from helpers import projects, settings, subagents +from helpers import state_monitor_integration from tests.test_projects import _prepare_project_tree @@ -236,3 +237,49 @@ def test_agent_command_sets_manual_flag_and_blocks_default_switch( assert context.config.profile == GLOBAL_DEFAULT finally: AgentContext.remove(context.id) + + +def test_connector_create_context_sets_flag_before_activate_project( + monkeypatch, tmp_path: Path +) -> None: + """The A0 connector's create_context is an explicit-selection path: with + agent_profile provided, the manual flag must be set BEFORE activate_project + triggers reconciliation.""" + from plugins._a0_connector.helpers import chat_context + from plugins._model_config.helpers import model_config as mc_model_config + + _prepare_project_tree(monkeypatch, tmp_path) + _stub_reconcile_deps( + monkeypatch, {GLOBAL_DEFAULT: GLOBAL_DEFAULT, CAPTAIN: CAPTAIN} + ) + monkeypatch.setattr( + initialize, "initialize_agent", lambda override_settings=None: AgentConfig( + mcp_servers="", + profile=(override_settings or {}).get("agent_profile", GLOBAL_DEFAULT), + ) + ) + monkeypatch.setattr(settings, "get_settings", lambda: {}) + monkeypatch.setattr( + state_monitor_integration, "mark_dirty_all", lambda **_kwargs: None + ) + monkeypatch.setattr(mc_model_config, "is_chat_override_allowed", lambda _agent: False) + + flag_at_activate: list[bool] = [] + + def _record_activate(context_id, name, **_kwargs): + from agent import AgentContext as AC + + ctx = AC.get(context_id) + flag_at_activate.append(bool(ctx and ctx.get_data("agent_profile_manually_set"))) + + monkeypatch.setattr(projects, "activate_project", _record_activate) + + try: + context = chat_context.create_context( + agent_profile=CAPTAIN, project_name="demo" + ) + assert context.config.profile == CAPTAIN + assert context.get_data("agent_profile_manually_set") is True + assert flag_at_activate == [True] + finally: + AgentContext.remove(context.id)