Make agent profiles context scoped

Persist the active agent profile with each chat context and add a context-scoped endpoint for switching profiles without mutating global settings. Update the WebUI selector and docs to treat settings as the default for new chats, and expose the switch through the A0 connector plugin.

Alessandro committed Apr 26, 2026 at 22:27 UTC 56a42b97d7eabd6c3afaaa512f92125995465916
9 files changed +90 -21
api/agent_profile_set.py new
+54
@@ -0,0 +1,54 @@
1 +from agent import Agent, AgentContext
2 +from helpers import subagents
3 +from helpers.api import ApiHandler, Request, Response
4 +from helpers.persist_chat import save_tmp_chat
5 +from helpers.state_monitor_integration import mark_dirty_for_context
6 +from initialize import initialize_agent
7 +
8 +
9 +def _agent_profile_labels() -> dict[str, str]:
10 + return {
11 + str(item.get("key") or ""): str(item.get("label") or item.get("key") or "")
12 + for item in subagents.get_all_agents_list()
13 + if item.get("key")
14 + }
15 +
16 +
17 +class SetAgentProfile(ApiHandler):
18 + async def process(self, input: dict, request: Request) -> dict | Response:
19 + context_id = str(input.get("context_id", "") or "").strip()
20 + profile = str(input.get("agent_profile", "") or "").strip()
21 +
22 + if not context_id:
23 + return Response(status=400, response="Missing context_id")
24 + if not profile:
25 + return Response(status=400, response="Missing agent_profile")
26 +
27 + context = AgentContext.get(context_id)
28 + if not context:
29 + return Response(status=404, response="Context not found")
30 + if context.is_running():
31 + return Response(
32 + status=409,
33 + response="Agent profile can be changed after the current run finishes.",
34 + )
35 +
36 + labels = _agent_profile_labels()
37 + if profile not in labels:
38 + return Response(status=404, response=f"Agent profile '{profile}' not found")
39 +
40 + config = initialize_agent(override_settings={"agent_profile": profile})
41 + context.config = config
42 +
43 + agent = context.agent0
44 + while agent:
45 + agent.config = config
46 + agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE)
47 +
48 + save_tmp_chat(context)
49 + mark_dirty_for_context(context.id, reason="agent_profile_change")
50 + return {
51 + "ok": True,
52 + "agent_profile": profile,
53 + "agent_profile_label": labels.get(profile, profile),
54 + }
docs/guides/agent-profiles.md
+1 -1
@@ -2,7 +2,7 @@
2
3 Agent profiles let you give Agent Zero different identities, prompt overrides, tools, extensions, and optionally model settings. Use them when you want a specialized agent such as a researcher, developer, security auditor, copywriter, or domain-specific assistant.
4
5 -You can select the active profile from **Settings > Agent Config** or from the chat composer profile selector.
5 +Use **Settings > Agent Config** to choose the default profile for new chats. The chat composer profile selector shows and changes the profile for the currently selected chat only, so different chats can keep different active profiles.
6
7 ## Where Profiles Live
8
docs/setup/installation.md
+1 -1
@@ -318,7 +318,7 @@ See the [Agent Profiles guide](../guides/agent-profiles.md) for profile file loc
318 > Since v0.9.7, custom prompts belong in `/a0/agents/<agent_name>/prompts/` rather than a shared `/prompts` folder. See the [Extensions guide](../developer/extensions.md#prompts) for details.
319
320 > [!NOTE]
321 -> The Hacker profile is included in the main image. After launch, choose the **hacker** agent profile in Settings if you want the security-focused prompts and tooling. The "hacker" branch is deprecated.
321 +> The Hacker profile is included in the main image. After launch, choose the **hacker** agent profile in Settings to make it the default for new chats, or switch the selected chat from the composer profile selector. The "hacker" branch is deprecated.
322
323 ![settings](../res/setup/settings/1-agentConfig.png)
324
helpers/persist_chat.py
+10 -1
@@ -116,6 +116,12 @@ def remove_msg_files(ctxid):
116
117
118 def _serialize_context(context: AgentContext):
119 + profile = str(
120 + getattr(context.config, "profile", None)
121 + or getattr(context.agent0.config, "profile", None)
122 + or ""
123 + )
124 +
125 # serialize agents
126 agents = []
127 agent = context.agent0
@@ -145,6 +151,7 @@ def _serialize_context(context: AgentContext):
151 "streaming_agent": (
152 context.streaming_agent.number if context.streaming_agent else 0
153 ),
154 + "agent_profile": profile,
155 "log": _serialize_log(context.log),
156 "data": data,
157 "output_data": output_data,
@@ -179,7 +186,9 @@ def _serialize_log(log: Log):
186
187
188 def _deserialize_context(data):
182 - config = initialize_agent()
189 + profile = data.get("agent_profile")
190 + override_settings = {"agent_profile": profile} if profile else None
191 + config = initialize_agent(override_settings=override_settings)
192 log = _deserialize_log(data.get("log", None))
193
194 context = AgentContext(
helpers/settings.py
+2 -1
@@ -496,8 +496,9 @@ def _apply_settings(previous: Settings | None):
496 from agent import AgentContext
497 from initialize import initialize_agent
498
499 - config = initialize_agent()
499 for ctx in AgentContext.all():
500 + profile = str(getattr(ctx.config, "profile", "") or _settings["agent_profile"])
501 + config = initialize_agent(override_settings={"agent_profile": profile})
502 ctx.config = config # reinitialize context config with new settings
503 # apply config to agents
504 agent = ctx.agent0
plugins/_a0_connector/api/v1/agent_profile_set.py new
+13
@@ -0,0 +1,13 @@
1 +"""POST /api/plugins/_a0_connector/v1/agent_profile_set."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +class AgentProfileSet(connector_base.ProtectedConnectorApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + from api.agent_profile_set import SetAgentProfile
11 +
12 + handler = SetAgentProfile(self.app, self.thread_lock)
13 + return await handler.process(input, request)
plugins/_a0_connector/api/v1/capabilities.py
+1
@@ -29,6 +29,7 @@ _BASE_FEATURES = [
29 _OPTIONAL_FEATURES: dict[str, tuple[str, ...]] = {
30 "settings_get": ("helpers.settings", "helpers.subagents"),
31 "settings_set": ("helpers.settings", "helpers.subagents"),
32 + "agent_profile_set": ("api.agent_profile_set",),
33 "agents_list": ("helpers.subagents",),
34 "skills_list": ("helpers.skills", "helpers.files", "helpers.projects", "helpers.runtime"),
35 "skills_delete": ("helpers.skills", "helpers.files", "helpers.projects", "helpers.runtime"),
plugins/_model_config/webui/switcher-mixin.js
+7 -16
@@ -144,35 +144,26 @@ export const switcherMethods = {
144 return false;
145 }
146
147 - const activeProfile = selectedContext?.agent_profile || this.agentProfileSettings?.agent_profile || "";
147 + const activeProfile = selectedContext?.agent_profile || "";
148 if (activeProfile === agentProfile) return true;
149
150 this.agentProfileSaving = true;
151 try {
152 await this.loadAgentProfiles();
153 - const settings = { ...(this.agentProfileSettings || {}), agent_profile: agentProfile };
154 - const res = await fetchApi("/settings_set", {
153 + const res = await fetchApi("/agent_profile_set", {
154 method: "POST",
155 headers: { "Content-Type": "application/json" },
157 - body: JSON.stringify({ settings }),
156 + body: JSON.stringify({ context_id: contextId, agent_profile: agentProfile }),
157 });
158 + if (!res.ok) throw new Error(await res.text());
159 const data = await res.json();
160 - if (!data.settings) return false;
160 + if (!data.ok) return false;
161
162 - this.agentProfileSettings = data.settings;
163 - this.agentProfiles = (data.additional?.agent_subdirs || this.agentProfiles)
164 - .map(profile => ({
165 - key: profile.value || profile.key || "",
166 - label: profile.label || profile.value || profile.key || "",
167 - }))
168 - .filter(profile => profile.key && profile.key !== "_example");
169 -
170 - const label = this.agentProfiles.find(profile => profile.key === agentProfile)?.label || agentProfile;
162 + const label = data.agent_profile_label || this.agentProfiles.find(profile => profile.key === agentProfile)?.label || agentProfile;
163 if (selectedContext) {
172 - selectedContext.agent_profile = agentProfile;
164 + selectedContext.agent_profile = data.agent_profile || agentProfile;
165 selectedContext.agent_profile_label = label;
166 }
175 - document.dispatchEvent(new CustomEvent("settings-updated", { detail: this.agentProfileSettings }));
167 window.justToast?.(`Agent profile: ${label}`, "success", 1600, "agent-profile-switch");
168 return true;
169 } catch (e) {
webui/components/settings/agent/agent.html
+1 -1
@@ -16,7 +16,7 @@
16 <div class="field-label">
17 <div class="field-title">Default agent profile</div>
18 <div class="field-description">
19 - Subdirectory of /agents folder to be used by default agent no. 0. Subordinate agents can be spawned with other profiles, that is on their superior agent to decide. This setting affects the behaviour of the top level agent you communicate with.
19 + Profile used for new top-level chats. Existing chats keep their own active profile; use the chat composer selector to change the selected chat.
20 </div>
21 </div>
22 <div class="field-control">