diff --git a/api/agent_profile_set.py b/api/agent_profile_set.py index 331e21fd0..bec0b2ef1 100644 --- a/api/agent_profile_set.py +++ b/api/agent_profile_set.py @@ -1,4 +1,4 @@ -from agent import Agent, AgentContext +from agent import AgentContext from helpers import subagents from helpers.api import ApiHandler, Request, Response from helpers.persist_chat import save_tmp_chat @@ -39,11 +39,7 @@ class SetAgentProfile(ApiHandler): config = initialize_agent(override_settings={"agent_profile": profile}) context.config = config - - agent = context.agent0 - while agent: - agent.config = config - agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE) + context.agent0.config = config 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 845ee9b1d..8e891085d 100644 --- a/api/agent_profile_set.py.dox.md +++ b/api/agent_profile_set.py.dox.md @@ -23,11 +23,12 @@ - `SetAgentProfile` is an `ApiHandler`. - `SetAgentProfile` defines `process(...)`. - Observed side-effect areas: filesystem writes, settings/state persistence. -- Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.persist_chat`, `helpers.state_monitor_integration`, `initialize`. +- Switching a chat profile updates the context and top-level agent profile only; existing subordinate agents keep their own profile configs. +- Imported dependency areas include: `agent`, `helpers`, `helpers.api`, `helpers.persist_chat`, `helpers.state_monitor_integration`. ## Key Concepts -- Important called helpers/classes observed in the source: `str.strip`, `context.is_running`, `_agent_profile_labels`, `initialize_agent`, `save_tmp_chat`, `mark_dirty_for_context`, `subagents.get_all_agents_list`, `Response`, `agent.get_data`. +- Important called helpers/classes observed in the source: `str.strip`, `context.is_running`, `_agent_profile_labels`, `initialize_agent`, `context.agent0.config`, `save_tmp_chat`, `mark_dirty_for_context`, `subagents.get_all_agents_list`, `Response`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance @@ -39,7 +40,8 @@ ## Verification - Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. -- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check. +- Related tests observed by source search: + - `tests/test_subagent_profiles.py` ## Child DOX Index diff --git a/helpers/integration_commands.py b/helpers/integration_commands.py index 24a78e60f..b4c4cc361 100644 --- a/helpers/integration_commands.py +++ b/helpers/integration_commands.py @@ -436,7 +436,6 @@ def _handle_model(context: "AgentContext", args: str) -> str: def _handle_agent(context: "AgentContext", args: str) -> str: - from agent import Agent from helpers import subagents from initialize import initialize_agent @@ -468,10 +467,7 @@ def _handle_agent(context: "AgentContext", args: str) -> str: config = initialize_agent(override_settings={"agent_profile": profile}) context.config = config - agent = context.agent0 - while agent: - agent.config = config - agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE) + context.agent0.config = config 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 b7462926e..1d00fb083 100644 --- a/helpers/integration_commands.py.dox.md +++ b/helpers/integration_commands.py.dox.md @@ -31,10 +31,11 @@ - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. - Observed side-effect areas: filesystem writes, model calls, plugin state, settings/state persistence. - Imported dependency areas include: `__future__`, `helpers`, `helpers.persist_chat`, `helpers.state_monitor_integration`, `plugins._model_config.helpers`, `re`, `typing`. +- `/agent` switches the top-level chat profile and preserves existing subordinate agent profiles. ## Key Concepts -- Important called helpers/classes observed in the source: `splitlines`, `extract_command_line`, `line.partition`, `command.strip.lower`, `parse_command`, `mq.get_queue`, `args.strip.lower`, `mq.send_all_aggregated`, `mark_dirty_for_context`, `_strip_quotes`, `_match_named_item`, `projects.activate_project`, `model_config.is_chat_override_allowed`, `context.get_data`, `context.set_data`, `save_tmp_chat`, `str.strip`, `value.strip`, `value.lower.strip`, `re.sub`. +- Important called helpers/classes observed in the source: `splitlines`, `extract_command_line`, `line.partition`, `command.strip.lower`, `parse_command`, `mq.get_queue`, `args.strip.lower`, `mq.send_all_aggregated`, `mark_dirty_for_context`, `_strip_quotes`, `_match_named_item`, `projects.activate_project`, `initialize_agent`, `model_config.is_chat_override_allowed`, `context.get_data`, `context.set_data`, `save_tmp_chat`, `str.strip`, `value.strip`, `value.lower.strip`, `re.sub`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance @@ -46,7 +47,8 @@ ## Verification - Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers. -- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check. +- Related tests observed by source search: + - `tests/test_subagent_profiles.py` ## Child DOX Index diff --git a/helpers/persist_chat.py b/helpers/persist_chat.py index d64a9b5db..2dd8e0663 100644 --- a/helpers/persist_chat.py +++ b/helpers/persist_chat.py @@ -132,8 +132,8 @@ def remove_msg_files(ctxid): def _serialize_context(context: AgentContext): profile = str( - getattr(context.config, "profile", None) - or getattr(context.agent0.config, "profile", None) + getattr(context.agent0.config, "profile", None) + or getattr(context.config, "profile", None) or "" ) @@ -180,6 +180,7 @@ def _serialize_agent(agent: Agent): return { "number": agent.number, + "agent_profile": str(getattr(agent.config, "profile", "") or ""), "data": data, "history": history, } @@ -232,11 +233,23 @@ def _deserialize_context(data): streaming_agent = streaming_agent.data.get(Agent.DATA_NAME_SUBORDINATE, None) context.agent0 = agent0 + context.config = agent0.config context.streaming_agent = streaming_agent return context +def _deserialize_agent_config( + agent_data: dict[str, Any], fallback_config: AgentConfig +) -> AgentConfig: + fallback_profile = str(getattr(fallback_config, "profile", "") or "") + profile = str(agent_data.get("agent_profile") or fallback_profile).strip() + if profile == fallback_profile: + return fallback_config + override_settings = {"agent_profile": profile} if profile else None + return initialize_agent(override_settings=override_settings) + + def _deserialize_agents( agents: list[dict[str, Any]], config: AgentConfig, context: AgentContext ) -> Agent: @@ -246,7 +259,7 @@ def _deserialize_agents( for ag in agents: current = Agent( number=ag["number"], - config=config, + config=_deserialize_agent_config(ag, config), context=context, ) current.data = ag.get("data", {}) diff --git a/helpers/persist_chat.py.dox.md b/helpers/persist_chat.py.dox.md index bf251b8e7..3cc103ee7 100644 --- a/helpers/persist_chat.py.dox.md +++ b/helpers/persist_chat.py.dox.md @@ -28,6 +28,7 @@ - `_serialize_agent(agent: Agent)` - `_serialize_log(log: Log)` - `_deserialize_context(data)` +- `_deserialize_agent_config(agent_data: dict[str, Any], fallback_config: AgentConfig) -> AgentConfig` - `_deserialize_agents(agents: list[dict[str, Any]], config: AgentConfig, context: AgentContext) -> Agent` - `_deserialize_log(data: dict[str, Any]) -> 'Log'` - `_safe_json_serialize(obj, **kwargs)` @@ -39,10 +40,12 @@ - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, settings/state persistence, scheduler state. - Imported dependency areas include: `agent`, `collections`, `datetime`, `helpers`, `helpers.localization`, `helpers.log`, `initialize`, `json`, `typing`, `uuid`. +- Serialized chats store `agent_profile` both at the context level for the main chat and on each serialized agent so subordinate profiles survive server restart. +- Deserialization must rebuild each agent with its serialized profile when present, falling back to the context profile for older chat files. ## Key Concepts -- Important called helpers/classes observed in the source: `datetime.fromtimestamp.isoformat`, `datetime.fromisoformat`, `files.get_abs_path`, `_get_chat_file_path`, `files.make_dirs`, `_serialize_context`, `_safe_json_serialize`, `files.write_file`, `_convert_v080_chats`, `files.list_files`, `get_chat_folder_path`, `files.delete_dir`, `get_chat_msg_files_folder`, `agent.history.serialize`, `initialize_agent`, `_deserialize_log`, `AgentContext`, `_deserialize_agents`, `Log`, `log.set_initial_progress`. +- Important called helpers/classes observed in the source: `datetime.fromtimestamp.isoformat`, `datetime.fromisoformat`, `files.get_abs_path`, `_get_chat_file_path`, `files.make_dirs`, `_serialize_context`, `_safe_json_serialize`, `files.write_file`, `_convert_v080_chats`, `files.list_files`, `get_chat_folder_path`, `files.delete_dir`, `get_chat_msg_files_folder`, `agent.history.serialize`, `initialize_agent`, `_deserialize_log`, `AgentContext`, `_deserialize_agent_config`, `_deserialize_agents`, `Log`, `log.set_initial_progress`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance @@ -58,6 +61,7 @@ - `tests/test_api_chat_lifetime.py` - `tests/test_browser_agent_regressions.py` - `tests/test_persist_chat_log_ids.py` + - `tests/test_subagent_profiles.py` - `tests/test_tool_action_contracts.py` ## Child DOX Index diff --git a/helpers/settings.py b/helpers/settings.py index 88eea749e..3f8121d26 100644 --- a/helpers/settings.py +++ b/helpers/settings.py @@ -572,18 +572,27 @@ def _apply_settings(previous: Settings | None, browser_timezone: str | None = No if _settings: _apply_timezone_setting(previous, browser_timezone) - from agent import AgentContext + from agent import Agent, AgentContext from initialize import initialize_agent for ctx in AgentContext.all(): - profile = str(getattr(ctx.config, "profile", "") or _settings["agent_profile"]) - config = initialize_agent(override_settings={"agent_profile": profile}) - ctx.config = config # reinitialize context config with new settings - # apply config to agents + profile = str( + getattr(ctx.config, "profile", "") or _settings["agent_profile"] + ) + ctx.config = initialize_agent(override_settings={"agent_profile": profile}) agent = ctx.agent0 while agent: - agent.config = ctx.config - agent = agent.get_data(agent.DATA_NAME_SUBORDINATE) + agent_profile = str( + getattr(getattr(agent, "config", None), "profile", "") or profile + ) + agent.config = ( + ctx.config + if agent is ctx.agent0 and agent_profile == profile + else initialize_agent( + override_settings={"agent_profile": agent_profile} + ) + ) + agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE) # update mcp settings if necessary if not previous or _settings["mcp_servers"] != previous["mcp_servers"]: diff --git a/helpers/settings.py.dox.md b/helpers/settings.py.dox.md index ef35d05c0..f24270c8b 100644 --- a/helpers/settings.py.dox.md +++ b/helpers/settings.py.dox.md @@ -61,7 +61,8 @@ ## Key Concepts -- Important called helpers/classes observed in the source: `TypeVar`, `files.get_abs_path`, `dotenv.get_dotenv_value`, `opts.insert`, `str.strip`, `_is_valid_timezone`, `str.strip.lower`, `_normalize_timezone_setting`, `SettingsOutput`, `get_default_settings`, `_ensure_option_present`, `_resolve_runtime_timezone`, `get_default_secrets_manager`, `get_settings`, `normalize_settings`, `_load_sensitive_settings`, `settings.copy`, `_write_settings_file`, `reload_settings`, `set_settings`. +- Important called helpers/classes observed in the source: `TypeVar`, `files.get_abs_path`, `dotenv.get_dotenv_value`, `opts.insert`, `str.strip`, `_is_valid_timezone`, `str.strip.lower`, `_normalize_timezone_setting`, `SettingsOutput`, `get_default_settings`, `_ensure_option_present`, `_resolve_runtime_timezone`, `get_default_secrets_manager`, `get_settings`, `normalize_settings`, `_load_sensitive_settings`, `settings.copy`, `_write_settings_file`, `reload_settings`, `set_settings`, `initialize_agent`. +- Applying settings refreshes active context configs while preserving each subordinate agent's own profile. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance @@ -82,6 +83,7 @@ - `tests/test_model_config_api_keys.py` - `tests/test_model_config_project_presets.py` - `tests/test_oauth_static.py` + - `tests/test_subagent_profiles.py` ## Child DOX Index diff --git a/helpers/subagents.py b/helpers/subagents.py index f3557a49e..df9d5ae20 100644 --- a/helpers/subagents.py +++ b/helpers/subagents.py @@ -433,4 +433,4 @@ def get_paths( # end-of-file imports to prevent circular imports -from helpers import plugins \ No newline at end of file +from helpers import plugins diff --git a/plugins/_telegram_integration/AGENTS.md b/plugins/_telegram_integration/AGENTS.md index bbbc062f0..b39f92ad0 100644 --- a/plugins/_telegram_integration/AGENTS.md +++ b/plugins/_telegram_integration/AGENTS.md @@ -16,6 +16,7 @@ - Treat bot tokens, chat IDs, attachments, and user data as sensitive. - Keep allowed-user, group-mode, project, model, and `/send` controls enforced. - Install Telegram dependencies into the framework runtime only when required. +- Agent profile picker actions change the top-level chat profile and must preserve existing subordinate agent profiles. ## Work Guidance diff --git a/plugins/_telegram_integration/helpers/command_ui.py b/plugins/_telegram_integration/helpers/command_ui.py index b31dc0501..50f20f75b 100644 --- a/plugins/_telegram_integration/helpers/command_ui.py +++ b/plugins/_telegram_integration/helpers/command_ui.py @@ -413,7 +413,6 @@ async def _select_project(context: AgentContext, index: int, *, clear: bool = Fa async def _select_agent(context: AgentContext, index: int) -> None: if context.is_running(): return - from agent import Agent from initialize import initialize_agent items = [item for item in subagents.get_all_agents_list() if item.get("key")] @@ -422,10 +421,7 @@ async def _select_agent(context: AgentContext, index: int) -> None: profile = str(items[index]["key"]) config = initialize_agent(override_settings={"agent_profile": profile}) context.config = config - agent = context.agent0 - while agent: - agent.config = config - agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE) + context.agent0.config = config save_tmp_chat(context) mark_dirty_for_context(context.id, reason="telegram.agent_select") diff --git a/prompts/agent.system.tool.call_sub.md b/prompts/agent.system.tool.call_sub.md index 3cd630fce..5e69c6206 100644 --- a/prompts/agent.system.tool.call_sub.md +++ b/prompts/agent.system.tool.call_sub.md @@ -1,7 +1,7 @@ ### call_subordinate delegate research or complex subtasks to a specialized agent. args: `message`, optional `profile`, `reset` -- `profile`: optional prompt profile name for the subordinate; leave empty for the default profile +- `profile`: optional prompt profile key for the subordinate; when provided, it must exactly match an available profile; leave empty for the default profile - `reset`: use json boolean `true` for the first message or when changing profile; use `false` to continue - `message`: define role, goal, and the concrete task after the subordinate returns, answer from its result directly when it satisfies the user request diff --git a/tests/test_subagent_profiles.py b/tests/test_subagent_profiles.py new file mode 100644 index 000000000..d6a81abf7 --- /dev/null +++ b/tests/test_subagent_profiles.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from agent import Agent, AgentConfig, AgentContext +from helpers import persist_chat +from helpers.errors import RepairableException + + +class _FakeContext: + id = "ctx" + + def get_data(self, key: str, recursive: bool = True): + return None + + +class _FakeParentAgent: + def __init__(self) -> None: + self.number = 0 + self.agent_name = "A0" + self.config = AgentConfig(mcp_servers="", profile="agent0") + self.context = _FakeContext() + self.data = {} + + def get_data(self, key: str): + return self.data.get(key) + + def set_data(self, key: str, value): + self.data[key] = value + + def read_prompt(self, _file: str, **_kwargs) -> str: + return "" + + +class _FakeSubAgent: + DATA_NAME_SUPERIOR = "_superior" + DATA_NAME_SUBORDINATE = "_subordinate" + + def __init__(self, number: int, config: AgentConfig, context) -> None: + self.number = number + self.config = config + self.context = context + self.data = {} + self.history = SimpleNamespace(new_topic=lambda: None) + self.messages = [] + + def set_data(self, key: str, value): + self.data[key] = value + + def hist_add_user_message(self, message): + self.messages.append(message) + + async def monologue(self): + return "delegated" + + +@pytest.mark.asyncio +async def test_call_subordinate_rejects_unknown_profile(monkeypatch) -> None: + import tools.call_subordinate as call_subordinate + + monkeypatch.setattr( + call_subordinate, + "_subordinate_profile_labels", + lambda _agent: {"developer": "Developer", "researcher": "Researcher"}, + ) + parent = _FakeParentAgent() + tool = call_subordinate.Delegation( + parent, # type: ignore[arg-type] + "call_subordinate", + None, + {"profile": "ghost", "message": "work"}, + "", + None, + ) + + with pytest.raises(RepairableException, match="Agent profile 'ghost' not found"): + await tool.execute(message="work", profile="ghost", reset=True) + + assert parent.data == {} + + +@pytest.mark.asyncio +async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None: + import tools.call_subordinate as call_subordinate + + monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent) + monkeypatch.setattr( + call_subordinate, + "_subordinate_profile_labels", + lambda _agent: {"developer": "Developer"}, + ) + monkeypatch.setattr( + call_subordinate, + "initialize_agent", + lambda override_settings=None: AgentConfig( + mcp_servers="", + profile=(override_settings or {}).get("agent_profile", "agent0"), + ), + ) + + parent = _FakeParentAgent() + tool = call_subordinate.Delegation( + parent, # type: ignore[arg-type] + "call_subordinate", + None, + {"profile": "developer", "message": "work"}, + "", + None, + ) + + response = await tool.execute(message="work", profile="developer", reset=True) + child = parent.get_data(_FakeSubAgent.DATA_NAME_SUBORDINATE) + + assert response.message == "delegated" + assert child.config.profile == "developer" + assert child.messages[0].message == "work" + + +@pytest.mark.asyncio +async def test_call_subordinate_requires_reset_to_change_existing_profile(monkeypatch) -> None: + import tools.call_subordinate as call_subordinate + + monkeypatch.setattr( + call_subordinate, + "_subordinate_profile_labels", + lambda _agent: {"developer": "Developer", "researcher": "Researcher"}, + ) + + parent = _FakeParentAgent() + existing = SimpleNamespace(config=AgentConfig(mcp_servers="", profile="developer")) + parent.set_data(_FakeSubAgent.DATA_NAME_SUBORDINATE, existing) + tool = call_subordinate.Delegation( + parent, # type: ignore[arg-type] + "call_subordinate", + None, + {"profile": "researcher", "message": "work"}, + "", + None, + ) + + with pytest.raises(RepairableException, match="Set reset=true"): + await tool.execute(message="work", profile="researcher", reset=False) + + +def test_persist_chat_roundtrip_preserves_each_agent_profile(monkeypatch) -> None: + monkeypatch.setattr( + persist_chat, + "initialize_agent", + lambda override_settings=None: AgentConfig( + mcp_servers="", + profile=(override_settings or {}).get("agent_profile", "agent0"), + ), + ) + + context_id = "ctx-subagent-profile" + AgentContext.remove(context_id) + context = AgentContext( + config=AgentConfig(mcp_servers="", profile="agent0"), + id=context_id, + set_current=False, + ) + child = Agent(1, AgentConfig(mcp_servers="", profile="developer"), context) + context.agent0.set_data(Agent.DATA_NAME_SUBORDINATE, child) + child.set_data(Agent.DATA_NAME_SUPERIOR, context.agent0) + + try: + serialized = persist_chat._serialize_context(context) + assert serialized["agent_profile"] == "agent0" + assert serialized["agents"][0]["agent_profile"] == "agent0" + assert serialized["agents"][1]["agent_profile"] == "developer" + + AgentContext.remove(context_id) + restored = persist_chat._deserialize_context(serialized) + restored_child = restored.agent0.get_data(Agent.DATA_NAME_SUBORDINATE) + + assert restored.config.profile == "agent0" + assert restored.agent0.config.profile == "agent0" + assert restored_child.config.profile == "developer" + finally: + AgentContext.remove(context_id) + + +@pytest.mark.asyncio +async def test_agent_profile_set_preserves_subagent_profile(monkeypatch) -> None: + import api.agent_profile_set as agent_profile_set + + monkeypatch.setattr( + agent_profile_set, + "_agent_profile_labels", + lambda: {"researcher": "Researcher"}, + ) + monkeypatch.setattr( + agent_profile_set, + "initialize_agent", + lambda override_settings=None: AgentConfig( + mcp_servers="", + profile=(override_settings or {}).get("agent_profile", "agent0"), + ), + ) + monkeypatch.setattr(agent_profile_set, "save_tmp_chat", lambda _context: None) + monkeypatch.setattr( + agent_profile_set, + "mark_dirty_for_context", + lambda *_args, **_kwargs: None, + ) + + context_id = "ctx-profile-switch" + AgentContext.remove(context_id) + context = AgentContext( + config=AgentConfig(mcp_servers="", profile="agent0"), + id=context_id, + set_current=False, + ) + child = Agent(1, AgentConfig(mcp_servers="", profile="developer"), context) + context.agent0.set_data(Agent.DATA_NAME_SUBORDINATE, child) + child.set_data(Agent.DATA_NAME_SUPERIOR, context.agent0) + + try: + handler = agent_profile_set.SetAgentProfile.__new__( + agent_profile_set.SetAgentProfile + ) + response = await handler.process( + {"context_id": context_id, "agent_profile": "researcher"}, + request=None, # type: ignore[arg-type] + ) + + assert response["ok"] is True + assert context.config.profile == "researcher" + assert context.agent0.config.profile == "researcher" + assert child.config.profile == "developer" + finally: + AgentContext.remove(context_id) diff --git a/tools/call_subordinate.py b/tools/call_subordinate.py index 55b827c92..f355ef212 100644 --- a/tools/call_subordinate.py +++ b/tools/call_subordinate.py @@ -1,26 +1,71 @@ from agent import Agent, UserMessage +from helpers import projects, subagents +from helpers.errors import RepairableException from helpers.tool import Tool, Response from initialize import initialize_agent from extensions.python.hist_add_tool_result import _90_save_tool_call_file as save_tool_call_file +def _subordinate_profile_labels(agent: Agent) -> dict[str, str]: + project = projects.get_context_project_name(agent.context) if agent.context else None + return { + name: subagent.title or name + for name, subagent in subagents.get_available_agents_dict(project).items() + } + + +def _validate_subordinate_profile(agent: Agent, profile: str) -> str: + agent_profile = str(profile or "").strip() + if not agent_profile: + return "" + + labels = _subordinate_profile_labels(agent) + if agent_profile in labels: + return agent_profile + + available = ", ".join( + f"{key} ({label})" if label and label != key else key + for key, label in sorted(labels.items()) + ) + if not available: + available = "none" + raise RepairableException( + f"Agent profile '{agent_profile}' not found. Use one of the available profiles: {available}." + ) + + class Delegation(Tool): async def execute(self, message="", reset="", **kwargs): + requested_profile = _validate_subordinate_profile( + self.agent, kwargs.get("profile", kwargs.get("agent_profile", "")) + ) + existing_subordinate = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE) + reset_requested = str(reset).lower().strip() == "true" + + if existing_subordinate and requested_profile and not reset_requested: + current_profile = str( + getattr(getattr(existing_subordinate, "config", None), "profile", "") + or "" + ) + if current_profile != requested_profile: + raise RepairableException( + f"Subordinate already uses profile '{current_profile or 'default'}'. " + f"Set reset=true to switch to '{requested_profile}'." + ) + # create subordinate agent using the data object on this agent and set superior agent to his data object if ( - self.agent.get_data(Agent.DATA_NAME_SUBORDINATE) is None - or str(reset).lower().strip() == "true" + existing_subordinate is None + or reset_requested ): - # initialize default config - config = initialize_agent() + # set subordinate prompt profile if provided, otherwise use the default profile + override_settings = ( + {"agent_profile": requested_profile} if requested_profile else None + ) + config = initialize_agent(override_settings=override_settings) - # set subordinate prompt profile if provided, if not, keep original - agent_profile = kwargs.get("profile", kwargs.get("agent_profile", "")) - if agent_profile: - config.profile = agent_profile - - # crate agent + # create agent sub = Agent(self.agent.number + 1, config, self.agent.context) # register superior/subordinate sub.set_data(Agent.DATA_NAME_SUPERIOR, self.agent) diff --git a/tools/call_subordinate.py.dox.md b/tools/call_subordinate.py.dox.md index 544873f0b..ea6d07dee 100644 --- a/tools/call_subordinate.py.dox.md +++ b/tools/call_subordinate.py.dox.md @@ -14,6 +14,9 @@ - `Delegation` (`Tool`) - `async execute(self, message=..., reset=..., **kwargs)` - `get_log_object(self)` +- Top-level functions: +- `_subordinate_profile_labels(agent: Agent) -> dict[str, str]` +- `_validate_subordinate_profile(agent: Agent, profile: str) -> str` ## Runtime Contracts @@ -22,11 +25,13 @@ - `Delegation` is a `Tool`. - `Delegation` defines `execute(...)`. - Observed side-effect areas: filesystem writes, settings/state persistence. -- Imported dependency areas include: `agent`, `extensions.python.hist_add_tool_result`, `helpers.tool`, `initialize`. +- `profile`/`agent_profile` values are validated against available profile keys before use; unknown profiles raise `RepairableException` so the agent can retry with a real profile. +- Supplying a different profile for an existing subordinate without `reset=true` raises `RepairableException` instead of silently continuing the old subordinate. +- Imported dependency areas include: `agent`, `extensions.python.hist_add_tool_result`, `helpers`, `helpers.errors`, `helpers.tool`. ## Key Concepts -- Important called helpers/classes observed in the source: `self.agent.get_data`, `subordinate.hist_add_user_message`, `subordinate.history.new_topic`, `Response`, `self.agent.context.log.log`, `initialize_agent`, `Agent`, `sub.set_data`, `self.agent.set_data`, `UserMessage`, `subordinate.monologue`, `self.agent.read_prompt`, `str.lower.strip`, `str.lower`. +- Important called helpers/classes observed in the source: `self.agent.get_data`, `projects.get_context_project_name`, `subagents.get_available_agents_dict`, `RepairableException`, `initialize_agent`, `subordinate.hist_add_user_message`, `subordinate.history.new_topic`, `Response`, `self.agent.context.log.log`, `Agent`, `sub.set_data`, `self.agent.set_data`, `UserMessage`, `subordinate.monologue`, `self.agent.read_prompt`, `str.lower.strip`, `str.lower`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance @@ -40,6 +45,7 @@ - Run targeted tool and prompt-contract tests for changed behavior; smoke-test agent execution when no focused test exists. - Related tests observed by source search: - `tests/test_default_prompt_budget.py` + - `tests/test_subagent_profiles.py` ## Child DOX Index