agent-zero/tools/call_subordinate.py
Alessandro f4700b4256
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions
Track direct subordinate execution with its own context task
Direct delegation awaited the child's monologue without assigning a task to
its separate context. Child chats therefore appeared idle while working and
could not be stopped independently through the normal context controls.

Run direct delegation through the child's existing AgentContext.run_task and
await its result. Propagate caller cancellation to the child, while treating
a child-only Stop as a recoverable error for the parent. Preserve inline
execution for legacy shared-context agents and existing parallel job ownership.

Add lifecycle coverage for direct and nested completion, failure, parent/child
Stop, intervention, and legacy execution. Update profile test doubles and the
tool's ownership documentation.

Validation: 121 focused checks passed; the nine lifecycle cases also passed
in the container framework runtime. Live UI checks verified Working/Paused
transitions, child reuse, completion, and parent recovery after child-only Stop.
2026-09-30 15:55:03 +02:00

268 lines
10 KiB
Python

import asyncio
from agent import Agent, AgentContext, UserMessage
from helpers import message_queue, persist_chat, 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
from plugins._model_config.helpers.model_config import DEFAULT_PRESET_NAME, get_configured_preset_name
SUBORDINATES_DATA_KEY = "_subordinates"
CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id"
CHILD_PARENT_AGENT_NUMBER_KEY = "parent_agent_number"
CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind"
CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label"
CHILD_SUBORDINATE_SLOT_KEY = "subordinate_slot"
DEFAULT_SUBORDINATE_SLOT = "default"
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}."
)
def _register_subordinate(parent: Agent, subordinate: Agent, slot: str) -> None:
subordinates = parent.get_data(SUBORDINATES_DATA_KEY)
if not isinstance(subordinates, dict):
subordinates = {}
parent.set_data(SUBORDINATES_DATA_KEY, subordinates)
subordinates[subordinate.context.id] = subordinate
subordinate.set_data(Agent.DATA_NAME_SUPERIOR, parent)
if slot == DEFAULT_SUBORDINATE_SLOT and subordinate.context is parent.context:
parent.set_data(Agent.DATA_NAME_SUBORDINATE, subordinate)
def _is_child_context(context: AgentContext, parent: Agent, slot: str | None = None) -> bool:
if context.get_output_data(CHILD_PARENT_CONTEXT_ID_KEY) != parent.context.id:
return False
if context.get_output_data(CHILD_PARENT_AGENT_NUMBER_KEY) != parent.number:
return False
if context.agent0.number != parent.number + 1:
return False
return slot is None or context.get_output_data(CHILD_SUBORDINATE_SLOT_KEY) == slot
def _is_live_context(context: AgentContext) -> bool:
return not isinstance(context, AgentContext) or AgentContext.get(context.id) is context
def _find_subordinate(parent: Agent, context_id: str, slot: str) -> Agent | None:
registered = parent.get_data(SUBORDINATES_DATA_KEY)
registered = registered if isinstance(registered, dict) else {}
if context_id:
subordinate = registered.get(context_id)
if (
subordinate
and _is_live_context(subordinate.context)
and _is_child_context(subordinate.context, parent)
):
return subordinate
context = AgentContext.get(context_id)
if not context or not _is_child_context(context, parent):
raise RepairableException(
f"Subordinate context '{context_id}' was not found under {parent.agent_name}."
)
subordinate = context.agent0
_register_subordinate(parent, subordinate, slot)
return subordinate
existing = parent.get_data(Agent.DATA_NAME_SUBORDINATE)
if slot == DEFAULT_SUBORDINATE_SLOT and existing is not None:
return existing
registered_matches = [
subordinate
for subordinate in registered.values()
if _is_live_context(subordinate.context)
and _is_child_context(subordinate.context, parent, slot)
]
if registered_matches:
return max(
registered_matches,
key=lambda subordinate: subordinate.context.created_at,
)
matches = [
context
for context in AgentContext.all()
if _is_child_context(context, parent, slot)
]
if not matches:
return None
subordinate = max(matches, key=lambda context: context.created_at).agent0
_register_subordinate(parent, subordinate, slot)
return subordinate
def get_or_create_subordinate(
parent: Agent,
*,
profile: str = "",
reset: bool | str = False,
context_id: str = "",
name: str = "",
message: str = "",
slot: str = DEFAULT_SUBORDINATE_SLOT,
) -> Agent:
requested_profile = _validate_subordinate_profile(parent, profile)
target_context_id = str(context_id or "").strip()
reset_requested = str(reset).lower().strip() == "true"
if target_context_id and reset_requested:
raise RepairableException(
"`context_id` continues an existing subordinate and requires reset=false. "
"Omit `context_id` to create a fresh subordinate."
)
subordinate = (
None
if reset_requested
else _find_subordinate(parent, target_context_id, slot)
)
if subordinate:
current_profile = str(getattr(subordinate.config, "profile", "") or "")
if requested_profile and current_profile != requested_profile:
raise RepairableException(
f"Subordinate already uses profile '{current_profile or 'default'}'. "
f"Set reset=true and omit `context_id` to switch to '{requested_profile}'."
)
if subordinate.context is not parent.context and subordinate.context.is_running():
raise RepairableException(
f"Subordinate context '{subordinate.context.id}' is still running. "
"Wait for it to finish or stop it before continuing it."
)
label = str(name or "").strip()
if label and subordinate.context is not parent.context:
subordinate.context.name = label
subordinate.context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, label)
return subordinate
override_settings = {"agent_profile": requested_profile} if requested_profile else None
subordinate = Agent(parent.number + 1, initialize_agent(override_settings=override_settings))
context = subordinate.context
context.name = str(name or "").strip() or _short_label(message) or subordinate.agent_name
context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent.context.id)
context.set_output_data(CHILD_PARENT_AGENT_NUMBER_KEY, parent.number)
context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "subordinate")
context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, context.name)
context.set_output_data(CHILD_SUBORDINATE_SLOT_KEY, slot)
project = projects.get_context_project_name(parent.context)
if project:
projects.activate_project(context.id, project, mark_dirty=False)
model_override = parent.context.get_data("chat_model_override")
if model_override and get_configured_preset_name(subordinate) == DEFAULT_PRESET_NAME:
context.set_data("chat_model_override", model_override)
_register_subordinate(parent, subordinate, slot)
return subordinate
async def run_subordinate(
parent: Agent,
subordinate: Agent,
message: str,
attachments: list[str] | None = None,
) -> str:
assignment = str(message or "").strip()
if not assignment:
raise RepairableException("call_subordinate requires a non-empty `message`.")
attachment_paths = [str(item) for item in attachments or []]
if subordinate.context is not parent.context:
message_queue.log_user_message(
subordinate.context,
assignment,
attachment_paths,
source=" (subordinate)",
)
subordinate.hist_add_user_message(
UserMessage(message=assignment, attachments=attachment_paths)
)
if subordinate.context is not parent.context:
persist_chat.save_tmp_chat(subordinate.context)
try:
result = await subordinate.monologue()
subordinate.history.new_topic()
return result
finally:
if subordinate.context is not parent.context:
persist_chat.save_tmp_chat(subordinate.context)
def _short_label(text: str, limit: int = 80) -> str:
return " ".join(str(text or "").split())[:limit].rstrip()
class Delegation(Tool):
async def execute(self, message="", reset="", context_id="", **kwargs):
attachments = kwargs.get("attachments")
attachments = attachments if isinstance(attachments, list) else []
subordinate = get_or_create_subordinate(
self.agent,
profile=kwargs.get("profile", kwargs.get("agent_profile", "")),
reset=reset,
context_id=context_id or kwargs.get("agent_id", ""),
name=kwargs.get("name", ""),
message=message,
)
if subordinate.context is self.agent.context:
result = await run_subordinate(self.agent, subordinate, message, attachments)
else:
task = subordinate.context.run_task(
run_subordinate, self.agent, subordinate, message, attachments
)
try:
result = await task.result()
except asyncio.CancelledError:
caller = asyncio.current_task()
if caller and caller.cancelling():
task.kill()
raise
raise RepairableException(
f"Subordinate context '{subordinate.context.id}' was stopped."
) from None
# hint to use includes for long responses
additional = {"context_id": subordinate.context.id}
if len(result) >= save_tool_call_file.LEN_MIN:
hint = self.agent.read_prompt("fw.hint.call_sub.md")
if hint:
additional["hint"] = hint
# result
return Response(message=result, break_loop=False, additional=additional)
def get_log_object(self):
return self.agent.context.log.log(
type="subagent",
heading=f"icon://communication {self.agent.agent_name}: Calling Subordinate Agent",
content="",
kvps=self.args,
)