mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
Preserve subordinate agent profiles
Validate call_subordinate profile arguments against available agent profiles so missing profiles fail as repairable errors. Persist each agent's profile in saved chats and avoid flattening existing subordinate profiles during profile switches, settings refresh, or restart reload.
This commit is contained in:
parent
630bfc16f2
commit
b9153f718e
15 changed files with 353 additions and 47 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -433,4 +433,4 @@ def get_paths(
|
|||
|
||||
|
||||
# end-of-file imports to prevent circular imports
|
||||
from helpers import plugins
|
||||
from helpers import plugins
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
234
tests/test_subagent_profiles.py
Normal file
234
tests/test_subagent_profiles.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue