"""
Agent - Agent with built-in inbox memory and structured response rules.

This module provides the Agent class that wraps agent logic functions
with inbox memory access and structured JSON response handling.
"""

import json
import httpx
from typing import Any, Callable, Coroutine, Optional, List, Dict

from synqed.memory import AgentMemory, InboxMessage


# ============================================================================
# Global Interaction Protocol
# ============================================================================

def get_team_roster(exclude_agent: str = None) -> str:
    """
    Get formatted team roster showing other agents' roles and capabilities.
    
    This is a utility function that agents can use in their custom logic
    to automatically discover what other agents are available.
    
    Args:
        exclude_agent: Optional agent name to exclude from roster (typically self)
        
    Returns:
        Formatted team roster string, or empty string if no other agents
        
    Example:
        ```python
        async def my_agent_logic(context):
            team_info = synqed.agent.get_team_roster(exclude_agent="my_agent")
            system_prompt = f"{GLOBAL_INTERACTION_PROTOCOL}\\n\\n{team_info}"
            ...
        ```
    """
    from synqed.workspace_manager import AgentRuntimeRegistry
    
    team_members = []
    for role in AgentRuntimeRegistry.list_roles():
        if exclude_agent and role == exclude_agent:
            continue
        agent = AgentRuntimeRegistry.get(role)
        if agent:
            caps = agent.capabilities if agent.capabilities else ["general tasks"]
            team_members.append(
                f"  - {role}: {agent.description}\n"
                f"    Capabilities: {', '.join(caps)}"
            )
    
    if not team_members:
        return ""
    
    return f"""TEAM MEMBERS (other agents in this workspace):
{chr(10).join(team_members)}

When coordinating, contact the appropriate agent based on their capabilities above."""


def get_interaction_protocol(exclude_agent: str = None) -> str:
    """
    Get the full interaction protocol including team roster.
    
    This automatically includes:
    1. Global interaction rules
    2. Team roster (other agents' roles and capabilities)
    
    Args:
        exclude_agent: Optional agent name to exclude from team roster (typically self)
        
    Returns:
        Complete interaction protocol with team roster
        
    Example:
        ```python
        async def my_agent_logic(context):
            protocol = synqed.get_interaction_protocol(exclude_agent="my_agent")
            system_prompt = f"{protocol}\\n\\nYOUR ROLE: ...\\n\\n{custom_instructions}"
            ...
        ```
    """
    team_roster = get_team_roster(exclude_agent=exclude_agent)
    
    if team_roster:
        return f"{GLOBAL_INTERACTION_PROTOCOL}\n\n{team_roster}"
    else:
        return GLOBAL_INTERACTION_PROTOCOL


GLOBAL_INTERACTION_PROTOCOL = """
═══════════════════════════════════════════════════════════════════════════════
AUTONOMOUS AGENT INTERACTION PROTOCOL v2.0
═══════════════════════════════════════════════════════════════════════════════

You are part of a multi-agent team completing structured tasks.
Behave like a COMPETENT OPERATOR, not a bottleneck.

CRITICAL: The user is NOT reading all chat logs. They are an EXECUTIVE, not a manager.
Do NOT treat them as someone who wants to answer micro-questions.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CORE AUTONOMY PRINCIPLES (MANDATORY):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. DO NOT ask the user for anything unless ABSOLUTELY required.
   Before ANY escalation to USER, you MUST:
   a) Attempt to INFER the missing information from context
   b) Coordinate with other agents to resolve it internally
   c) Provide a REASONABLE DEFAULT assumption
   d) State what you WILL proceed with unless overridden

2. NEVER produce vague requests. These are FORBIDDEN phrases:
   ❌ "Need more details"
   ❌ "Please clarify"
   ❌ "Cannot proceed"
   ❌ "Waiting on your input"
   ❌ "What is the [X]?" without providing a default
   ❌ Repeating the same question in different wording

3. ALWAYS prefer action over discussion.
   - Make reasonable assumptions and PROCEED
   - State your assumptions clearly
   - Let the user correct you if needed (they rarely will)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
USER ESCALATION RULE (ONLY when absolutely necessary):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

IF (and ONLY if) you must ask the user a question after exhausting all other options:

You MUST use this EXACT 4-part format:
┌─────────────────────────────────────────────────────────────────────┐
│ 1. WHAT I NEED: [one sentence - what specific information]         │
│ 2. WHY NEEDED: [one sentence - why this blocks progress]           │
│ 3. MY DEFAULT: [one sentence - what I will assume if no response]  │
│ 4. OPTIONS: [explicit choices the user can select from]            │
└─────────────────────────────────────────────────────────────────────┘

EXAMPLE (the ONLY acceptable format):
"I need the customer's preferred meeting format to finalize the schedule.
This determines whether we prepare virtual or in-person logistics.
If no response, I will default to: Virtual (Zoom).
Please reply with: 'virtual' OR 'in-person'"

WRONG examples (NEVER do this):
❌ "What customer are we working on?"
❌ "Need more information about the timeline"
❌ "Please provide details about requirements"
❌ "Can you tell me the target audience?"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
EXECUTION MODE (Automatic after 2-3 messages):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

After initial coordination, IMMEDIATELY enter EXECUTION MODE:
• Produce DRAFTS and DELIVERABLES immediately
• Use ASSUMPTIONS (state them clearly, then proceed)
• NO chatter without concrete output
• If blocked, propose assumption and CONTINUE

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
AGENT-TO-AGENT COMMUNICATION:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

• SHORT and DIRECT. No meta-talk or pleasantries.
• If another agent asks you something, answer with EXACTLY what they need.
• If you delegate, specify: DELIVERABLE + STRUCTURE + DEADLINE.
• If blocked by another agent, propose your assumption and proceed.
• Read ALL conversation history - don't ask for info already discussed.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RESPONSE FORMAT (REQUIRED JSON):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

You MUST respond with valid JSON:
{"send_to": "<recipient>", "content": "<your message>"}

RECIPIENTS:
• "ALL" - Broadcast to all teammates (for coordination)
• "USER" - Send deliverables OR properly-formatted clarification requests
• "agent_name" - Reply to a specific agent

GOOD USER MESSAGE (deliverable):
{"send_to": "USER", "content": "# Analysis Complete\\n\\n## Summary\\nBased on available data..."}

GOOD USER MESSAGE (clarification - ONLY if truly blocked):
{"send_to": "USER", "content": "I need the target industry to tailor recommendations.\\nThis affects which benchmarks and competitors I reference.\\nIf no response, I will default to: SaaS/Technology sector.\\nPlease reply with your industry OR confirm 'SaaS' is correct."}

BAD USER MESSAGE (NEVER do this):
{"send_to": "USER", "content": "What customer are we analyzing?"}
{"send_to": "USER", "content": "Need more details to proceed."}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
REMEMBER: You are an OPERATOR, not an assistant asking for permission.
Make decisions. State assumptions. Deliver results. Only escalate with full
context, clear options, and a stated default. The user should rarely need
to answer your questions - that's the whole point of having an agent team.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""


def parse_llm_response(response_text: str) -> str:
    """
    Parse LLM response by stripping markdown code blocks.
    
    Many LLMs wrap JSON responses in markdown code blocks like:
    ```json
    {"send_to": "Agent", "content": "..."}
    ```
    
    This function removes those code blocks to extract the actual content.
    
    Args:
        response_text: Raw LLM response text
        
    Returns:
        Cleaned response text with markdown code blocks removed
    """
    if "```" not in response_text:
        return response_text
    
    # Find the JSON content between code blocks
    start_idx = response_text.find("```")
    if start_idx != -1:
        # Skip the opening ``` and optional language identifier
        start_idx = response_text.find("\n", start_idx)
        if start_idx != -1:
            end_idx = response_text.find("```", start_idx)
            if end_idx != -1:
                return response_text[start_idx:end_idx].strip()
    
    # Fallback: return original
    return response_text


def extract_partial_json_content(response_text: str) -> Optional[dict]:
    """
    Extract content from partial/truncated JSON responses.
    
    Handles cases where LLM response was truncated mid-JSON, like:
    {"send_to": "USER", "content": "story text...
    
    Args:
        response_text: Potentially truncated JSON response
        
    Returns:
        Extracted dict with send_to and content if parseable, None otherwise
    """
    # Check if response was INTENDED for specific recipient
    send_to_match = None
    for pattern in ['"send_to": "', '"send_to":"']:
        if pattern in response_text:
            start = response_text.find(pattern) + len(pattern)
            end = response_text.find('"', start)
            if end != -1:
                send_to_match = response_text[start:end]
                break
    
    if not send_to_match:
        return None
    
    # Try to find the content field
    content_match = None
    for pattern in ['"content": "', '"content":"']:
        if pattern in response_text:
            content_start = response_text.find(pattern) + len(pattern)
            # Extract until end or closing quote
            content_text = response_text[content_start:]
            # Try to find natural end (closing quote followed by })
            # But handle truncation gracefully
            content_match = content_text.rstrip('"}')
            break
    
    if content_match is None:
        return None
    
    return {
        "send_to": send_to_match,
        "content": content_match
    }


class ResponseBuilder:
    """
    Helper class for building structured responses.
    
    Agents use this to emit JSON responses with "send_to" and "content" fields.
    
    Example:
        ```python
        builder = ResponseBuilder()
        builder.send_to("Editor").content("Here's my draft")
        response = builder.build()  # {"send_to": "Editor", "content": "..."}
        ```
    """
    
    def __init__(self):
        """Initialize the response builder."""
        self._send_to: Optional[str] = None
        self._content: Optional[str] = None
    
    def send_to(self, agent_name: str) -> "ResponseBuilder":
        """
        Set the target agent name.
        
        Args:
            agent_name: Name of the agent to send the message to
            
        Returns:
            Self for method chaining
        """
        self._send_to = agent_name
        return self
    
    def content(self, text: str) -> "ResponseBuilder":
        """
        Set the message content.
        
        Args:
            text: Message content text
            
        Returns:
            Self for method chaining
        """
        self._content = text
        return self
    
    def build(self) -> dict[str, str]:
        """
        Build the structured response dictionary.
        
        Returns:
            Dictionary with "send_to" and "content" keys
            
        Raises:
            ValueError: If send_to or content is not set
        """
        if self._send_to is None:
            raise ValueError("send_to must be set before building response")
        if self._content is None:
            raise ValueError("content must be set before building response")
        
        return {
            "send_to": self._send_to,
            "content": self._content
        }
    
    def to_json(self) -> str:
        """
        Build and return as JSON string.
        
        Returns:
            JSON string representation of the response
        """
        return json.dumps(self.build())


class AgentLogicContext:
    """
    Context object passed to agent logic functions.
    
    Provides access to:
    - Agent's memory
    - Latest incoming message
    - sender: Who sent the current message (context.sender)
    - reply(): Respond directly to sender (context.reply("message"))
    - ResponseBuilder helper
    - send() helper method for convenience
    - get_conversation_history() for formatted conversation history
    - workspace reference for transcript access
    - shared_plan: Shared workspace plan that agents can read/update
    
    Example:
        ```python
        async def agent_logic(context: AgentLogicContext):
            # See who sent the message
            sender = context.sender  # e.g., "alice"
            
            # Reply directly to sender
            return context.reply("Thanks for the message!")
            
            # Or send to someone specific
            return context.send("bob", "Hey Bob!")
        ```
    
    WARNING: Agent logic functions must NOT directly use any router.
    Routing is strictly handled by ExecutionEngine + Workspace.
    """
    
    def __init__(
        self, 
        memory: AgentMemory, 
        default_target: Optional[str] = None,
        workspace: Optional[Any] = None,
        agent_name: Optional[str] = None,
        shared_plan: Optional[str] = None
    ):
        """
        Initialize the logic context.
        
        Args:
            memory: Agent's memory instance
            default_target: Default target agent if logic doesn't specify one
            workspace: Optional workspace reference for transcript access
            agent_name: Optional agent name for conversation history filtering
            shared_plan: Optional shared workspace plan for coordination
        """
        self.memory = memory
        self.default_target = default_target
        self.workspace = workspace
        self.agent_name = agent_name
        self.shared_plan = shared_plan or ""
        self._response_builder = ResponseBuilder()
    
    @property
    def latest_message(self) -> Optional[InboxMessage]:
        """Get the latest incoming message."""
        return self.memory.get_latest_message()
    
    @property
    def sender(self) -> Optional[str]:
        """
        Get the sender of the latest message.
        
        This is a convenience property to make it easy for agents to see
        who sent them the current message and respond directly.
        
        Returns:
            Name of the agent who sent the latest message, or None
        """
        latest = self.latest_message
        return latest.from_agent if latest else None
    
    @property
    def response(self) -> ResponseBuilder:
        """Get the response builder helper."""
        return self._response_builder
    
    def send(self, to: str, content: str) -> dict[str, str]:
        """
        Helper method to create a structured response.
        
        Args:
            to: Target agent name
            content: Message content
            
        Returns:
            Dictionary with "send_to" and "content" keys
        """
        return {"send_to": to, "content": content}
    
    def reply(self, content: str) -> dict[str, str]:
        """
        Reply directly to the sender of the current message.
        
        This is a convenience method that automatically sends the response
        to whoever sent the latest message. If there's no sender or the
        sender is not set, falls back to default_target.
        
        Args:
            content: Message content
            
        Returns:
            Dictionary with "send_to" and "content" keys
        """
        recipient = self.sender or self.default_target or "USER"
        return {"send_to": recipient, "content": content}
    
    def build_response(self, send_to: str, content: str) -> dict[str, str]:
        """
        Convenience method to build a response.
        
        Args:
            send_to: Target agent name
            content: Message content
            
        Returns:
            Structured response dictionary
        """
        return ResponseBuilder().send_to(send_to).content(content).build()
    
    def get_conversation_history(
        self, 
        format: str = "text",
        include_system_messages: bool = False,
        parse_json_content: bool = True,
        workspace_wide: bool = True,  # CHANGED: Default to True so agents see ALL messages
        max_messages: Optional[int] = None
    ) -> str | List[dict]:
        """
        Get formatted conversation history for this agent.
        
        This method extracts the conversation history from the workspace transcript,
        including both messages received and sent by this agent. It automatically
        parses JSON content to extract the actual message content.
        
        Args:
            format: Output format - "text" for formatted string, "raw" for list of dicts
            include_system_messages: Whether to include system messages like [startup]
            parse_json_content: Whether to parse JSON content and extract "content" field
            workspace_wide: If True, show ALL messages in workspace (not just involving this agent)
            max_messages: Maximum number of messages to include (most recent), None for all
            
        Returns:
            Formatted conversation history as string (if format="text") or list of dicts (if format="raw")
            
        Example:
            ```python
            async def agent_logic(context):
                # Get conversation history as formatted text
                history = context.get_conversation_history()
                
                # Get full workspace conversation (all agents)
                full_history = context.get_conversation_history(workspace_wide=True)
                
                # Get last 10 messages only
                recent_history = context.get_conversation_history(max_messages=10)
                
                # Pass to LLM
                response = await llm.chat(history)
                return context.send("Editor", response)
            ```
        """
        if not self.workspace or not self.agent_name:
            # No workspace context, return empty
            return "" if format == "text" else []
        
        transcript = self.workspace.router.get_transcript()
        
        # 🔧 FIX: Limit transcript to max_messages if specified (most recent)
        if max_messages is not None and len(transcript) > max_messages:
            transcript = transcript[-max_messages:]
        
        conversation_parts = []
        raw_messages = []
        
        for entry in transcript:
            sender = entry.get("from", "")
            recipient = entry.get("to", "")
            content = entry.get("content", "")
            
            # Skip system messages unless requested
            if not include_system_messages:
                if content == "[startup]" or content.startswith("[subteam_result]"):
                    continue
            
            # Filter based on workspace_wide parameter
            if not workspace_wide:
                # Only include messages involving this agent (received or sent)
                if not (recipient == self.agent_name or sender == self.agent_name):
                    continue
            
            # Process message for inclusion
            if True:  # Keep the same indentation level
                # Parse JSON content if requested
                display_content = content
                if parse_json_content:
                    try:
                        parsed = json.loads(content)
                        if isinstance(parsed, dict) and "content" in parsed:
                            display_content = parsed["content"]
                    except (json.JSONDecodeError, TypeError):
                        # Not JSON or no "content" field, use as-is
                        pass
                
                # Build message entry - clean format without arrows
                if format == "text":
                    if sender == "USER":
                        conversation_parts.append(f"[USER]\n{display_content}")
                    elif sender == self.agent_name:
                        # Show your own messages
                        if recipient == "ALL":
                            conversation_parts.append(f"[YOU broadcast]\n{display_content}")
                        else:
                            conversation_parts.append(f"[YOU to {recipient}]\n{display_content}")
                    elif workspace_wide:
                        # Workspace-wide view: show sender and recipient clearly
                        if recipient == "ALL":
                            conversation_parts.append(f"[{sender} broadcast]\n{display_content}")
                        else:
                            conversation_parts.append(f"[{sender} to {recipient}]\n{display_content}")
                    else:
                        # Emphasize who is speaking to you
                        conversation_parts.append(f"[{sender}]\n{display_content}")
                else:
                    raw_messages.append({
                        "sender": sender,
                        "recipient": recipient,
                        "content": display_content,
                        "original_content": content,
                        "timestamp": entry.get("timestamp", "")
                    })
        
        if format == "text":
            # Add clear indicator of current sender at the end
            if conversation_parts:
                latest = self.latest_message
                if latest and latest.from_agent:
                    conversation_parts.append(f"\n>>> Current message is FROM: {latest.from_agent}")
                    conversation_parts.append(f">>> You should respond TO: {latest.from_agent}")
            
            return "\n\n".join(conversation_parts)
        else:
            return raw_messages


class Agent:
    """
    Agent with built-in inbox memory, JSON-structured response rules, and optional email capabilities.
    
    This class wraps a logic function and provides it with access to:
    - The agent's memory (via AgentLogicContext)
    - The latest incoming message
    - A ResponseBuilder helper for structured responses
    - Optional email addressing and cloud messaging (if created with a role)
    
    The logic function should return JSON with "send_to" and "content" fields.
    If it returns non-JSON, it will be automatically wrapped.
    
    Email Capabilities:
    When created with a 'role' parameter, agents get:
    - Email-like identity (name@role)
    - Cryptographic keypair (Ed25519)
    - Cloud registration capability
    - Remote message sending
    
    WARNING: Agent logic functions must NOT directly use any router.
    Routing is strictly handled by ExecutionEngine + Workspace. Any attempt
    to access or use a router within agent logic will raise an assertion error.
    
    Example (Local workspace):
        ```python
        async def writer_logic(context: AgentLogicContext) -> dict:
            latest = context.latest_message
            if not latest:
                return context.send("Editor", "I'm ready!")
            
            # Process message and respond
            return context.send("Editor", "Here's my draft")
        
        agent = Agent(
            name="Writer",
            description="Creative writer",
            logic=writer_logic,
            default_target="Editor"
        )
        ```
    
    Example (With email capabilities):
        ```python
        async def alice_logic(ctx):
            # Your AI logic here
            return ctx.send("bob@builder", "Hello!")
        
        alice = Agent(name="alice", role="wonderland", logic=alice_logic)
        await alice.register()  # Register on cloud
        await alice.send("bob@builder", "Hello!")  # Send via cloud
        ```
    """
    
    def __init__(
        self,
        name: str,
        description: str = "",
        logic: Optional[Callable[[AgentLogicContext], Coroutine[Any, Any, dict[str, str] | str]]] = None,
        default_target: Optional[str] = None,
        memory: Optional[AgentMemory] = None,
        role: Optional[str] = None,
        inbox_url: str = "https://synqed.fly.dev",
        metadata: Optional[Dict[str, Any]] = None,
        capabilities: Optional[List[str]] = None,
        default_coordination: str = "respond_to_sender",
    ):
        """
        Initialize an Agent.
        
        Args:
            name: Agent name
            description: Agent description
            logic: Optional async function that takes AgentLogicContext and returns
                   dict with "send_to" and "content", or a string (will be wrapped)
            default_target: Default target agent if logic returns non-JSON or
                           doesn't specify send_to
            memory: Optional pre-existing memory instance (creates new one if not provided)
            role: Optional role/organization for email addressing (name@role)
            inbox_url: Cloud inbox URL (default: synqed.fly.dev)
            metadata: Optional metadata to store with registration
            capabilities: Optional list of capabilities for this agent
            default_coordination: Default coordination style (default: "respond_to_sender")
        """
        if not name:
            raise ValueError("Agent name must be provided")
        
        # Use default no-op logic if none provided
        if logic is None:
            async def default_logic(ctx: AgentLogicContext):
                # No-op logic for cloud-only agents
                return None
            logic = default_logic
        
        # Validate logic is async
        import inspect
        if not inspect.iscoroutinefunction(logic):
            raise ValueError("Agent logic must be an async function")
        
        self.name = name
        self.description = description or f"Agent {name}"
        self.logic = logic
        self.default_target = default_target
        self.memory = memory or AgentMemory(agent_name=name)
        
        # Baseline interaction metadata
        self.capabilities = capabilities or []
        self.default_coordination = default_coordination
        
        # Ensure memory has agent name set
        if self.memory.agent_name != name:
            self.memory.agent_name = name
        
        # Duplicate-response protection
        self.last_processed_message_id: Optional[str] = None
        
        # Email capabilities (if role is provided)
        self.role = role
        if role:
            self.email = f"{name}@{role}"
            self.agent_id = f"agent://{role}/{name}"
            
            # Cloud messaging config
            self.inbox_url = inbox_url
            self.metadata = metadata or {}
            
            # Cryptographic identity
            try:
                from synqed.agent_email.inbox import generate_keypair, sign_message
                self.private_key, self.public_key = generate_keypair()
                self._sign_message = sign_message
            except ImportError:
                # Email features not available
                self.private_key = None
                self.public_key = None
                self._sign_message = None
        else:
            self.email = None
            self.agent_id = None
            self.inbox_url = None
            self.metadata = None
            self.private_key = None
            self.public_key = None
            self._sign_message = None
    
    def __deepcopy__(self, memo):
        """
        Deep copy the agent to ensure isolation between workspaces.
        
        Creates new instances of mutable objects to prevent shared state.
        Memory is fully deep-copied, but the logic function reference is NOT
        deep-copied (functions are immutable and should be shared).
        """
        import copy
        cls = self.__class__
        result = cls.__new__(cls)
        memo[id(self)] = result
        
        # Copy immutable fields
        result.name = self.name
        result.description = self.description
        result.logic = self.logic  # Function reference must NOT be deepcopied
        result.default_target = self.default_target
        
        # Deep copy memory - ensure no shared mutable fields remain
        result.memory = copy.deepcopy(self.memory, memo)
        
        # Copy baseline interaction metadata
        result.capabilities = copy.deepcopy(self.capabilities, memo) if self.capabilities else []
        result.default_coordination = self.default_coordination
        
        # Copy duplicate-response protection field
        result.last_processed_message_id = self.last_processed_message_id
        
        # Copy email-related fields
        result.role = self.role
        result.email = self.email
        result.agent_id = self.agent_id
        result.inbox_url = self.inbox_url
        result.metadata = copy.deepcopy(self.metadata, memo) if self.metadata else None
        result.private_key = self.private_key
        result.public_key = self.public_key
        result._sign_message = self._sign_message
        
        return result
    
    def _build_role_profile(self) -> str:
        """
        Build the role profile section for agent system prompt.
        
        This creates a standardized profile that describes the agent's role,
        capabilities, and coordination style.
        
        Returns:
            Formatted role profile string
        """
        role_display = self.role or self.name
        capabilities_display = ", ".join(self.capabilities) if self.capabilities else "general purpose"
        
        return f"""
YOUR ROLE: {role_display}
YOUR CAPABILITIES: {capabilities_display}
DEFAULT COORDINATION STYLE: {self.default_coordination}
"""
    
    def get_system_prompt_with_protocol(self, custom_prompt: str = "") -> str:
        """
        Build complete system prompt with global protocol and role profile.
        
        This method combines:
        1. Global interaction protocol (universal rules)
        2. Team roster (other agents' roles and capabilities) - automatic!
        3. Role profile (agent-specific metadata)
        4. Custom prompt (developer-provided instructions)
        
        Args:
            custom_prompt: Optional custom instructions for this agent
            
        Returns:
            Complete system prompt string
        """
        # Get protocol with team roster automatically included
        protocol = get_interaction_protocol(exclude_agent=self.name)
        role_profile = self._build_role_profile()
        
        parts = [protocol, role_profile]
        
        if custom_prompt:
            parts.append(custom_prompt)
        
        return "\n".join(parts)
    
    def _build_team_roster(self) -> str:
        """
        Build team roster showing other agents' roles and capabilities.
        
        This automatically queries the AgentRuntimeRegistry to discover
        what other agents are available and what they can do. This prevents
        agents from making incorrect assumptions about each other's roles.
        
        Returns:
            Formatted team roster string, or empty string if no other agents
        """
        from synqed.workspace_manager import AgentRuntimeRegistry
        
        team_members = []
        for role in AgentRuntimeRegistry.list_roles():
            if role != self.name:  # Don't include self
                agent = AgentRuntimeRegistry.get(role)
                if agent:
                    caps = agent.capabilities if agent.capabilities else ["general tasks"]
                    team_members.append(
                        f"  - {role}: {agent.description}\n"
                        f"    Capabilities: {', '.join(caps)}"
                    )
        
        if not team_members:
            return ""
        
        return f"""
TEAM MEMBERS (other agents in this workspace):
{chr(10).join(team_members)}

When coordinating, contact the appropriate agent based on their capabilities above."""
    
    async def process(self, context: AgentLogicContext) -> Optional[dict[str, str]]:
        """
        Process the agent's inbox and generate a response.
        
        This method accepts an externally supplied context and calls the
        agent's logic function with it. The context must be created by the
        caller (typically ExecutionEngine or Workspace).
        
        This method:
        1. Validates that context is provided
        2. Ensures agent logic does not directly use router
        3. Checks for duplicate message processing
        4. Calls the agent's logic function with the provided context
        5. Ensures the response is valid JSON with "send_to" and "content"
        6. Wraps non-JSON responses automatically
        
        Args:
            context: AgentLogicContext instance (must be externally supplied)
        
        Returns:
            Dictionary with "send_to" and "content" keys, or None if no response needed
            
        Raises:
            ValueError: If context is None or response cannot be parsed or structured
            AssertionError: If agent logic attempts to use router directly
        """
        if context is None:
            raise ValueError("Agent.process() requires an externally supplied context")
        
        # Ensure agent logic does not directly use router
        # This is enforced by checking that context doesn't have router access
        # (Router should not be accessible from AgentLogicContext)
        assert not hasattr(context, 'router'), (
            "Agent logic must NOT directly use any router. "
            "Routing is strictly handled by ExecutionEngine + Workspace."
        )
        
        # Get latest message for filtering
        latest_msg = context.latest_message
        
        # AUTOMATIC MESSAGE FILTERING (built into framework)
        if latest_msg:
            # 1. Target filtering: only process messages addressed to this agent
            if hasattr(latest_msg, 'target') and latest_msg.target is not None:
                if latest_msg.target != self.name:
                    return None  # Message not for us, skip
            
            # 2. System message filtering: skip startup messages
            if latest_msg.content in ["[startup]", ""]:
                return None
            
            # 3. Duplicate-response protection: check if we already processed this message
            if latest_msg.message_id == self.last_processed_message_id:
                return None  # Already processed, skip
        
        # Call logic function with provided context
        result = await self.logic(context)
        
        # Update last processed message ID
        if latest_msg:
            self.last_processed_message_id = latest_msg.message_id
        
        # If logic returns None, pass it through (agent chose not to respond)
        if result is None:
            return None
        
        # Ensure result is structured JSON
        return self._ensure_structured_response(result, context.default_target)
    
    def _ensure_structured_response(
        self, result: dict[str, str] | str, default_target: Optional[str] = None
    ) -> dict[str, str]:
        """
        Ensure the response is valid structured JSON with strict validation.
        
        This method automatically handles:
        - Markdown code block stripping (```json ... ```)
        - Partial/truncated JSON responses
        - Missing "send_to" fields (uses default_target, prefers sender)
        - Missing "content" fields (converts whole object to JSON string)
        
        Validation rules:
        - If missing "send_to" → prefer sender, then default_target
        - If missing "content" → convert whole object to JSON string
        - If no target available → raise descriptive error
        
        Args:
            result: Result from logic function (dict or string)
            default_target: Default target agent (from context)
            
        Returns:
            Valid structured response dictionary
            
        Raises:
            ValueError: If default_target is None and "send_to" is missing
        """
        # Prefer sender of latest message, fall back to default_target
        # This ensures agents respond to whoever messaged them by default
        sender_of_latest = None
        if self.memory:
            latest = self.memory.get_latest_message()
            if latest and latest.from_agent:
                sender_of_latest = latest.from_agent
        
        effective_default = sender_of_latest or default_target or self.default_target
        
        # If already a dict, validate structure
        if isinstance(result, dict):
            send_to = result.get("send_to")
            content = result.get("content")
            
            # If missing "send_to" → use default_target
            if send_to is None:
                if effective_default is None:
                    raise ValueError(
                        "Response missing 'send_to' field and no default_target is set. "
                        "Either provide 'send_to' in the response or set a default_target."
                    )
                send_to = effective_default
            
            # If missing "content" → convert whole object to JSON string
            if content is None:
                content = json.dumps(result)
            
            return {
                "send_to": send_to,
                "content": content
            }
        
        # If string, try to parse as JSON (with automatic cleaning)
        if isinstance(result, str):
            # Step 1: Strip markdown code blocks
            cleaned_result = parse_llm_response(result)
            
            # Step 2: Try to parse as JSON
            try:
                parsed = json.loads(cleaned_result)
                if isinstance(parsed, dict):
                    send_to = parsed.get("send_to")
                    content = parsed.get("content")
                    
                    # If missing "send_to" → use default_target
                    if send_to is None:
                        if effective_default is None:
                            raise ValueError(
                                "Response missing 'send_to' field and no default_target is set. "
                                "Either provide 'send_to' in the response or set a default_target."
                            )
                        send_to = effective_default
                    
                    # If missing "content" → convert whole object to JSON string
                    if content is None:
                        content = json.dumps(parsed)
                    
                    return {
                        "send_to": send_to,
                        "content": content
                    }
                else:
                    # Invalid JSON structure, wrap it
                    if effective_default is None:
                        raise ValueError(
                            "Response is not a valid dict and no default_target is set. "
                            "Either return a dict with 'send_to' and 'content' or set a default_target."
                        )
                    return {
                        "send_to": effective_default,
                        "content": result
                    }
            except json.JSONDecodeError:
                # Step 3: Try to extract partial JSON (handles truncated responses)
                partial = extract_partial_json_content(cleaned_result)
                if partial:
                    return partial
                
                # Step 4: Not JSON, wrap it
                if effective_default is None:
                    raise ValueError(
                        "Response is not valid JSON and no default_target is set. "
                        "Either return a dict with 'send_to' and 'content' or set a default_target."
                    )
                return {
                    "send_to": effective_default,
                    "content": result
                }
        
        # Fallback: convert to string and wrap
        if effective_default is None:
            raise ValueError(
                f"Response type '{type(result).__name__}' is not supported and no default_target is set. "
                "Either return a dict with 'send_to' and 'content' or set a default_target."
            )
        return {
            "send_to": effective_default,
            "content": str(result)
        }
    
    async def register(self) -> Dict[str, Any]:
        """
        Register this agent with Synqed cloud.
        
        Requires that the agent was created with a role (for email addressing).
        
        Returns:
            Registration response with status and agent details
            
        Raises:
            ValueError: If agent was not created with a role
            httpx.HTTPStatusError: If registration fails
        """
        if not self.role or not self.email:
            raise ValueError(
                "Agent must be created with a 'role' parameter to use email capabilities. "
                "Example: Agent(name='alice', role='wonderland', ...)"
            )
        
        if not self.public_key:
            raise ValueError("Agent does not have cryptographic keys. Email features may not be available.")
        
        registration = {
            "agent_id": self.agent_id,
            "email_like": self.email,
            "inbox_url": f"{self.inbox_url}/v1/a2a/inbox",
            "public_key": self.public_key,
            "capabilities": ["a2a/1.0"],
            "metadata": self.metadata,
        }
        
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.inbox_url}/v1/a2a/register",
                json=registration,
                timeout=30.0,
            )
            response.raise_for_status()
            return response.json()
    
    async def send(
        self,
        to: str,
        content: str,
        thread_id: Optional[str] = None,
        via_cloud: bool = True,
    ) -> Dict[str, Any]:
        """
        Send a message to another agent via cloud.
        
        This is for direct cloud messaging. For workspace-based routing,
        use the agent in a Workspace and return messages from your logic function.
        
        Args:
            to: Recipient's email address (e.g., "bob@builder") or agent_id
            content: Message content
            thread_id: Optional conversation thread ID
            via_cloud: If True, send via cloud inbox (default). If False, just return message dict.
            
        Returns:
            Delivery response with message_id and trace_id (if via_cloud=True)
            Or message dict (if via_cloud=False, for workspace routing)
            
        Raises:
            ValueError: If agent was not created with a role
            httpx.HTTPStatusError: If message delivery fails
        """
        if not self.role or not self.email:
            raise ValueError(
                "Agent must be created with a 'role' parameter to use email capabilities. "
                "Example: Agent(name='alice', role='wonderland', ...)"
            )
        
        if not via_cloud:
            # Return message dict for workspace routing
            return {"send_to": to, "content": content}
        
        if not self._sign_message or not self.private_key:
            raise ValueError("Agent does not have cryptographic keys. Email features may not be available.")
        
        # Convert email to agent_id
        if "@" in to:
            username, domain = to.split("@")
            recipient_id = f"agent://{domain}/{username}"
        else:
            recipient_id = to
        
        if thread_id is None:
            thread_id = f"conversation-{self.name}"
        
        # Create message
        message = {
            "thread_id": thread_id,
            "role": "user",
            "content": content,
        }
        
        # Sign with private key
        signature = self._sign_message(
            private_key_b64=self.private_key,
            sender=self.agent_id,
            recipient=recipient_id,
            message=message,
            thread_id=thread_id,
        )
        
        # Send to cloud inbox
        envelope = {
            "sender": self.agent_id,
            "recipient": recipient_id,
            "message": message,
            "signature": signature,
        }
        
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.inbox_url}/v1/a2a/inbox",
                json=envelope,
                timeout=30.0,
            )
            response.raise_for_status()
            return response.json()
    
    def __repr__(self) -> str:
        """String representation."""
        if self.email:
            return f"Agent(name='{self.name}', email='{self.email}')"
        return f"Agent(name='{self.name}', memory_messages={len(self.memory.messages)})"
