"""
WorkspaceExecutionEngine - Production-hard execution engine for multi-agent workspaces.

This module provides the WorkspaceExecutionEngine class that:
- Executes agents using event-driven scheduling with deterministic message IDs
- Prevents dropped messages through ID-based matching
- Enforces recursion safety (max workspaces, depth limits, per-agent throttling)
- Protects against infinite loops and unbounded execution
- Validates agent outputs strictly
- Maintains complete transcripts for debugging
"""

from __future__ import annotations

import json
import logging
import asyncio
from typing import Any, Optional
from datetime import datetime

from synqed.agent import AgentLogicContext
from synqed.memory import InboxMessage, AgentMemory
from synqed.workspace_manager import Workspace, WorkspaceManager
from synqed.planner import PlannerLLM
from synqed.scheduler import EventScheduler, AgentEvent
from synqed.display import MessageDisplay

logger = logging.getLogger(__name__)


class Context(AgentLogicContext):
    """
    Context object passed to agent logic functions during workspace execution.
    
    Extends AgentLogicContext with workspace-specific information and event details.
    """
    
    def __init__(
        self,
        agent_name: str,
        workspace: Workspace,
        workspace_id: str,
        messages: list[InboxMessage],
        memory: AgentMemory,
        default_target: Optional[str] = None,
        event_trigger: Optional[str] = None,
        event_payload: Optional[dict] = None,
    ):
        """Initialize the context."""
        super().__init__(
            memory=memory, 
            default_target=default_target,
            workspace=workspace,
            agent_name=agent_name
        )
        self.agent_name = agent_name
        self.workspace = workspace
        self.workspace_id = workspace_id
        self.messages = messages
        self.event_trigger = event_trigger
        self.event_payload = event_payload or {}
    
    def build_response(self, target: str, content: str) -> dict[str, str]:
        """Build a response dictionary. Delegates to parent AgentLogicContext.build_response."""
        return super().build_response(target, content)
    
    def __repr__(self) -> str:
        """String representation."""
        return f"Context(agent='{self.agent_name}', workspace='{self.workspace_id}', messages={len(self.messages)})"


class WorkspaceExecutionEngine:
    """
    Production-hard execution engine for multi-agent workspaces.
    
    Features:
    - Deterministic message processing with unique IDs
    - Zero dropped messages through ID-based matching
    - Recursion safety (max workspaces, depth limits, throttling)
    - Event loop protection (max events per workspace/cycle)
    - Infinite loop detection and prevention
    - Strict agent output validation
    - Complete transcript tracking
    """
    
    def __init__(
        self,
        planner: PlannerLLM,
        workspace_manager: WorkspaceManager,
        enable_display: bool = True,
        max_cycles: int = 20,
        max_events_per_cycle: int = 50,
        max_events_per_workspace: int = 2000,
        max_agent_turns: Optional[int] = None,
        max_total_workspaces: int = 50,
        max_workspace_depth: int = 8,
        max_subteam_requests_per_agent: int = 3,
        fatal_cycle_threshold: int = 5,
    ):
        """
        Initialize the workspace execution engine.
        
        Args:
            planner: PlannerLLM instance for creating subteam subtrees
            workspace_manager: WorkspaceManager instance for workspace operations
            enable_display: Whether to enable real-time message display (default: True)
            max_cycles: Maximum number of event processing cycles per workspace (default: 20)
            max_events_per_cycle: Maximum events to process in a single cycle (default: 50)
            max_events_per_workspace: Maximum total events per workspace (default: 2000)
            max_agent_turns: Maximum number of agent responses/turns before stopping (default: None = unlimited)
            max_total_workspaces: Maximum total workspaces that can be created (default: 50)
            max_workspace_depth: Maximum nesting depth for workspaces (default: 8)
            max_subteam_requests_per_agent: Maximum subteam requests per agent (default: 3)
            fatal_cycle_threshold: Cycles with no output before killing workspace (default: 5)
        """
        self.planner = planner
        self.workspace_manager = workspace_manager
        self._workspace_schedulers: dict[str, EventScheduler] = {}  # Track schedulers for all workspaces
        self._total_workspaces_created = 0
        
        # Configurable execution limits
        self.max_cycles = max_cycles
        self.max_events_per_cycle = max_events_per_cycle
        self.max_events_per_workspace = max_events_per_workspace
        self.max_agent_turns = max_agent_turns
        self.max_total_workspaces = max_total_workspaces
        self.max_workspace_depth = max_workspace_depth
        self.max_subteam_requests_per_agent = max_subteam_requests_per_agent
        self.fatal_cycle_threshold = fatal_cycle_threshold
        
        # Global workspace execution queue for asynchronous hierarchical execution
        self.global_workspace_queue: asyncio.Queue[str] = asyncio.Queue()
        # Track which workspaces are currently executing to prevent re-entry
        self._running_workspaces: set[str] = set()
        # Track which workspaces are queued to prevent duplicate scheduling
        self._queued_workspaces: set[str] = set()
        # Track which agent requested each child workspace (child_id -> requesting_agent_name)
        self._subteam_requesters: dict[str, str] = {}
        # Real-time message display
        self.display = MessageDisplay() if enable_display else None
        # Track active agent for display
        self._active_agent: Optional[str] = None
    
    def schedule_workspace(self, workspace_id: str) -> None:
        """
        Schedule a workspace for execution in the global queue.
        
        This method enqueues a workspace ID for later execution. Workspaces
        cannot be enqueued if they are already running OR already in the queue.
        
        Args:
            workspace_id: ID of workspace to schedule
        """
        workspace = self.workspace_manager.get_workspace(workspace_id)
        
        # Prevent re-entry: skip if already running
        if workspace.is_running or workspace_id in self._running_workspaces:
            logger.debug(f"Workspace {workspace_id} is already running, skipping enqueue")
            return
        
        # FIX: Check if workspace is already in the queue to prevent duplicate scheduling
        # This prevents the same workspace from being queued multiple times before execution
        if workspace_id in self._queued_workspaces:
            logger.debug(f"Workspace {workspace_id} is already queued, skipping duplicate enqueue")
            return
        
        # Enqueue for execution
        self._queued_workspaces.add(workspace_id)
        self.global_workspace_queue.put_nowait(workspace_id)
        logger.debug(f"Scheduled workspace {workspace_id} for execution")
    
    async def run_global_scheduler(self) -> None:
        """
        Process all workspaces in the global execution queue IN PARALLEL.
        
        This method continuously processes workspaces from the queue until
        the queue is empty. Workspaces run concurrently using asyncio.gather,
        allowing true parallel execution. Workspaces may schedule child workspaces
        during execution, which will be processed in subsequent iterations.
        """
        # Continue processing until queue is empty
        # New workspaces may be scheduled during execution
        processed_count = 0
        max_iterations = 1000  # Safety limit to prevent infinite loops
        
        for iteration in range(max_iterations):
            if self.global_workspace_queue.empty():
                break
            
            # Collect all workspaces currently in queue
            batch_size = self.global_workspace_queue.qsize()
            workspace_ids = []
            for _ in range(batch_size):
                try:
                    workspace_id = self.global_workspace_queue.get_nowait()
                    workspace_ids.append(workspace_id)
                except asyncio.QueueEmpty:
                    break
            
            if workspace_ids:
                logger.debug(f"Running {len(workspace_ids)} workspaces in parallel: {workspace_ids}")
                
                # FIX: Remove workspaces from queued set before running
                # This allows them to be re-queued later if needed
                for wid in workspace_ids:
                    self._queued_workspaces.discard(wid)
                
                # Run all workspaces in this batch CONCURRENTLY
                tasks = [self.run_workspace(workspace_id=wid) for wid in workspace_ids]
                try:
                    await asyncio.gather(*tasks, return_exceptions=True)
                    processed_count += len(workspace_ids)
                except Exception as e:
                    logger.error(f"Error in parallel workspace execution: {e}")
    
    async def run(self, root_workspace_id: str) -> None:
        """
        Top-level entrypoint for executing a root workspace and all its children.
        
        This method schedules the root workspace and then runs the global scheduler
        to process all workspaces (including children) asynchronously.
        
        Args:
            root_workspace_id: ID of the root workspace to execute
        """
        # Display initial message placement if display is enabled
        if self.display:
            workspace = self.workspace_manager.get_workspace(root_workspace_id)
            # Check if there are any initial messages
            for agent_name, agent in workspace.agents.items():
                messages = agent.memory.get_messages()
                if messages:
                    # Display initial for the first agent with a message
                    self.display.display_initial("user task", agent_name)
                    break
        
        self.schedule_workspace(root_workspace_id)
        await self.run_global_scheduler()
    
    async def run_workspace(
        self,
        workspace_id: str,
        max_cycles: Optional[int] = None,
    ) -> None:
        """
        Execute a workspace's agents using event-driven scheduling.
        
        This method executes a workspace independently without recursion.
        When a workspace completes, it sends subteam_result messages to its
        parent workspace if it has one.
        
        Args:
            workspace_id: ID of workspace to execute
            max_cycles: Maximum number of event processing cycles (default: uses engine's max_cycles)
        """
        # Use instance max_cycles if not provided
        if max_cycles is None:
            max_cycles = self.max_cycles
        workspace = self.workspace_manager.get_workspace(workspace_id)
        
        # Prevent re-entry
        if workspace.is_running or workspace_id in self._running_workspaces:
            logger.warning(f"Workspace {workspace_id} is already running, skipping execution")
            return
        
        # Mark as running
        workspace.is_running = True
        self._running_workspaces.add(workspace_id)
        
        try:
            # Get or create shared event scheduler for this workspace
            if workspace_id not in self._workspace_schedulers:
                self._workspace_schedulers[workspace_id] = EventScheduler()
            scheduler = self._workspace_schedulers[workspace_id]
        
            # Schedule startup events for all agents (only on first execution)
            # FIX: Skip agents that already have unprocessed messages (e.g., initial USER message)
            # FIX: Only send startup to root workspace (child workspaces activate on-demand via messages)
            # The unprocessed messages section below will schedule events for them
            if not workspace.has_started:
                # Only root workspace agents get startup events
                # Child workspace agents activate only when messaged
                if workspace.depth == 0:
                    for agent_name, agent in workspace.agents.items():
                        # Check if agent already has unprocessed messages
                        unprocessed = agent.memory.get_unprocessed_messages()
                        if unprocessed:
                            # Agent already has work to do, skip startup event
                            # The unprocessed messages loop below will schedule events for these
                            logger.debug(f"Skipping startup event for {agent_name} - already has {len(unprocessed)} unprocessed messages")
                            continue
                        
                        # Route a system startup message via workspace
                        msg_id = await workspace.route_message(
                            sender="SYSTEM",
                            recipient=agent_name,
                            content="[startup]",
                            manager=self.workspace_manager,
                        )
                        
                        # Schedule startup event carrying that message_id
                        scheduler.schedule_event_dedup(AgentEvent(
                            agent_name=agent_name,
                            trigger="startup",
                            payload={"message_id": msg_id},
                        ))
                workspace.has_started = True
            
            # Check for unprocessed messages (e.g., subteam_result from child workspaces)
            # and schedule message events for them
            for agent_name, agent in workspace.agents.items():
                unprocessed_messages = agent.memory.get_unprocessed_messages()
                for message in unprocessed_messages:
                    # ROUTING ENFORCEMENT: Only schedule event if message is targeted to this agent
                    # target should always be set by router, but check just in case
                    message_target = getattr(message, 'target', None)
                    
                    # If target is set and doesn't match this agent, skip
                    if message_target is not None and message_target != agent_name:
                        logger.debug(f"Skipping message {message.message_id} for {agent_name} - targeted to {message_target}")
                        continue
                    
                    # If target is not set, this is a legacy message or system message
                    # Only allow for the first agent (backwards compatibility) or if content suggests it's for this agent
                    if message_target is None:
                        # For None targets, only schedule if this is a system message or workspace-level broadcast
                        # Most messages should have targets, so log a warning
                        logger.warning(f"Message {message.message_id} has no target, scheduling for {agent_name}")
                    
                    # Determine trigger type based on message content
                    trigger = "message"
                    if message.content.startswith("[subteam_result]"):
                        trigger = "subteam_result"
                    
                    # Schedule event for unprocessed messages
                    scheduler.schedule_event_dedup(AgentEvent(
                        agent_name=agent_name,
                        trigger=trigger,
                        payload={"message_id": message.message_id},
                    ))
            
            cycle = 0
            consecutive_no_output_cycles = 0
            total_events_processed = 0
            agent_turns = 0  # Count of actual agent responses
            task_complete = False  # Flag to stop execution when USER receives a message
            
            while scheduler.has_pending_events() and cycle < max_cycles and not task_complete:
                # Event loop protection: max events per workspace
                if total_events_processed >= self.max_events_per_workspace:
                    if self.display:
                        self.display.display_error(
                            "max_events_exceeded",
                            f"Workspace stopped after {total_events_processed} events"
                        )
                    # Add error transcript entry
                    self._add_error_transcript_entry(
                        workspace, workspace_id, "max_events_exceeded",
                        f"Stopped after {total_events_processed} events"
                    )
                    break
                
                cycle += 1
                
                cycle_had_output = False
                events_processed_this_cycle = 0
                
                # Process events with per-cycle throttle
                while (scheduler.has_pending_events() and 
                       events_processed_this_cycle < self.max_events_per_cycle):
                    
                    event = scheduler.pop_next_event()
                    if event is None:
                        break
                    
                    events_processed_this_cycle += 1
                    total_events_processed += 1
                    agent_name = event.agent_name
                    
                    # NOTE: Display processing message AFTER we confirm agent produces output
                    # (moved below to avoid showing "processing" for filtered messages)
                    
                    # Strict validation for event.trigger
                    valid_triggers = {"startup", "message", "subteam_result"}
                    if event.trigger not in valid_triggers:
                        logger.error(
                            f"Invalid event trigger '{event.trigger}' for agent '{agent_name}' "
                            f"in workspace {workspace_id}. Valid triggers: {valid_triggers}. Skipping."
                        )
                        continue
                    
                    # Verify agent exists
                    if agent_name not in workspace.agents:
                        logger.warning(f"Event for unknown agent '{agent_name}' in workspace {workspace_id}")
                        continue
                    
                    agent = workspace.agents[agent_name]
                    
                    # Message-like events must always carry a message_id that matches AgentMemory
                    message_like_triggers = {"startup", "message", "subteam_result"}
                    message_id: Optional[str] = None
                    
                    if event.trigger in message_like_triggers:
                        message_id = event.payload.get("message_id")
                        if not message_id:
                            logger.warning(
                                f"{event.trigger} event missing message_id for agent {agent_name} "
                                f"in workspace {workspace_id}"
                            )
                            continue
                        
                        # Fetch exact message by id
                        message = agent.memory.get_message_by_id(message_id)
                        if message is None:
                            logger.debug(
                                f"message {message_id} not found for agent {agent_name} in workspace {workspace_id}, skipping"
                            )
                            continue
                        
                        # Check if already processed
                        if agent.memory.is_message_processed(message_id):
                            logger.debug(
                                f"message {message_id} already processed for agent {agent_name} in workspace {workspace_id}, skipping"
                            )
                            continue
                    
                    # Get messages for context
                    messages = agent.memory.get_messages()
                    
                    # Create context with event trigger and payload
                    context = Context(
                        agent_name=agent_name,
                        workspace=workspace,
                        workspace_id=workspace_id,
                        messages=messages[-10:] if messages else [],
                        memory=agent.memory,
                        default_target=agent.default_target,
                        event_trigger=event.trigger,
                        event_payload=event.payload,
                    )
                    
                    try:
                        # ExecutionEngine is the only component allowed to route messages;
                        # agent logic may not use routers. All routing must go through workspace.route_message.
                        # Agent.process enforces router isolation and structured return format.
                        
                        # Check if this is a local agent or remote A2A agent
                        if hasattr(agent, 'process'):
                            # Local agent built with Synqed - call process() with context
                            result = await agent.process(context)
                            
                            # Mark message as processed after successful logic execution
                            if message_id:
                                agent.memory.mark_message_processed(message_id)
                            
                            # Check if agent returned None (agent chose not to respond)
                            if result is None:
                                logger.debug(f"Agent {agent_name} returned None - skipping (no response needed)")
                                continue
                        else:
                            # Remote A2A agent - call get_response() (messages already buffered)
                            result = await agent.get_response()
                            # No memory to mark - remote agent manages its own state
                            
                            # Check if agent returned None (e.g., for startup messages that should be skipped)
                            if result is None:
                                logger.debug(f"Agent {agent_name} returned None - skipping (no response needed)")
                                continue
                        
                        # Display processing message NOW (agent produced output)
                        if self.display and agent_name != self._active_agent:
                            self._active_agent = agent_name
                            self.display.display_processing(agent_name)
                        
                        # Hardened agent output validation (supports broadcast)
                        agent_response = self._validate_and_normalize_response(
                            result, agent_name, workspace
                        )
                        
                        if agent_response is None:
                            continue  # Invalid response, skip
                        
                        # Handle BROADCAST: convert single dict to list for uniform processing
                        responses_to_process = [agent_response] if isinstance(agent_response, dict) else agent_response
                        
                        # Process each response (single or broadcast)
                        for single_response in responses_to_process:
                            # Handle USER messages: route and STOP execution (task complete)
                            if single_response["send_to"] == "USER":
                                # Display the message
                                if self.display:
                                    self.display.display_message(
                                        sender=agent_name,
                                        recipient="USER",
                                        content=single_response["content"]
                                    )
                                
                                await workspace.route_message(
                                    sender=agent_name,
                                    recipient="USER",
                                    content=single_response["content"],
                                    manager=self.workspace_manager,
                                )
                                
                                # Task complete - break out of ALL loops
                                logger.info(f"Agent {agent_name} sent to USER - task complete, stopping workspace")
                                task_complete = True
                                # Clear remaining events
                                while scheduler.has_pending_events():
                                    scheduler.pop_next_event()
                                break  # Exit event processing loop
                            
                            # Check for subteam request
                            subteam_handled = await self._handle_subteam_request(
                                workspace=workspace,
                                workspace_id=workspace_id,
                                agent_name=agent_name,
                                response=single_response,
                                scheduler=scheduler,  # Only used for error cases
                            )
                            
                            if subteam_handled:
                                cycle_had_output = True
                                continue
                            
                            # Check for infinite self-message loops
                            if self._is_self_message_loop(
                                agent, single_response["send_to"], agent_name, single_response["content"]
                            ):
                                logger.warning(
                                    f"Blocked self-message loop for agent {agent_name} in workspace {workspace_id}"
                                )
                                self._add_error_transcript_entry(
                                    workspace, workspace_id, "self_message_loop_blocked",
                                    f"Agent {agent_name} attempted self-message loop"
                                )
                                continue
                            
                            # Recipient validation already done in _validate_and_normalize_response
                            # No need to check again here
                            
                            # Display the message
                            if self.display:
                                self.display.display_message(
                                    sender=agent_name,
                                    recipient=single_response["send_to"],
                                    content=single_response["content"]
                                )
                            
                            # Route message with deterministic ID
                            message_id = await workspace.route_message(
                                sender=agent_name,
                                recipient=single_response["send_to"],
                                content=single_response["content"],
                                manager=self.workspace_manager,
                            )
                            
                            if message_id:
                                # Determine which workspace the recipient is in
                                recipient_workspace_id = None
                                if single_response["send_to"] in workspace.agents:
                                    # Recipient is in current workspace
                                    recipient_workspace_id = workspace_id
                                elif self.workspace_manager:
                                    # Check child workspaces
                                    for child_id in workspace.children:
                                        try:
                                            child_workspace = self.workspace_manager.get_workspace(child_id)
                                            if single_response["send_to"] in child_workspace.agents:
                                                recipient_workspace_id = child_id
                                                break
                                        except:
                                            pass
                                    
                                    # Check parent workspace
                                    if not recipient_workspace_id and workspace.parent_id:
                                        try:
                                            parent_workspace = self.workspace_manager.get_workspace(workspace.parent_id)
                                            if single_response["send_to"] in parent_workspace.agents:
                                                recipient_workspace_id = workspace.parent_id
                                        except:
                                            pass
                                
                                # Schedule event in the appropriate workspace's scheduler
                                if recipient_workspace_id:
                                    # Get or create scheduler for the target workspace
                                    if recipient_workspace_id not in self._workspace_schedulers:
                                        self._workspace_schedulers[recipient_workspace_id] = EventScheduler()
                                    
                                    target_scheduler = self._workspace_schedulers[recipient_workspace_id]
                                    target_scheduler.schedule_event_dedup(AgentEvent(
                                        agent_name=single_response["send_to"],
                                        trigger="message",
                                        payload={"message_id": message_id}
                                    ))
                                    
                                    # For cross-workspace messages, schedule the target workspace for execution
                                    if recipient_workspace_id != workspace_id:
                                        self.schedule_workspace(recipient_workspace_id)
                                        logger.debug(
                                            f"Cross-workspace message: {agent_name} → {single_response['send_to']} "
                                            f"(workspace {workspace_id} → {recipient_workspace_id}), "
                                            f"scheduled target workspace for execution"
                                        )
                                
                                cycle_had_output = True
                        
                        # Increment agent turns counter AFTER processing all broadcast responses
                        agent_turns += 1
                        
                        # Check max_agent_turns limit
                        if self.max_agent_turns is not None and agent_turns >= self.max_agent_turns:
                            if self.display:
                                self.display.display_error(
                                    "max_agent_turns_exceeded",
                                    f"Stopped after {agent_turns} agent responses (task incomplete)"
                                )
                            logger.info(f"Max agent turns ({self.max_agent_turns}) reached, stopping workspace")
                            task_complete = True
                            # Clear remaining events
                            while scheduler.has_pending_events():
                                scheduler.pop_next_event()
                            break
                    
                    except Exception as e:
                        logger.error(f"Error executing agent {agent_name} in workspace {workspace_id}: {e}")
                        continue
                
                # Fatal-cycle detection: no output for N cycles with non-empty queue
                if cycle_had_output:
                    consecutive_no_output_cycles = 0
                else:
                    consecutive_no_output_cycles += 1
                    if (consecutive_no_output_cycles >= self.fatal_cycle_threshold and 
                        scheduler.has_pending_events()):
                        if self.display:
                            self.display.display_error(
                                "fatal_cycle_detected",
                                f"Workspace stuck (no output for {consecutive_no_output_cycles} cycles)"
                            )
                        self._add_error_transcript_entry(
                            workspace, workspace_id, "fatal_cycle_detected",
                            f"Killed after {consecutive_no_output_cycles} cycles with no output"
                        )
                        break
            
            # Display completion
            if self.display and workspace.depth == 0:
                self.display.display_completion(workspace_id, cycle)
            
            # If this workspace has a parent, send subteam_result message
            # Note: We don't have access to parent's scheduler here, so we'll route the message
            # and schedule the parent workspace. The parent will pick up the message when it runs.
            if workspace.parent_id:
                await self._send_subteam_result_to_parent(workspace, workspace_id)
        finally:
            # Mark as not running
            workspace.is_running = False
            self._running_workspaces.discard(workspace_id)
    
    async def _send_subteam_result_to_parent(
        self,
        child_workspace: Workspace,
        child_workspace_id: str,
    ) -> None:
        """
        Send subteam_result message to parent workspace when child completes.
        
        This method is called when a child workspace finishes execution.
        It creates a subteam_result message and routes it to the requesting
        agent in the parent workspace via normal message routing. The parent
        workspace will pick up this message when it runs next and schedule
        a subteam_result event automatically (via unprocessed message detection).
        
        Args:
            child_workspace: The child workspace that just completed
            child_workspace_id: ID of the child workspace
        """
        if not child_workspace.parent_id:
            return
        
        try:
            parent_workspace = self.workspace_manager.get_workspace(child_workspace.parent_id)
            
            # Find which agent in parent requested this subteam
            requesting_agent = self._subteam_requesters.get(child_workspace_id)
            
            if not requesting_agent:
                # Fallback: try to find agent with subteam requests
                for agent_name, count in parent_workspace.subteam_requests.items():
                    if count > 0:
                        requesting_agent = agent_name
                        break
                
                if not requesting_agent:
                    # Final fallback: send to first agent in parent
                    if parent_workspace.agents:
                        requesting_agent = list(parent_workspace.agents.keys())[0]
                    else:
                        logger.warning(f"No agent found in parent workspace {child_workspace.parent_id} for subteam_result")
                        return
            
            # Fetch final transcript from child workspace
            child_transcript = child_workspace.router.get_transcript()
            result_message = ""
            
            if child_transcript:
                # Only include the last message
                last_entry = child_transcript[-1]
                result_message = last_entry.get("content", "")[:500]  # Truncate
            else:
                result_message = f"Subteam {child_workspace_id} completed"
            
            # Build a canonical payload for the result
            payload = {
                "child_workspace_id": child_workspace_id,
                "result_message": result_message,
            }
            
            # Create a system message to the requesting agent with the subteam result
            # The message content starts with [subteam_result] so it will be detected
            # as a subteam_result trigger when the parent workspace processes unprocessed messages
            msg_id = await parent_workspace.route_message(
                sender="SYSTEM",
                recipient=requesting_agent,
                content=f"[subteam_result]{json.dumps(payload, sort_keys=True)}",
                manager=self.workspace_manager,
            )
            
            # Schedule parent workspace to process the subteam_result message
            # When parent runs, it will detect the unprocessed [subteam_result] message
            # and schedule a subteam_result event automatically
            self.schedule_workspace(child_workspace.parent_id)
        
        except Exception as e:
            logger.error(f"Error sending subteam_result to parent: {e}")
    
    def _validate_and_normalize_response(
        self,
        result: dict[str, str],
        agent_name: str,
        workspace: Workspace,
    ) -> Optional[dict[str, str] | list[dict[str, str]]]:
        """
        Validate and normalize agent response with broadcast support.
        
        Supports both single and broadcast responses:
        - Single: {"send_to": "Agent", "content": "..."}
        - Broadcast: {"send_to": ["Agent1", "Agent2", "Agent3"], "content": "..."}
        
        Args:
            result: dict from Agent.process() with "send_to" (str or list) and "content"
            agent_name: Name of the agent for logging
            workspace: Workspace instance for agent validation
            
        Returns:
            dict[str, str] if single recipient
            list[dict[str, str]] if broadcast to multiple recipients
            None if invalid
        """
        # Validate required fields exist
        if "send_to" not in result or "content" not in result:
            logger.warning(f"Agent {agent_name} returned invalid response structure (missing send_to or content)")
            return None
        
        send_to = result["send_to"]
        content = result.get("content", "")
        
        # BROADCAST SUPPORT: Handle list of recipients
        if isinstance(send_to, list):
            if not send_to:
                logger.warning(f"Agent {agent_name} returned empty recipient list")
                return None
            
            logger.info(f"Agent {agent_name} broadcasting to {len(send_to)} recipients: {send_to}")
            
            # Validate and normalize each recipient
            normalized_responses = []
            for recipient in send_to:
                if not isinstance(recipient, str):
                    logger.warning(f"Agent {agent_name} returned non-string recipient in list: {recipient}")
                    continue
                
                # Validate this recipient exists (using same logic as single recipient)
                validated_recipient = self._validate_single_recipient(recipient.strip(), workspace, agent_name)
                if validated_recipient:
                    normalized_responses.append({
                        "send_to": validated_recipient,
                        "content": content
                    })
            
            if not normalized_responses:
                logger.warning(f"Agent {agent_name} broadcast had no valid recipients")
                return None
            
            return normalized_responses
        
        # SINGLE RECIPIENT: Use validation helper
        if not isinstance(send_to, str):
            logger.warning(f"Agent {agent_name} returned non-string send_to: {type(send_to)}")
            return None
        
        # Validate recipient
        send_to = self._validate_single_recipient(send_to.strip(), workspace, agent_name)
        if not send_to:
            return None
        
        # Truncate content if too long
        if len(content) > 10000:
            logger.warning(f"Agent {agent_name} response content truncated from {len(content)} to 10000 chars")
            content = content[:10000]
        
        # Ignore empty content
        if not content.strip():
            logger.debug(f"Agent {agent_name} returned empty content, ignoring")
            return None
        
        return {"send_to": send_to, "content": content}
    
    def _validate_single_recipient(
        self,
        send_to: str,
        workspace: Workspace,
        agent_name: str
    ) -> Optional[str]:
        """
        Validate a single recipient exists in workspace hierarchy.
        
        Returns the validated recipient name, or "USER" if not found, or None if invalid.
        """
        if not send_to:
            return None
        
        # Handle nonexistent send_to by checking workspace hierarchy
        recipient_exists = False
        if send_to == "USER":
            recipient_exists = True
        elif send_to in workspace.agents:
            recipient_exists = True
        elif self.workspace_manager:
            # Check child workspaces
            for child_id in workspace.children:
                try:
                    child_workspace = self.workspace_manager.get_workspace(child_id)
                    if send_to in child_workspace.agents:
                        recipient_exists = True
                        logger.debug(f"Found recipient '{send_to}' in child workspace {child_id}")
                        break
                except Exception as e:
                    logger.debug(f"Error checking child workspace {child_id}: {e}")
            
            # Check parent workspace if not found in children
            if not recipient_exists and workspace.parent_id:
                try:
                    parent_workspace = self.workspace_manager.get_workspace(workspace.parent_id)
                    if send_to in parent_workspace.agents:
                        recipient_exists = True
                        logger.debug(f"Found recipient '{send_to}' in parent workspace {workspace.parent_id}")
                except Exception as e:
                    logger.debug(f"Error checking parent workspace {workspace.parent_id}: {e}")
        
        if not recipient_exists:
            logger.warning(
                f"Agent {agent_name} sent to nonexistent agent '{send_to}', converting to USER. "
                f"Checked: current workspace {workspace.workspace_id}, {len(workspace.children)} children, parent: {workspace.parent_id}"
            )
            return "USER"
        
        return send_to
    
    def _is_self_message_loop(
        self,
        agent: Any,
        send_to: str,
        agent_name: str,
        content: str,
    ) -> bool:
        """
        Detect infinite self-message loops.
        
        Returns True if:
        - send_to == sender AND
        - last 3 messages were self-targeted with same content
        """
        if send_to != agent_name:
            return False
        
        messages = agent.memory.get_last_n_messages(3)
        if len(messages) < 3:
            return False
        
        # Check if last 3 messages were self-targeted with same content
        self_targeted_same_content = sum(
            1 for msg in messages 
            if msg.from_agent == agent_name and msg.content == content
        )
        
        return self_targeted_same_content >= 3
    
    def _add_error_transcript_entry(
        self,
        workspace: Workspace,
        workspace_id: str,
        error_type: str,
        message: str,
    ) -> None:
        """Add an error entry to workspace transcript."""
        entry = {
            "timestamp": datetime.now().isoformat(),
            "workspace_id": workspace_id,
            "from": "SYSTEM",
            "to": "ERROR",
            "message_id": f"error-{datetime.now().isoformat()}",
            "content": f"[{error_type}] {message}",
        }
        workspace.router.add_transcript_entry(entry)
    
    async def _handle_subteam_request(
        self,
        workspace: Workspace,
        workspace_id: str,
        agent_name: str,
        response: dict[str, str],
        scheduler: EventScheduler,
    ) -> bool:
        """
        Handle a subteam request with recursion safety checks.
        
        The scheduler parameter is only used for error cases. The success path
        does not schedule any events - subteam results are handled later in
        _send_subteam_result_to_parent.
        
        Returns:
            True if subteam request was handled, False otherwise
        """
        # Parse request JSON from response content
        try:
            request_json = json.loads(response["content"])
        except (json.JSONDecodeError, TypeError, KeyError):
            return False
        
        if not isinstance(request_json, dict):
            return False
        
        if request_json.get("action") != "request_subteam":
            return False
        
        # Recursion safety checks
        # 1. Global workspace limit
        if self._total_workspaces_created >= self.max_total_workspaces:
            logger.error(
                f"Subteam creation denied: max total workspaces ({self.max_total_workspaces}) exceeded"
            )
            await self._schedule_subteam_error_result(
                workspace, scheduler, agent_name, "max_workspaces_exceeded"
            )
            return True
        
        # 2. Depth limit
        if workspace.depth >= self.max_workspace_depth:
            logger.error(
                f"Subteam creation denied: max depth ({self.max_workspace_depth}) reached "
                f"for workspace {workspace_id}"
            )
            await self._schedule_subteam_error_result(
                workspace, scheduler, agent_name, "max_depth_exceeded"
            )
            return True
        
        # 3. Per-agent throttling
        agent_request_count = workspace.subteam_requests.get(agent_name, 0)
        if agent_request_count >= self.max_subteam_requests_per_agent:
            logger.error(
                f"Subteam creation denied: agent {agent_name} exceeded max requests "
                f"({self.max_subteam_requests_per_agent})"
            )
            await self._schedule_subteam_error_result(
                workspace, scheduler, agent_name, "max_requests_per_agent_exceeded"
            )
            return True
        
        try:
            # Ensure requesting_agent is set
            if "requesting_agent" not in request_json:
                request_json["requesting_agent"] = agent_name
            
            # Create subtree
            subtree_root = await self.planner.create_subteam_subtree(request_json)
            
            # Create child workspace
            child_workspace = await self.workspace_manager.create_workspace(
                task_tree_node=subtree_root,
                parent_workspace_id=workspace_id,
            )
            
            self._total_workspaces_created += 1
            
            # Link subteam
            self.workspace_manager.link_subteam(
                parent_workspace_id=workspace_id,
                subteam_workspace_id=child_workspace.workspace_id,
            )
            
            # Increment agent request count
            workspace.subteam_requests[agent_name] = agent_request_count + 1
            
            # Track which agent requested this child workspace
            self._subteam_requesters[child_workspace.workspace_id] = agent_name
            
            # Schedule child workspace for asynchronous execution (no recursion)
            # The child will send subteam_result to parent when it completes
            self.schedule_workspace(child_workspace.workspace_id)
            
            # Return immediately - parent continues execution
            # Child workspace will send subteam_result message to parent when it finishes
            return True
            
        except Exception as e:
            logger.error(f"Error handling subteam request from {agent_name}: {e}")
            await self._schedule_subteam_error_result(
                workspace, scheduler, agent_name, f"error: {str(e)}"
            )
            return True
    
    async def _schedule_subteam_error_result(
        self,
        workspace: Workspace,
        scheduler: EventScheduler,
        agent_name: str,
        error_reason: str,
    ) -> None:
        """
        Schedule a subteam_result event pointing to an error message in memory.
        """
        payload = {
            "error": "subteam_limit_reached",
            "reason": error_reason,
        }
        
        # Create error message in agent memory via workspace.route_message
        msg_id = await workspace.route_message(
            sender="SYSTEM",
            recipient=agent_name,
            content=f"[subteam_result]{json.dumps(payload, sort_keys=True)}",
            manager=self.workspace_manager,
        )
        
        # Schedule the event with payload={"message_id": msg_id}
        scheduler.schedule_event_dedup(AgentEvent(
            agent_name=agent_name,
            trigger="subteam_result",
            payload={"message_id": msg_id},
        ))
