================================================================================
File: hikigai/agentsdk/__init__.py
================================================================================

"""
hikigai-agentsdk: Python SDK for deploying AI agents.

Deploy and manage AI agents on the Hikigai platform.
"""

__version__ = "0.1.2"

from hikigai.agentsdk.client import AgentClient
from hikigai.agentsdk.rooms import RoomError, RoomSession
from hikigai.agentsdk.events import (
    SIGNATURE_HEADER,
    EventsClient,
    EventStream,
    EventStreamError,
    SignatureVerificationError,
    WebhookDelivery,
    WebhookSubscription,
    parse_webhook_event,
    verify_webhook_signature,
)
from hikigai.agentsdk.models import (
    AgentConfig,
    ToolConfig,
    ConnectorConfig,
    InputSchema,
    OutputSchema,
    StringField,
    IntegerField,
    BooleanField,
    ArrayField,
    ObjectField,
    DeployedAgent,
    DeploymentResult,
    RuntimeConfig,
    HIPAAConfig,
    SubAgentConfig,
    PlannerConfig,
    GenerationConfig,
)
from hikigai.agentsdk.tools import tool, FunctionTool, OpenAPITool, MCPTool

# Re-export common exceptions from core
from hikigai.core.exceptions import (
    HikigaiError,
    AuthenticationError,
    RateLimitError,
    ValidationError,
    DeploymentError,
    ConfigurationError,
)

__all__ = [
    "RoomSession",
    "RoomError",
    "__version__",
    # Client
    "AgentClient",
    # Event bus — webhooks + live event stream
    "EventsClient",
    "EventStream",
    "EventStreamError",
    "WebhookSubscription",
    "WebhookDelivery",
    "SignatureVerificationError",
    "verify_webhook_signature",
    "parse_webhook_event",
    "SIGNATURE_HEADER",
    # Configuration
    "AgentConfig",
    "ToolConfig",
    "ConnectorConfig",
    "RuntimeConfig",
    "HIPAAConfig",
    "SubAgentConfig",
    "PlannerConfig",
    "GenerationConfig",
    # Schemas
    "InputSchema",
    "OutputSchema",
    "StringField",
    "IntegerField",
    "BooleanField",
    "ArrayField",
    "ObjectField",
    # Models
    "DeployedAgent",
    "DeploymentResult",
    # Tools
    "tool",
    "FunctionTool",
    "OpenAPITool",
    "MCPTool",
    # Exceptions
    "HikigaiError",
    "AuthenticationError",
    "RateLimitError",
    "ValidationError",
    "DeploymentError",
    "ConfigurationError",
]


================================================================================
File: hikigai/agentsdk/client.py
================================================================================

"""
AgentClient: Deploy and manage AI agents on the Hikigai platform.

This is the main client for agent developers to deploy, update, and manage agents.
"""

import os
import time
import logging
from typing import Iterator, List, Optional, Dict, Any, Union
from pathlib import Path

from hikigai.core.api.client import APIClient
from hikigai.core.exceptions import (
    ConfigurationError,
    DeploymentError,
    AgentNotFoundError,
)

from hikigai.agentsdk.models.config import AgentConfig
from hikigai.agentsdk.models.agent import DeployedAgent, DeploymentResult
from hikigai.agentsdk.models.schemas import InputSchema, OutputSchema
from hikigai.agentsdk.tools import normalize_tools

logger = logging.getLogger(__name__)


class AgentClient:
    """
    Client for deploying and managing AI agents.
    
    Example:
        client = AgentClient(
            api_key=os.environ["HIKIGAI_API_KEY"],
            project_id=os.environ["HIKIGAI_PROJECT_ID"]
        )
        
        # Deploy an agent
        agent = client.deploy(AgentConfig(...))
        
        # List agents
        agents = client.list_agents()
        
        # Delete an agent
        client.delete_agent(agent.id)
    """
    
    def __init__(
        self,
        api_key: Optional[str] = None,
        project_id: Optional[str] = None,
        base_url: Optional[str] = None,
        timeout: float = 30.0,
    ):
        """
        Initialize AgentClient.
        
        Args:
            api_key: Hikigai API key (defaults to HIKIGAI_API_KEY env var)
            project_id: Project ID (defaults to HIKIGAI_PROJECT_ID env var)
            base_url: API base URL (defaults to production)
            timeout: Default request timeout in seconds
            
        Raises:
            ConfigurationError: If required credentials are missing
        """
        # Get credentials
        self.api_key = api_key or os.environ.get("HIKIGAI_API_KEY")
        self.project_id = project_id or os.environ.get("HIKIGAI_PROJECT_ID")
        
        if not self.api_key:
            raise ConfigurationError(
                "API key is required. Provide it via api_key parameter or HIKIGAI_API_KEY env var."
            )
        
        if not self.project_id:
            raise ConfigurationError(
                "Project ID is required. Provide it via project_id parameter or HIKIGAI_PROJECT_ID env var."
            )
        
        # Initialize HTTP client
        self.api = APIClient(
            api_key=self.api_key,
            project_id=self.project_id,
            base_url=base_url,
            timeout=timeout,
        )
        
        logger.info(f"AgentClient initialized for project: {self.project_id}")

    @property
    def jobs(self):
        """Background job queue client."""
        if not hasattr(self, "_jobs_client"):
            from hikigai.agentsdk.jobs import JobsClient
            self._jobs_client = JobsClient(self.api)
        return self._jobs_client

    @property
    def events(self):
        """Platform event bus client: webhooks and the live event stream.

        Webhooks push events to an endpoint you own (at-least-once, signed);
        ``events.stream()`` opens a live WebSocket subscription instead.
        """
        if not hasattr(self, "_events_client"):
            from hikigai.agentsdk.events import EventsClient
            self._events_client = EventsClient(self)
        return self._events_client


    def deploy(
        self,
        config: AgentConfig,
        timeout: Optional[float] = 1200.0,
        poll_interval: float = 10.0,
    ) -> DeployedAgent:
        """
        Deploy an agent to the platform.
        
        The backend registers the agent and starts deployment in the background.
        This method polls for deployment completion until the agent status
        transitions to 'active' (success) or 'error'/'failed'.
        
        Args:
            config: Agent configuration
            timeout: Deployment timeout in seconds (default: 20 minutes)
            poll_interval: Seconds between status polls (default: 10)
            
        Returns:
            DeployedAgent: Deployed agent metadata
            
        Raises:
            DeploymentError: If deployment fails
            ValidationError: If configuration is invalid
        """
        logger.info(f"Deploying agent: {config.name}")
        
        # Prepare payload
        payload = self._prepare_deploy_payload(config)
        
        try:
            # Call deployment API — returns immediately with status="pending"
            response = self.api.post(
                "/api/v1/agents/deploy/full",
                json=payload,
                timeout=120.0,  # Short timeout for the registration call
            )
            
            agent_id = response["agent_id"]
            agent_status = response.get("status", "pending")
            job_id = response.get("job_id")
            slug = response.get("slug", config.name)
            
            # Start the timeout clock AFTER registration succeeds.
            # The 600 s budget is for polling only — not for the initial
            # POST which can take time for HIPAA checks + code generation.
            start_time = time.time()
            
            logger.info(
                f"Agent registered: {slug} (id={agent_id}, status={agent_status}"
                + (f", job_id={job_id})" if job_id else ")")
            )
            
            # If already deployed (shouldn't happen with background tasks, but handle it)
            if agent_status in ("active", "deployed"):
                return DeployedAgent(
                    id=agent_id,
                    name=response["name"],
                    slug=response["slug"],
                    version=response.get("version", "1.0.0"),
                    deployment_status=agent_status,
                    deployment_type=response.get("deployment_type", "config_based"),
                    endpoint_url=response.get("endpoint"),
                    resource_name=response.get("resource_name"),
                    hipaa_compliant=response.get("hipaa_compliant", True),
                    hipaa_verified=response.get("hipaa_verified", False),
                )
            
            # If immediately errored
            if agent_status in ("error", "failed"):
                msg = response.get("message", "Deployment failed")
                raise DeploymentError(msg, agent_name=config.name)
            
            # Poll for deployment completion (agent status or job queue)
            logger.info(
                f"Deployment running in background. Polling every {poll_interval}s "
                f"(timeout={timeout}s)..."
            )

            pending_statuses = {"pending", "queued", "scheduled", "deploying", "running", "retrying"}
            
            while True:
                time.sleep(poll_interval)
                elapsed = time.time() - start_time

                try:
                    completed = self._poll_deploy_completion(
                        agent_id=agent_id,
                        job_id=job_id,
                        config_name=config.name,
                        slug=slug,
                        register_response=response,
                        elapsed=elapsed,
                    )
                    if completed:
                        logger.info(
                            f"Agent deployed successfully in {elapsed:.1f}s: {completed.slug}"
                        )
                        return completed
                except DeploymentError:
                    raise
                except Exception as poll_error:
                    logger.warning(f"  Polling error (will retry): {poll_error}")

                if elapsed >= timeout:
                    try:
                        completed = self._poll_deploy_completion(
                            agent_id=agent_id,
                            job_id=job_id,
                            config_name=config.name,
                            slug=slug,
                            register_response=response,
                            elapsed=elapsed,
                        )
                        if completed:
                            logger.info(
                                f"Agent deployed successfully in {elapsed:.1f}s: {completed.slug}"
                            )
                            return completed
                    except DeploymentError:
                        raise
                    except Exception as poll_error:
                        logger.warning(f"  Final deploy poll failed: {poll_error}")

                    hint = f"client.get_agent('{agent_id}')"
                    if job_id:
                        hint += f" or client.jobs.get('{job_id}')"
                    raise DeploymentError(
                        f"Deployment timed out after {timeout}s. "
                        f"The agent may still be deploying — check status with {hint}",
                        agent_name=config.name,
                    )
            
        except DeploymentError:
            raise
        except Exception as e:
            logger.error(f"Deployment failed: {e}")
            raise DeploymentError(f"Failed to deploy agent: {e}", agent_name=config.name)

    def _poll_deploy_completion(
        self,
        *,
        agent_id: str,
        job_id: Optional[str],
        config_name: str,
        slug: str,
        register_response: Dict[str, Any],
        elapsed: float,
    ) -> Optional[DeployedAgent]:
        """Poll agent and job status; return DeployedAgent when deployment finishes."""
        status_response = self.api.get(
            f"/api/v1/agents/{agent_id}",
            timeout=120.0,
        )
        current_status = (
            status_response.get("deployment_status")
            or status_response.get("status", "pending")
        )

        logger.info(f"  [{elapsed:.0f}s] Agent status: {current_status}")

        if current_status in ("active", "deployed"):
            return self._deployed_agent_from_poll(
                agent_id=agent_id,
                config_name=config_name,
                slug=slug,
                register_response=register_response,
                status_response=status_response,
                deployment_status=current_status,
            )

        if current_status in ("error", "failed"):
            error_msg = (
                status_response.get("deployment_error")
                or status_response.get("error")
                or status_response.get("message")
                or status_response.get("detail")
                or status_response.get("reason")
                or "No details provided by server."
            )
            logger.error(
                f"Deployment failed: {error_msg}\n"
                f"  Full response: {status_response}"
            )
            raise DeploymentError(
                f"Deployment failed: {error_msg}",
                agent_name=config_name,
            )

        job_result: Optional[Dict[str, Any]] = None
        if job_id:
            try:
                job_detail = self.jobs.get(job_id)
                job_status = job_detail.status.value
                logger.info(
                    f"  [{elapsed:.0f}s] Job status: {job_status}"
                    + (
                        f" (attempt {job_detail.current_attempt})"
                        if job_detail.current_attempt
                        else ""
                    )
                )
                if job_status in ("failed", "dead_lettered", "cancelled"):
                    error_msg = (
                        job_detail.last_error
                        or f"Deployment job ended with status: {job_status}"
                    )
                    raise DeploymentError(
                        f"Deployment failed: {error_msg}",
                        agent_name=config_name,
                    )
                if job_status == "completed":
                    job_result = job_detail.result or {}
                    return self._deployed_agent_from_poll(
                        agent_id=agent_id,
                        config_name=config_name,
                        slug=slug,
                        register_response=register_response,
                        status_response=status_response,
                        deployment_status="active",
                        job_result=job_result,
                    )
            except DeploymentError:
                raise
            except Exception as job_poll_error:
                logger.warning(f"  Job poll error (will retry): {job_poll_error}")

        return None

    def _deployed_agent_from_poll(
        self,
        *,
        agent_id: str,
        config_name: str,
        slug: str,
        register_response: Dict[str, Any],
        status_response: Dict[str, Any],
        deployment_status: str,
        job_result: Optional[Dict[str, Any]] = None,
    ) -> DeployedAgent:
        job_result = job_result or {}
        endpoint_url = (
            status_response.get("endpoint_url")
            or status_response.get("endpoint")
            or job_result.get("endpoint_url")
        )
        return DeployedAgent(
            id=status_response.get("id", agent_id),
            name=status_response.get("name", config_name),
            slug=status_response.get("slug", slug),
            version=register_response.get("version", "1.0.0"),
            deployment_status=deployment_status,
            deployment_type=register_response.get("deployment_type", "config_based"),
            endpoint_url=endpoint_url,
            resource_name=job_result.get("resource_name")
            or register_response.get("resource_name"),
            hipaa_compliant=register_response.get("hipaa_compliant", True),
            hipaa_verified=register_response.get("hipaa_verified", False),
        )
    
    def deploy_from_file(
        self,
        file_path: str,
        name: Optional[str] = None,
        description: Optional[str] = None,
    ) -> DeployedAgent:
        """
        Deploy an agent from a Python file.
        
        Args:
            file_path: Path to agent.py file
            name: Optional agent name override
            description: Optional description
            
        Returns:
            DeployedAgent: Deployed agent metadata
            
        Raises:
            DeploymentError: If deployment fails
            FileNotFoundError: If file doesn't exist
        """
        path = Path(file_path)
        
        if not path.exists():
            raise FileNotFoundError(f"Agent file not found: {file_path}")
        
        # Read agent code
        agent_code = path.read_text()
        
        logger.info(f"Deploying agent from file: {file_path}")
        
        # Call file deployment API
        payload = {
            "agent_code": agent_code,
            "name": name,
            "description": description,
        }
        
        try:
            response = self.api.post(
                "/api/v1/agents/deploy/adk",
                json=payload,
                timeout=600.0,
            )
            
            return DeployedAgent(
                id=response["agent_id"],
                name=response["name"],
                slug=response["slug"],
                version=response["version"],
                deployment_status=response["status"],
                deployment_type=response["deployment_type"],
                endpoint_url=response.get("endpoint"),
                resource_name=response.get("resource_name"),
                hipaa_compliant=response.get("hipaa_compliant", True),
                hipaa_verified=response.get("hipaa_verified", False),
            )
            
        except Exception as e:
            logger.error(f"File deployment failed: {e}")
            raise DeploymentError(f"Failed to deploy from file: {e}")
    
    def list_agents(self) -> List[DeployedAgent]:
        """
        List all agents in the project.
        
        Returns:
            List of deployed agents
        """
        logger.debug("Listing agents")
        
        try:
            response = self.api.get("/api/v1/agents")
            
            agents = []
            for agent_data in response.get("agents", []):
                agents.append(DeployedAgent(
                    id=agent_data["id"],
                    name=agent_data["name"],
                    slug=agent_data["slug"],
                    version=agent_data.get("version", "1.0.0"),
                    display_name=agent_data.get("display_name"),
                    description=agent_data.get("description"),
                    deployment_status=agent_data["deployment_status"],
                    deployment_type=agent_data.get("deployment_type", "unknown"),
                    endpoint_url=agent_data.get("endpoint_url"),
                    cloud_provider=agent_data.get("cloud_provider"),
                    region=agent_data.get("region"),
                ))
            
            return agents
            
        except Exception as e:
            logger.error(f"Failed to list agents: {e}")
            raise
    
    def get_agent(self, agent_id: str) -> DeployedAgent:
        """
        Get agent by ID or slug.
        
        Args:
            agent_id: Agent ID or slug
            
        Returns:
            DeployedAgent: Agent metadata
            
        Raises:
            AgentNotFoundError: If agent doesn't exist
        """
        logger.debug(f"Getting agent: {agent_id}")
        
        try:
            response = self.api.get(f"/api/v1/agents/{agent_id}")
            
            return DeployedAgent(
                id=response["id"],
                name=response["name"],
                slug=response["slug"],
                version=response.get("version", "1.0.0"),
                display_name=response.get("display_name"),
                description=response.get("description"),
                deployment_status=response["deployment_status"],
                deployment_type=response.get("deployment_type", "unknown"),
                endpoint_url=response.get("endpoint_url"),
                cloud_provider=response.get("cloud_provider"),
                region=response.get("region"),
            )
            
        except Exception as e:
            logger.error(f"Failed to get agent: {e}")
            raise
    
    def delete_agent(self, agent_id: str) -> None:
        """
        Delete an agent.
        
        Args:
            agent_id: Agent ID or slug
            
        Raises:
            AgentNotFoundError: If agent doesn't exist
        """
        logger.info(f"Deleting agent: {agent_id}")
        
        try:
            self.api.delete(f"/api/v1/agents/{agent_id}")
            logger.info(f"Agent deleted: {agent_id}")
            
        except Exception as e:
            logger.error(f"Failed to delete agent: {e}")
            raise

    def invoke(
        self,
        agent_id: str,
        input: Union[Dict[str, Any], str],
        session_id: Optional[str] = None,
        provider: Optional[str] = None,
        model: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Invoke an agent for testing or verification.
        
        Args:
            agent_id: Agent ID or slug
            input: Input message or dict
            session_id: Optional session ID
            provider: Optional provider override
            model: Optional model override
            
        Returns:
            Dict[str, Any]: Raw invocation response
        """
        # Prepare payload
        payload = {"input": input}
        if session_id:
            payload["session_id"] = session_id
        if provider:
            payload["provider"] = provider
        if model:
            payload["model"] = model

        logger.debug(f"Invoking agent: {agent_id}")

        try:
            response = self.api.post(f"/api/v1/agents/{agent_id}/invoke", json=payload)
            logger.debug(f"Invocation succeeded for agent: {agent_id}")
            return response
        except Exception as e:
            logger.error(f"Invocation failed for agent {agent_id}: {e}")
            raise

    def stream(
        self,
        agent_id: str,
        input: Union[Dict[str, Any], str],
        session_id: Optional[str] = None,
        provider: Optional[str] = None,
        model: Optional[str] = None,
    ) -> Iterator[str]:
        """
        Stream agent response in real-time via Server-Sent Events.

        Sends stream=true in the payload so the backend routes to the
        container's /stream endpoint and returns text/event-stream.
        Parses SSE "data: ..." lines and yields each non-empty chunk.

        Args:
            agent_id: Agent ID or slug
            input: Input message or dict
            session_id: Optional session ID for conversation context
            provider: Optional provider override
            model: Optional model override

        Yields:
            str: Response text chunks as they arrive

        Example:
            for chunk in client.stream("medical-coder", "Patient has fever..."):
                print(chunk, end="", flush=True)
        """
        # Include stream=True so the backend routes to the SSE path,
        # matching the same contract used by AppSDK._stream_agent().
        payload: Dict[str, Any] = {
            "input": input,
            "stream": True,
        }
        if session_id:
            payload["session_id"] = session_id
        if provider:
            payload["provider"] = provider
        if model:
            payload["model"] = model

        logger.debug(f"Starting SSE stream for agent: {agent_id}")

        try:
            # self.api.stream() is the httpx streaming context manager exposed
            # by APIClient — same pattern used in AppSDK._stream_agent().
            with self.api.stream(
                "POST",
                f"/api/v1/agents/{agent_id}/invoke",
                json=payload,
            ) as response:
                chunks_yielded = 0
                for line in response.iter_lines():
                    if line:
                        try:
                            line_str = line.decode("utf-8") if isinstance(line, bytes) else line
                        except UnicodeDecodeError:
                            # Guard against malformed bytes in the SSE stream.
                            logger.warning(
                                f"Failed to decode SSE line as UTF-8 for agent {agent_id}: {line!r}"
                            )
                            continue

                        if line_str.startswith("data: "):
                            chunk_data = line_str[6:].strip()
                            if chunk_data and chunk_data != "[DONE]":
                                chunks_yielded += 1
                                yield chunk_data
                            elif chunk_data == "[DONE]":
                                logger.debug(
                                    f"SSE stream complete for agent {agent_id} "
                                    f"({chunks_yielded} chunks yielded)"
                                )
                                break
        except Exception as e:
            logger.error(f"Streaming failed for agent {agent_id}: {e}")
            raise
    
    def get_auth_token(self) -> Dict[str, Any]:
        """
        Exchange the API Key for a short-lived JWT Auth Token.

        Returns:
            Dict[str, Any]: Token details including access_token and expires_in.
        """
        logger.debug("Exchanging API Key for Auth Token")
        return self.api.post("/api/v1/auth/exchange")
    
    def update_agent(self, agent_id: str, update: Dict[str, Any]) -> DeployedAgent:
        """
        Update an existing agent's metadata/runtime config and trigger a redeploy.

        This mirrors the frontend behavior which performs a `PUT /api/v1/agents/{id}`
        with the updated fields (including `sub_agents`) and relies on a developer
        JWT for authentication. If `HIKIGAI_JWT` is present in the environment we
        will inject it into the underlying HTTP client's headers so the call
        matches the frontend authorization flow.
        """
        logger.info(f"Updating agent {agent_id} with fields: {list(update.keys())}")

        # Prefer calling the deploy endpoint to trigger a revision (deploy endpoints
        # accept API-key auth and follow the same flow as initial deploy).
        try:
            current = self.api.get(f"/api/v1/agents/{agent_id}")

            payload = {
                "name": current.get("slug") or current.get("name"),
                "display_name": current.get("display_name") or current.get("name"),
                "description": update.get("description", current.get("description", "")),
                "instruction": update.get("instruction", current.get("instruction", "")),
                "model": update.get("model", current.get("model")),
                "category": update.get("category", current.get("category", "documentation")),
                "tags": update.get("tags", current.get("tags", [])),
                "agent_type": update.get("agent_type", current.get("agent_type", "llm")),
                "version": update.get("version", current.get("version", "1.0.0")),
                "project_id": self.project_id,
                "input_schema": update.get("input_schema", current.get("input_schema", {})),
                "output_schema": update.get("output_schema", current.get("output_schema", {})),
                "tools": update.get("tools", current.get("tools", [])),
                "timeout": update.get("timeout", current.get("timeout") or 60),
                "memory_mb": update.get("memory_mb", current.get("memory_mb", 512)),
                "sub_agents": update.get("sub_agents", (current.get("sub_agents") or [])),
            }

            if "runtime_config" in update:
                rc = update.get("runtime_config") or {}
                payload.update(rc)

            response = self.api.post("/api/v1/agents/deploy/full", json=payload, timeout=600.0)

            return DeployedAgent(
                id=response.get('agent_id'),
                name=response.get('name'),
                slug=response.get('slug'),
                version=response.get('version'),
                deployment_status=response.get('status'),
                deployment_type=response.get('deployment_type'),
                endpoint_url=response.get('endpoint'),
                resource_name=response.get('resource_name'),
                hipaa_compliant=response.get('hipaa_compliant', True),
                hipaa_verified=response.get('hipaa_verified', False),
            )

        except Exception as e:
            logger.error(f"Failed to redeploy via deploy/full: {e}")
            # Fallback to PUT using developer JWT injection if available
            jwt = os.environ.get("HIKIGAI_JWT")
            if jwt:
                try:
                    if hasattr(self.api, '_headers'):
                        self.api._headers['Authorization'] = f"Bearer {jwt}"
                    if hasattr(self.api, '_client') and hasattr(self.api._client, 'headers'):
                        self.api._client.headers['Authorization'] = f"Bearer {jwt}"
                except Exception:
                    logger.debug("Failed to inject HIKIGAI_JWT into API client headers")

            response = self.api.put(f"/api/v1/agents/{agent_id}", json=update)
            return DeployedAgent(
                id=response.get('id') or response.get('agent_id'),
                name=response.get('name'),
                slug=response.get('slug'),
                version=response.get('version', '1.0.0'),
                deployment_status=response.get('deployment_status', response.get('status')),
                deployment_type=response.get('deployment_type', 'config_based'),
                endpoint_url=response.get('endpoint') or response.get('endpoint_url'),
            )
    
    def _prepare_deploy_payload(self, config: AgentConfig) -> Dict[str, Any]:
        """Prepare deployment payload from AgentConfig."""
        # Convert schemas to JSON Schema format
        input_schema_dict = {}
        output_schema_dict = {}
        
        if isinstance(config.input_schema, InputSchema):
            input_schema_dict = config.input_schema.to_json_schema()
        elif isinstance(config.input_schema, dict):
            input_schema_dict = config.input_schema
        
        if isinstance(config.output_schema, OutputSchema):
            output_schema_dict = config.output_schema.to_json_schema()
        elif isinstance(config.output_schema, dict):
            output_schema_dict = config.output_schema
        
        # Normalize tools
        tools_list = normalize_tools(config.tools) if config.tools else []
        
        # Serialize sub-agents recursively
        sub_agents_list = []
        for sub_agent in config.sub_agents:
            sub_agents_list.append(self._serialize_sub_agent(sub_agent))
        
        # Serialize planner config
        planner_dict = None
        if config.planner_config:
            planner_dict = config.planner_config.model_dump(exclude_none=True)
        
        # Serialize generation config
        generation_dict = None
        if config.generation_config:
            generation_dict = config.generation_config.model_dump(exclude_none=True)
        
        # Build payload matching backend API contract
        payload = {
            # Identity
            "name": config.name,
            "display_name": config.display_name,
            "description": config.description,
            "long_description": config.long_description,
            
            # Core config
            "agent_type": config.agent_type,
            "instruction": config.instruction,
            "model": config.model,
            
            # Multi-agent support
            "sub_agents": sub_agents_list,
            
            # Advanced features
            "planner_config": planner_dict,
            "code_execution": config.code_execution,
            "generation_config": generation_dict,
            
            # State management
            "output_key": config.output_key,
            "include_contents": config.include_contents,
            "max_iterations": config.max_iterations,
            
            # Classification
            "category": config.category,
            "tags": config.tags,
            
            # Schemas & Tools
            "input_schema": input_schema_dict,
            "output_schema": output_schema_dict,
            "tools": tools_list,
            
            # MCP Connectors
            "mcp_connectors": [
                {
                    "slug": c.slug,
                    "tool_filter": c.tool_filter,
                    "required": c.required,
                    # URL is resolved from the project's connector registry at deploy time
                }
                for c in config.connectors
            ] if config.connectors else [],
            
            # Modality
            "input_modality": config.input_modality,
            "output_modality": config.output_modality,

            # Runtime
            "timeout": config.timeout,
            "memory_mb": config.memory_mb,
            "min_instances": config.min_instances,
            "max_instances": config.max_instances,
            
            # Versioning
            "version": config.version,
            "changelog": config.changelog,
            
            # Compliance & Visibility
            "hipaa_compliant": config.hipaa_compliant,
            "public": config.public,
            
            # Cloud deployment
            "cloud_provider": config.cloud_provider,
            "gcp_region": config.gcp_region,
            "aws_region": config.aws_region,
            
            # Healthcare Spec Extended (§3)
            "risk_tier": config.risk_tier,
            "clinical_domain": config.clinical_domain,
            "a2a_skills": config.a2a_skills,
            "resource_limits": config.resource_limits,
            "fhir_resources": config.fhir_resources,
            "prompt_version": config.prompt_version,
        }

        return payload
    
    def _serialize_sub_agent(self, sub_agent: "SubAgentConfig") -> Dict[str, Any]:
        """Recursively serialize a sub-agent configuration."""
        # Serialize basic fields
        sub_agent_dict: Dict[str, Any] = {
            "name": sub_agent.name,
            "agent_type": sub_agent.agent_type,
            "model": sub_agent.model,
            "instruction": sub_agent.instruction,
            "description": sub_agent.description,
            "tools": normalize_tools(sub_agent.tools) if getattr(sub_agent, 'tools', None) else [],
            "max_iterations": sub_agent.max_iterations,
            "output_key": getattr(sub_agent, "output_key", None),
            "include_contents": getattr(sub_agent, "include_contents", None),
            "input_modality": sub_agent.input_modality,
            "output_modality": sub_agent.output_modality,
        }

        # Serialize input/output schemas if provided
        if getattr(sub_agent, "input_schema", None):
            if isinstance(sub_agent.input_schema, InputSchema):
                sub_agent_dict["input_schema"] = sub_agent.input_schema.to_json_schema()
            elif isinstance(sub_agent.input_schema, dict):
                sub_agent_dict["input_schema"] = sub_agent.input_schema

        if getattr(sub_agent, "output_schema", None):
            if isinstance(sub_agent.output_schema, OutputSchema):
                sub_agent_dict["output_schema"] = sub_agent.output_schema.to_json_schema()
            elif isinstance(sub_agent.output_schema, dict):
                sub_agent_dict["output_schema"] = sub_agent.output_schema
        
        # Recursively serialize nested sub-agents
        if sub_agent.sub_agents:
            sub_agent_dict["sub_agents"] = [
                self._serialize_sub_agent(sa) for sa in sub_agent.sub_agents
            ]
        else:
            sub_agent_dict["sub_agents"] = []
        
        return sub_agent_dict

    @property
    def storage(self):
        """Access platform-managed object storage for agent outputs."""
        if not hasattr(self, "_storage"):
            from hikigai.appsdk.storage import StorageClient
            self._storage = StorageClient(self.api, self.project_id)
        return self._storage
    
    def close(self):
        """Close HTTP client and cleanup resources."""
        if hasattr(self, "api"):
            self.api.close()
    
    def __enter__(self):
        """Context manager support."""
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        """Cleanup when exiting context."""
        self.close()
    
    def __del__(self):
        """Ensure cleanup on deletion."""
        try:
            self.close()
        except Exception:
            pass


================================================================================
File: hikigai/agentsdk/models/agent.py
================================================================================

"""
Deployed agent model and deployment result.
"""

from datetime import datetime
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, Field


class DeployedAgent(BaseModel):
    """
    Represents a successfully deployed agent.
    
    Contains deployment metadata and status information.
    """
    
    id: str = Field(..., description="Unique agent ID")
    name: str = Field(..., description="Agent name/slug")
    slug: str = Field(..., description="URL-friendly slug")
    version: str = Field(..., description="Semantic version")
    display_name: Optional[str] = Field(None, description="Human-readable name")
    description: Optional[str] = Field(None, description="Short description")
    
    # Deployment info
    deployment_status: str = Field(..., description="'active', 'pending', 'error'")
    deployment_type: str = Field(..., description="'config_based', 'adk', 'file'")
    endpoint_url: Optional[str] = Field(None, description="Agent invocation endpoint")
    cloud_provider: Optional[str] = Field(None, description="'gcp', 'aws', etc.")
    region: Optional[str] = Field(None, description="Cloud region")
    
    # Resources
    resource_name: Optional[str] = Field(None, description="Cloud resource ID")
    
    # Compliance
    hipaa_compliant: bool = Field(True, description="HIPAA compliance flag")
    hipaa_verified: bool = Field(False, description="HIPAA verification status")
    
    # Healthcare Spec
    risk_tier: Optional[str] = Field("low", description="Healthcare risk tier")
    clinical_domain: Optional[str] = Field(None, description="Clinical domain")
    a2a_skills: List[Dict[str, Any]] = Field(default_factory=list, description="A2A skills")
    
    # Timestamps
    created_at: Optional[datetime] = Field(None, description="Creation timestamp")
    deployed_at: Optional[datetime] = Field(None, description="Deployment timestamp")
    
    model_config = {
        "json_encoders": {
            datetime: lambda v: v.isoformat() if v else None
        }
    }



class DeploymentResult(BaseModel):
    """Result of a deployment operation."""
    
    success: bool = Field(..., description="Whether deployment succeeded")
    agent: Optional[DeployedAgent] = Field(None, description="Deployed agent (if successful)")
    message: str = Field(..., description="Human-readable result message")
    error: Optional[str] = Field(None, description="Error details (if failed)")
    deployment_duration_seconds: Optional[float] = Field(None, description="Time taken to deploy")


================================================================================
File: hikigai/agentsdk/models/config.py
================================================================================

"""
Agent configuration model for deployment.
"""

from typing import Any, Dict, List, Optional, Union, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
import re

from hikigai.agentsdk.models.schemas import InputSchema, OutputSchema
from hikigai.agentsdk.models.runtime import RuntimeConfig, HIPAAConfig
from hikigai.core.constants import DEFAULT_MODEL, CATEGORIES, VALID_MODELS


# ============================================================================
# Multi-Agent Support Models
# ============================================================================

class PlannerConfig(BaseModel):
    """
    Configuration for agent planning capabilities.
    
    Planning enables agents to think through problems step-by-step before acting.
    
    Example:
        planner = PlannerConfig(
            type="BuiltInPlanner",
            include_thoughts=True,
            thinking_budget=2048
        )
    """
    
    type: Literal["BuiltInPlanner", "PlanReActPlanner"] = Field(
        "BuiltInPlanner",
        description="Planner type to use"
    )
    
    include_thoughts: bool = Field(
        True,
        description="Include model's reasoning in response"
    )
    
    thinking_budget: int = Field(
        1024,
        ge=128,
        le=8192,
        description="Maximum tokens allocated for planning/reasoning"
    )
    
    @field_validator("thinking_budget")
    @classmethod
    def validate_thinking_budget(cls, v: int) -> int:
        """Ensure thinking budget is within acceptable range."""
        if v < 128:
            raise ValueError("thinking_budget must be at least 128 tokens")
        if v > 8192:
            raise ValueError("thinking_budget cannot exceed 8192 tokens")
        return v


class GenerationConfig(BaseModel):
    """
    LLM generation parameters for fine-tuning agent responses.
    
    Example:
        config = GenerationConfig(
            temperature=0.7,
            max_output_tokens=4096,
            top_p=0.95,
            top_k=40
        )
    """
    
    temperature: Optional[float] = Field(
        None,
        ge=0.0,
        le=2.0,
        description="Randomness in responses (0=deterministic, 2=very creative)"
    )
    
    max_output_tokens: Optional[int] = Field(
        None,
        ge=1,
        le=100000,
        description="Maximum tokens in model response"
    )
    
    top_p: Optional[float] = Field(
        None,
        ge=0.0,
        le=1.0,
        description="Nucleus sampling threshold"
    )
    
    top_k: Optional[int] = Field(
        None,
        ge=1,
        le=100,
        description="Top-K sampling parameter"
    )
    
    @field_validator("temperature")
    @classmethod
    def validate_temperature(cls, v: Optional[float]) -> Optional[float]:
        """Validate temperature is in acceptable range."""
        if v is not None and (v < 0.0 or v > 2.0):
            raise ValueError("temperature must be between 0.0 and 2.0")
        return v


class SubAgentConfig(BaseModel):
    """
    Configuration for a sub-agent in a workflow.
    
    Sub-agents are individual agents that compose larger workflows
    (Sequential, Parallel, Loop).
    
    Example:
        sub_agent = SubAgentConfig(
            name="entity-extractor",
            agent_type="llm",
            model="gemini-2.0-flash",
            instruction="Extract medical entities from clinical notes",
            tools=["google_search"]
        )
    """
    
    name: str = Field(
        ...,
        min_length=1,
        max_length=64,
        description="Sub-agent name (will be sanitized for Python)"
    )
    
    agent_type: Literal["llm", "sequential", "parallel", "loop"] = Field(
        "llm",
        description="Type of sub-agent orchestration"
    )
    
    model: str = Field(
        ...,
        description="Model to use for this sub-agent"
    )
    
    instruction: str = Field(
        ...,
        min_length=10,
        max_length=50000,
        description="System prompt for this sub-agent"
    )
    
    description: Optional[str] = Field(
        None,
        max_length=500,
        description="Brief description of sub-agent's purpose"
    )
    
    tools: List[str] = Field(
        default_factory=list,
        description="Tool names available to this sub-agent"
    )
    # Optional schemas for this sub-agent (leaf or workflow agents can declare their own)
    input_schema: Optional[Union["InputSchema", Dict[str, Any]]] = Field(
        default=None,
        description="Optional input schema for this sub-agent"
    )

    output_schema: Optional[Union["OutputSchema", Dict[str, Any]]] = Field(
        default=None,
        description="Optional output schema for this sub-agent"
    )

    input_modality: str = Field(
        "text",
        description="Input modality: text, audio, or text_and_audio",
    )

    output_modality: str = Field(
        "text",
        description="Output modality: text, audio, or text_and_audio",
    )
    
    # For nested workflow agents
    sub_agents: List["SubAgentConfig"] = Field(
        default_factory=list,
        description="Nested sub-agents (for workflow agents like Sequential/Parallel)"
    )
    
    # For LoopAgent
    max_iterations: Optional[int] = Field(
        None,
        ge=1,
        le=10,
        description="Maximum loop iterations (LoopAgent only)"
    )
    
    @field_validator("name")
    @classmethod
    def validate_name(cls, v: str) -> str:
        """Ensure name can be converted to valid Python identifier."""
        if not v:
            raise ValueError("Sub-agent name cannot be empty")
        
        # Allow letters, numbers, hyphens, underscores
        if not re.match(r"^[a-zA-Z0-9_-]+$", v):
            raise ValueError(
                "Sub-agent name must contain only letters, numbers, hyphens, and underscores"
            )
        
        return v
    
    @field_validator("model")
    @classmethod
    def validate_model(cls, v: str) -> str:
        """Ensure model is valid."""
        if v not in VALID_MODELS:
            raise ValueError(
                f"Invalid model for sub-agent. Must be one of: {', '.join(VALID_MODELS)}"
            )
        return v

    @field_validator("input_modality", "output_modality")
    @classmethod
    def validate_modality(cls, v: str) -> str:
        """Ensure modality is valid."""
        valid = {"text", "audio", "text_and_audio"}
        if v not in valid:
            raise ValueError(
                f"Invalid modality. Must be one of: {', '.join(sorted(valid))}"
            )
        return v
    
    @model_validator(mode='after')
    def validate_workflow_agent(self) -> "SubAgentConfig":
        """Validate workflow agent configurations."""
        # Sequential/Parallel/Loop agents must have sub_agents
        if self.agent_type in ["sequential", "parallel", "loop"]:
            if not self.sub_agents:
                raise ValueError(
                    f"{self.agent_type} agents must have at least one sub-agent"
                )
        
        # LoopAgent with max_iterations
        if self.agent_type == "loop" and self.max_iterations is None:
            raise ValueError("LoopAgent must specify max_iterations")
        
        # Non-loop agents shouldn't have max_iterations
        if self.agent_type != "loop" and self.max_iterations is not None:
            raise ValueError(
                f"max_iterations only applies to LoopAgent, not {self.agent_type}"
            )
        
        return self



class ToolConfig(BaseModel):
    """Configuration for an agent tool."""
    
    name: str = Field(..., description="Tool function name")
    description: str = Field(..., description="What the tool does")
    parameters: Optional[Dict[str, Any]] = Field(None, description="Tool parameter schema")
    builtin: bool = Field(False, description="Is this a built-in tool?")
    builtin_type: Optional[str] = Field(None, description="Built-in tool type (e.g., 'web_search')")


class ConnectorConfig(BaseModel):
    """
    Optional configuration for an MCP connector that this agent uses.
    
    NOTE: As of v2.0, connectors linked to the project are automatically
    available to all agents in that project. You only need to declare
    ConnectorConfig if you want to:
    - Filter which tools from a connector are available (tool_filter)
    - Mark a connector as required (fails invocation if not available)
    
    If no ConnectorConfig is specified, the agent can still use any
    connector linked to the project.
    
    Example:
        connector = ConnectorConfig(
            slug="epic-ehr",
            tool_filter=["get_patient", "get_medications"],
        )
    """
    
    slug: str = Field(
        ...,
        min_length=1,
        max_length=100,
        description="Connector slug from the connector registry (e.g., 'epic-ehr', 'cerner')"
    )
    
    tool_filter: Optional[List[str]] = Field(
        None,
        description="Whitelist of specific tools to use from this connector. None = all tools."
    )
    
    required: bool = Field(
        True,
        description="If True, agent fails if this connector is not available at invocation time"
    )
    
    @field_validator("slug")
    @classmethod
    def validate_slug(cls, v: str) -> str:
        """Ensure slug is valid format."""
        if not re.match(r"^[a-z0-9-]+$", v):
            raise ValueError("Connector slug must be lowercase with hyphens only")
        return v


class AgentConfig(BaseModel):
    """
    Complete configuration for deploying an AI agent.
    
    Example:
        config = AgentConfig(
            name="medical-coder",
            display_name="Medical Coding Assistant",
            description="Extracts ICD-10 and CPT codes from clinical notes",
            instruction="You are a medical coding expert...",
            model="claude-3.5-sonnet",
            category="Medical Coding",
            tags=["icd-10", "cpt", "medical"],
            input_schema=InputSchema(fields={
                "clinical_note": StringField(required=True)
            }),
            output_schema=OutputSchema(fields={
                "icd_codes": ArrayField(),
                "cpt_codes": ArrayField()
            }),
            tools=[],
            timeout=60,
            memory_mb=512,
            version="1.0.0",
        )
    """
    
    # ================== Identity ==================
    
    name: str = Field(
        ...,
        min_length=3,
        max_length=64,
        description="Agent name (slug format: lowercase, hyphens)"
    )
    
    display_name: str = Field(
        ...,
        min_length=3,
        max_length=100,
        description="Human-readable display name"
    )
    
    description: str = Field(
        ...,
        min_length=10,
        max_length=500,
        description="Short description for marketplace"
    )
    
    long_description: Optional[str] = Field(
        None,
        max_length=5000,
        description="Full documentation"
    )
    
    # ================== Core Configuration ==================
    
    agent_type: Literal["llm", "sequential", "parallel", "loop"] = Field(
        "llm",
        description="Agent orchestration type (llm=single, sequential/parallel/loop=workflow)"
    )
    
    instruction: str = Field(
        ...,
        min_length=10,
        max_length=50000,
        description="System prompt for the agent"
    )
    
    model: str = Field(
        default=DEFAULT_MODEL,
        description="AI model to use"
    )
    
    # ================== Multi-Agent Support ==================
    
    sub_agents: List[SubAgentConfig] = Field(
        default_factory=list,
        description="Sub-agents for workflow orchestration (Sequential/Parallel/Loop)"
    )
    
    # ================== Advanced Features ==================
    
    planner_config: Optional[PlannerConfig] = Field(
        None,
        description="Enable planning/reasoning capabilities"
    )
    
    code_execution: bool = Field(
        False,
        description="Enable code execution capability"
    )
    
    generation_config: Optional[GenerationConfig] = Field(
        None,
        description="LLM generation parameters (temperature, max_tokens, etc.)"
    )
    
    # ================== State Management ==================
    
    output_key: Optional[str] = Field(
        None,
        max_length=64,
        description="Key to save agent output in state for downstream agents"
    )
    
    include_contents: Optional[Literal["default", "none"]] = Field(
        None,
        description="Control conversation history inclusion"
    )
    
    max_iterations: Optional[int] = Field(
        None,
        ge=1,
        le=10,
        description="Maximum iterations for LoopAgent"
    )
    
    # ================== Classification ==================
    
    category: str = Field(
        default="documentation",
        description="Agent category (Healthcare AI Agent Ecosystem spec)"
    )
    
    tags: List[str] = Field(
        default_factory=list,
        max_length=10,
        description="Tags for discovery"
    )
    
    # ================== Schemas ==================
    
    input_schema: Union[InputSchema, Dict[str, Any]] = Field(
        ...,
        description="Input schema definition (required)"
    )
    
    output_schema: Union[OutputSchema, Dict[str, Any]] = Field(
        ...,
        description="Output schema definition (required)"
    )
    
    # ================== Tools ==================
    
    tools: List[Any] = Field(
        default_factory=list,
        description="Agent tools (functions, OpenAPI, MCP)"
    )
    
    # ================== MCP Connectors ==================
    
    connectors: List[ConnectorConfig] = Field(
        default_factory=list,
        description=(
            "Optional MCP connector declarations. All connectors linked to the "
            "project are automatically available to agents. Use this field only "
            "to apply tool filters or mark specific connectors as required."
        ),
    )
    
    # ================== Runtime ==================
    
    timeout: int = Field(
        60,
        ge=5,
        le=300,
        description="Timeout in seconds"
    )
    
    memory_mb: int = Field(
        512,
        ge=128,
        le=4096,
        description="Memory allocation in MB"
    )
    
    min_instances: int = Field(
        0,
        ge=0,
        le=10,
        description="Minimum instances"
    )
    
    max_instances: int = Field(
        10,
        ge=1,
        le=100,
        description="Maximum instances"
    )
    
    # ================== Versioning ==================
    
    version: str = Field(
        ...,
        pattern=r"^\d+\.\d+\.\d+$",
        description="Semantic version (e.g., '1.0.0')"
    )
    
    changelog: Optional[str] = Field(
        None,
        description="What changed in this version"
    )
    
    # ================== Compliance ==================
    
    hipaa_compliant: bool = Field(
        True,
        description="Agent handles PHI per HIPAA"
    )
    
    # ================== Healthcare Spec ==================
    
    risk_tier: Optional[Literal["low", "moderate", "high", "critical"]] = Field(
        "low",
        description="Healthcare risk tier"
    )
    
    clinical_domain: Optional[str] = Field(
        None,
        description="Clinical domain category"
    )
    
    a2a_skills: List[Dict[str, Any]] = Field(
        default_factory=list,
        description="Machine-readable capabilities for AI-to-AI discovery"
    )

    # Healthcare Spec Manifest
    resource_limits: Optional[Dict[str, str]] = Field(
        default={"cpu": "1", "memory": "512Mi"},
        description="Container resource limits (cpu, memory)"
    )
    
    fhir_resources: List[Dict[str, Any]] = Field(
        default_factory=list,
        description="Required FHIR resources for agent operation"
    )
    
    prompt_version: Optional[str] = Field(
        "1.0.0",
        description="Specific version of the clinical prompt"
    )
    
    requires_physician_oversight: bool = Field(
        False,
        description="Does this agent require human physician sign-off? "
    )
    
    fda_clearance_status: str = Field(
        "not-applicable",
        description="FDA clearance status (e.g., 'cleared', 'pending', 'not-applicable')"
    )
    
    # ================== Modality ==================

    input_modality: str = Field(
        "text",
        description="Input modality: text, audio, or text_and_audio"
    )

    output_modality: str = Field(
        "text",
        description="Output modality: text, audio, or text_and_audio"
    )

    # ================== Visibility ==================

    public: bool = Field(
        False,
        description="Publicly listed in marketplace"
    )
    
    # ================== Cloud Deployment ==================
    
    cloud_provider: str = Field(
        "gcp",
        description="Cloud provider: 'gcp', 'gcp-agent-engine', 'aws'"
    )
    
    gcp_region: str = Field(
        "us-central1",
        description="GCP region for deployment"
    )
    
    aws_region: str = Field(
        "us-east-1",
        description="AWS region for deployment"
    )
    
    # ================== Validators ==================
    
    @field_validator("name")
    @classmethod
    def validate_name(cls, v: str) -> str:
        """Ensure name is slug-compatible."""
        if not re.match(r"^[a-z0-9-]+$", v):
            raise ValueError("Name must be lowercase with hyphens only")
        return v
    
    @field_validator("model")
    @classmethod
    def validate_model(cls, v: str) -> str:
        """Ensure model is valid."""
        if v not in VALID_MODELS:
            raise ValueError(f"Invalid model. Must be one of: {', '.join(VALID_MODELS)}")
        return v
    
    @field_validator("category")
    @classmethod
    def validate_category(cls, v: str) -> str:
        """Ensure category is valid."""
        if v not in CATEGORIES:
            raise ValueError(f"Invalid category. Must be one of: {', '.join(CATEGORIES)}")
        return v
    
    @field_validator("tags")
    @classmethod
    def validate_tags(cls, v: List[str]) -> List[str]:
        """Limit and validate tags."""
        if len(v) > 10:
            raise ValueError("Maximum 10 tags allowed")
        return v
    
    @model_validator(mode='after')
    def validate_agent_configuration(self) -> "AgentConfig":
        """
        Comprehensive validation for agent configuration.
        Ensures workflow agents have proper sub-agents and configurations are consistent.
        """
        # 1. Workflow agents must have sub-agents
        if self.agent_type in ["sequential", "parallel", "loop"]:
            if not self.sub_agents:
                raise ValueError(
                    f"{self.agent_type.capitalize()}Agent requires at least one sub-agent. "
                    f"Add sub-agents using the sub_agents field."
                )
            
            # Ensure at least 2 sub-agents for loop
            if self.agent_type == "loop" and len(self.sub_agents) < 2:
                raise ValueError(
                    "LoopAgent requires at least 2 sub-agents to create a meaningful loop"
                )
        
        # 2. LLM agents shouldn't have sub-agents (sub-agents ignored for llm type)
        if self.agent_type == "llm" and self.sub_agents:
            # Warning: we'll allow this but it will be ignored
            pass
        
        # 3. LoopAgent must have max_iterations
        if self.agent_type == "loop" and self.max_iterations is None:
            raise ValueError(
                "LoopAgent must specify max_iterations (recommended: 3-5)"
            )
        
        # 4. Non-loop agents shouldn't set max_iterations
        if self.agent_type != "loop" and self.max_iterations is not None:
            raise ValueError(
                f"max_iterations only applies to LoopAgent, not {self.agent_type} agents"
            )
        
        # 5. Validate planner is only used with supported models
        if self.planner_config:
            # Planning requires Gemini 2.0+ or specific models
            planning_models = [
                "gemini-2.0-flash", "gemini-2.5-flash", "gemini-1.5-pro"
            ]
            if self.model not in planning_models:
                raise ValueError(
                    f"Planning requires one of: {','.join(planning_models)}. "
                    f"Current model '{self.model}' does not support planning."
                )
        
        # 6. Code execution requires specific models
        if self.code_execution:
            code_exec_models = [
                "gemini-2.0-flash", "gemini-2.5-flash", "gemini-1.5-pro"
            ]
            if self.model not in code_exec_models:
                raise ValueError(
                    f"Code execution requires one of: {', '.join(code_exec_models)}. "
                    f"Current model '{self.model}' does not support code execution."
                )
        
        # 7. Validate output_key format
        if self.output_key:
            if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", self.output_key):
                raise ValueError(
                    "output_key must be a valid Python identifier (letters, numbers, underscores)"
                )
        
        return self


# Resolve forward references so nested SubAgentConfig definitions are validated
try:
    SubAgentConfig.model_rebuild()
except Exception:
    # model_rebuild may not be available in older pydantic versions; ignore safely
    pass

try:
    AgentConfig.model_rebuild()
except Exception:
    pass


================================================================================
File: hikigai/agentsdk/models/schemas.py
================================================================================

"""
Schema definitions for agent inputs and outputs.

Provides structured type definitions for agent communication.
"""

from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, Field


class FieldDefinition(BaseModel):
    """Base field definition."""
    
    type: str
    description: Optional[str] = None
    required: bool = False
    default: Optional[Any] = None


class StringField(FieldDefinition):
    """String field definition."""
    
    type: str = "string"
    min_length: Optional[int] = None
    max_length: Optional[int] = None
    pattern: Optional[str] = None
    enum: Optional[List[str]] = None


class IntegerField(FieldDefinition):
    """Integer field definition."""
    
    type: str = "integer"
    minimum: Optional[int] = None
    maximum: Optional[int] = None


class BooleanField(FieldDefinition):
    """Boolean field definition."""
    
    type: str = "boolean"


class ArrayField(FieldDefinition):
    """Array field definition."""
    
    type: str = "array"
    items: Optional[Union[FieldDefinition, Dict[str, Any]]] = None
    min_items: Optional[int] = None
    max_items: Optional[int] = None


class ObjectField(FieldDefinition):
    """Object field definition."""
    
    type: str = "object"
    properties: Optional[Dict[str, FieldDefinition]] = None
    required_fields: Optional[List[str]] = None


class InputSchema(BaseModel):
    """
    Input schema for agent.
    
    Defines the structure of data the agent expects to receive.
    """
    
    description: Optional[str] = Field(None, description="Schema description")
    fields: Dict[str, Union[FieldDefinition, Dict[str, Any]]] = Field(
        default_factory=dict,
        description="Field definitions"
    )
    required: List[str] = Field(default_factory=list, description="Required field names")
    
    def to_json_schema(self) -> Dict[str, Any]:
        """Convert to JSON Schema format."""
        properties = {}
        required_fields = []
        
        for name, field_def in self.fields.items():
            if isinstance(field_def, FieldDefinition):
                properties[name] = {
                    "type": field_def.type,
                    "description": field_def.description,
                }
                if field_def.default is not None:
                    properties[name]["default"] = field_def.default
                if field_def.required:
                    required_fields.append(name)
            else:
                properties[name] = field_def
        
        schema = {
            "type": "object",
            "properties": properties,
        }
        
        if self.description:
            schema["description"] = self.description
        
        if required_fields or self.required:
            schema["required"] = list(set(required_fields + self.required))
        
        return schema


class OutputSchema(BaseModel):
    """
    Output schema for agent.
    
    Defines the structure of data the agent will return.
    """
    
    description: Optional[str] = Field(None, description="Schema description")
    fields: Dict[str, Union[FieldDefinition, Dict[str, Any]]] = Field(
        default_factory=dict,
        description="Field definitions"
    )
    
    def to_json_schema(self) -> Dict[str, Any]:
        """Convert to JSON Schema format."""
        properties = {}
        
        for name, field_def in self.fields.items():
            if isinstance(field_def, FieldDefinition):
                properties[name] = {
                    "type": field_def.type,
                    "description": field_def.description,
                }
            else:
                properties[name] = field_def
        
        schema = {
            "type": "object",
            "properties": properties,
        }
        
        if self.description:
            schema["description"] = self.description
        
        return schema


================================================================================
File: hikigai/agentsdk/models/runtime.py
================================================================================

"""
Runtime configuration models.
"""

from typing import Optional
from pydantic import BaseModel, Field


class RuntimeConfig(BaseModel):
    """Runtime configuration for agent deployment."""
    
    timeout: int = Field(60, ge=5, le=300, description="Timeout in seconds")
    memory_mb: int = Field(512, ge=128, le=4096, description="Memory allocation in MB")
    min_instances: int = Field(0, ge=0, le=10, description="Minimum instances")
    max_instances: int = Field(10, ge=1, le=100, description="Maximum instances")


class HIPAAConfig(BaseModel):
    """HIPAA compliance configuration."""
    
    compliant: bool = Field(True, description="Requires HIPAA compliance")
    audit_logging: bool = Field(True, description="Enable audit logging")
    encryption_at_rest: bool = Field(True, description="Encrypt data at rest")
    encryption_in_transit: bool = Field(True, description="Encrypt data in transit")


================================================================================
File: hikigai/agentsdk/tools.py
================================================================================

"""
Tool integration utilities for AgentSDK.

Supports:
- Custom Python functions via @tool decorator
- OpenAPI specifications
- MCP (Model Context Protocol) servers
"""

from typing import Any, Callable, Dict, List, Optional, Union
from functools import wraps
import inspect


import inspect
import textwrap


def _extract_function_body(func: Callable) -> str:
    """Extract function body source for generated agent code (deploy)."""
    lines = textwrap.dedent("".join(inspect.getsourcelines(func)[0])).splitlines()
    i = 1
    while i < len(lines) and not lines[i].strip():
        i += 1
    if i < len(lines):
        stripped = lines[i].strip()
        if stripped.startswith('"""') or stripped.startswith("'''"):
            quote = '"""' if stripped.startswith('"""') else "'''"
            if stripped.count(quote) < 2:
                i += 1
                while i < len(lines) and quote not in lines[i]:
                    i += 1
            i += 1
    return "\n".join(lines[i:]).strip() or 'return {"result": "not implemented"}'


class FunctionTool:
    """Wrapper for a Python function as an agent tool."""
    
    def __init__(
        self,
        func: Callable,
        name: Optional[str] = None,
        description: Optional[str] = None
    ):
        self.func = func
        self.name = name or func.__name__
        self.description = description or (func.__doc__ or "").strip()
        self._schema = self._extract_schema()
    
    def _extract_schema(self) -> Dict[str, Any]:
        """Extract parameter schema from function signature."""
        sig = inspect.signature(self.func)
        parameters = {}
        required = []
        
        for param_name, param in sig.parameters.items():
            if param_name == "self":
                continue
            
            param_schema = {"type": "string"}  # Default type
            
            # Extract type from annotation
            if param.annotation != inspect.Parameter.empty:
                annotation = param.annotation
                if annotation == str:
                    param_schema["type"] = "string"
                elif annotation == int:
                    param_schema["type"] = "integer"
                elif annotation == float:
                    param_schema["type"] = "number"
                elif annotation == bool:
                    param_schema["type"] = "boolean"
            
            parameters[param_name] = param_schema
            
            # Mark as required if no default
            if param.default == inspect.Parameter.empty:
                required.append(param_name)
        
        return {
            "type": "function",
            "name": self.name,
            "description": self.description,
            "parameters": {
                "type": "object",
                "properties": parameters,
                "required": required
            }
        }
    
    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary format for API."""
        return self._schema

    def to_deploy_config(self) -> Dict[str, Any]:
        """Full tool config for deployment code generation (includes implementation)."""
        schema = self._schema
        props = schema.get("parameters", {}).get("properties", {})
        type_map = {
            "string": "str",
            "integer": "int",
            "number": "float",
            "boolean": "bool",
            "array": "list",
            "object": "dict",
        }
        params_list = []
        args = []
        for pname, pinfo in props.items():
            py_type = type_map.get(pinfo.get("type", "string"), "str")
            params_list.append({
                "name": pname,
                "type": py_type,
                "description": pinfo.get("description", ""),
            })
            args.append(f"{pname}: {py_type}")
        return {
            "name": self.name,
            "description": self.description,
            "category": "read-only",
            "parameters": schema.get("parameters", {}),
            "return_type": "dict",
            "return_description": "Tool execution result",
            "params_list": params_list,
            "implementation": _extract_function_body(self.func),
            "builtin": False,
        }
    
    def __call__(self, *args, **kwargs):
        """Make the tool callable."""
        return self.func(*args, **kwargs)


def tool(
    func: Optional[Callable] = None,
    *,
    name: Optional[str] = None,
    description: Optional[str] = None
) -> Union[FunctionTool, Callable[[Callable], FunctionTool]]:
    """
    Decorator to convert a Python function into an agent tool.
    
    Usage:
        @tool
        def search_web(query: str) -> str:
            '''Search the web for information.'''
            # Implementation
            return results
        
        # Or with custom name/description
        @tool(name="web_search", description="Search the internet")
        def my_search(query: str) -> str:
            return results
    """
    def decorator(fn: Callable) -> FunctionTool:
        return FunctionTool(fn, name=name, description=description)
    
    if func is None:
        return decorator
    else:
        return decorator(func)


class OpenAPITool:
    """Tool from an OpenAPI specification."""
    
    def __init__(self, spec_url: str, operation_id: Optional[str] = None):
        self.spec_url = spec_url
        self.operation_id = operation_id
    
    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary format for API."""
        return {
            "type": "openapi",
            "spec_url": self.spec_url,
            "operation_id": self.operation_id
        }


class MCPTool:
    """Tool from an MCP (Model Context Protocol) server."""
    
    def __init__(self, server_name: str, tool_name: str):
        self.server_name = server_name
        self.tool_name = tool_name
    
    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary format for API."""
        return {
            "type": "mcp",
            "server_name": self.server_name,
            "tool_name": self.tool_name
        }


def normalize_tool(tool_spec: Any) -> Dict[str, Any]:
    """
    Normalize a tool specification to backend ToolConfig format.
    
    Supports:
    - FunctionTool objects
    - @tool decorated functions
    - OpenAPITool objects
    - MCPTool objects
    - String references to built-in tools
    - Raw dictionaries
    """
    # Built-in tool descriptions
    BUILTIN_TOOL_DESCRIPTIONS = {
        "google_search": "Search the web for information using Google Search",
        "code_execution": "Execute Python code snippets",
        "python_repl": "Execute Python code in a REPL environment",
    }
    
    if isinstance(tool_spec, FunctionTool):
        return tool_spec.to_deploy_config()
    
    if isinstance(tool_spec, OpenAPITool):
        return tool_spec.to_dict()
    
    if isinstance(tool_spec, MCPTool):
        return tool_spec.to_dict()
    
    if isinstance(tool_spec, str):
        # Built-in tool reference - convert to backend ToolConfig format
        description = BUILTIN_TOOL_DESCRIPTIONS.get(
            tool_spec.lower(),
            f"Built-in {tool_spec} tool"
        )
        return {
            "name": tool_spec,
            "description": description,
            "builtin": True,
            "builtin_type": tool_spec,
            "parameters": {}
        }
    
    if isinstance(tool_spec, dict):
        return tool_spec
    
    if callable(tool_spec):
        # Wrap bare function
        return FunctionTool(tool_spec).to_deploy_config()
    
    raise ValueError(f"Invalid tool specification: {type(tool_spec)}")


def normalize_tools(tools: List[Any]) -> List[Dict[str, Any]]:
    """Normalize a list of tool specifications."""
    return [normalize_tool(t) for t in tools]


================================================================================
File: hikigai/agentsdk/events.py
================================================================================

"""
Platform event bus surface for agent developers.

Accessible via ``agent_client.events``. Lets a deployment pipeline or an
operational tool react to platform facts (``agent.deployed``,
``agent.deleted``, ``job.*``, ``invocation.completed``, ``storage.*``)
instead of polling for them.

Two delivery paths:

**Webhooks** — the platform POSTs each matching event to an HTTPS endpoint
you own, signed with HMAC-SHA256::

    sub = client.events.create_webhook(
        url="https://ci.example.com/hooks/hikigai",
        event_types=["agent.deployed", "agent.deleted"],
    )
    print(sub.secret)   # shown once — store it in your secret manager

Verify before trusting a delivery::

    from hikigai.agentsdk import parse_webhook_event

    event = parse_webhook_event(
        secret=os.environ["HIKIGAI_WEBHOOK_SECRET"],
        signature_header=request.headers["X-Hikigai-Signature"],
        body=request.get_data(as_text=True),   # RAW body, before JSON parsing
    )

**WebSocket** — a live subscription for a process that is already running::

    async with client.events.stream(patterns=["agent.*"]) as stream:
        async for event in stream:
            print(event["type"], event["data"])

Webhooks are at-least-once (retried with backoff — dedupe on
``event["id"]``); the WebSocket is at-most-once (dropped under
backpressure). This module is deliberately self-contained: verification
needs no client, no network, and no other SDK module, so it is safe to
import inside a request handler.
"""

from __future__ import annotations

import hashlib
import hmac
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Dict, List, Optional

logger = logging.getLogger(__name__)

try:
    import websockets
except ImportError:  # pragma: no cover - optional dependency
    websockets = None

#: Header carrying the delivery signature (``t=<unix-ts>,v1=<hex digest>``).
SIGNATURE_HEADER = "X-Hikigai-Signature"

#: Default replay window for signature timestamps, in seconds.
DEFAULT_TOLERANCE_SECONDS = 300


class SignatureVerificationError(Exception):
    """Raised when a webhook delivery fails signature verification.

    Treat this as a hard reject: return 400 and do not process the body.
    """


class EventStreamError(Exception):
    """Server-reported events-stream error (carries the normative code)."""

    def __init__(self, code: str, message: str):
        super().__init__(f"{code}: {message}")
        self.code = code
        self.message = message


@dataclass
class WebhookSubscription:
    """A registration to receive events at an HTTPS endpoint.

    ``secret`` is populated only by :meth:`EventsClient.create_webhook` and
    :meth:`EventsClient.rotate_secret` — the platform stores it encrypted
    and never returns it again.
    """

    id: str
    project_id: str
    url: str
    event_types: List[str] = field(default_factory=list)
    description: Optional[str] = None
    is_active: bool = True
    created_at: Optional[str] = None
    updated_at: Optional[str] = None
    secret: Optional[str] = None

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "WebhookSubscription":
        return cls(
            id=d["id"],
            project_id=d.get("project_id", ""),
            url=d.get("url", ""),
            event_types=list(d.get("event_types") or []),
            description=d.get("description"),
            is_active=bool(d.get("is_active", True)),
            created_at=d.get("created_at"),
            updated_at=d.get("updated_at"),
            secret=d.get("secret"),
        )


@dataclass
class WebhookDelivery:
    """One attempt-tracked delivery of one event to one subscription.

    ``status`` is ``pending`` → ``success`` | ``failed`` (retry scheduled)
    | ``dead`` (retries exhausted; redeliver manually).
    """

    id: str
    subscription_id: str
    event_id: str
    event_type: str
    status: str
    attempts: int = 0
    response_status: Optional[int] = None
    last_error: Optional[str] = None
    next_attempt_at: Optional[str] = None
    created_at: Optional[str] = None
    delivered_at: Optional[str] = None

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "WebhookDelivery":
        return cls(
            id=d["id"],
            subscription_id=d.get("subscription_id", ""),
            event_id=d.get("event_id", ""),
            event_type=d.get("event_type", ""),
            status=d.get("status", "pending"),
            attempts=int(d.get("attempts") or 0),
            response_status=d.get("response_status"),
            last_error=d.get("last_error"),
            next_attempt_at=d.get("next_attempt_at"),
            created_at=d.get("created_at"),
            delivered_at=d.get("delivered_at"),
        )


# ---------------------------------------------------------------------------
# Signature verification
# ---------------------------------------------------------------------------

def verify_webhook_signature(
    secret: str,
    signature_header: str,
    body: str,
    tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
) -> bool:
    """Return True when ``signature_header`` authenticates ``body``.

    Args:
        secret: The subscription's signing secret.
        signature_header: The raw ``X-Hikigai-Signature`` header value.
        body: The RAW request body, byte-for-byte as received. Re-serializing
            parsed JSON will not reproduce the signed bytes.
        tolerance_seconds: Reject signatures whose timestamp is further from
            now than this, which bounds replay of a captured delivery.

    Never short-circuits on a malformed header, and compares digests in
    constant time.
    """
    if not secret or not signature_header:
        return False
    try:
        parts = dict(item.split("=", 1) for item in signature_header.split(","))
        timestamp = int(parts["t"])
        expected = parts["v1"]
    except (ValueError, KeyError):
        return False
    if abs(time.time() - timestamp) > tolerance_seconds:
        return False
    digest = hmac.new(
        secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(digest, expected)


def parse_webhook_event(
    secret: str,
    signature_header: str,
    body: str,
    tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
) -> Dict[str, Any]:
    """Verify a delivery and return the parsed CloudEvents envelope.

    Raises:
        SignatureVerificationError: The signature is missing, stale, or
            does not match — or the verified body is not a JSON object.

    Dedupe on the returned ``id``: webhook delivery is at-least-once, so
    the same event id can arrive more than once.
    """
    if not verify_webhook_signature(secret, signature_header, body, tolerance_seconds):
        raise SignatureVerificationError(
            "Webhook signature verification failed (bad, missing, or stale signature)"
        )
    try:
        envelope = json.loads(body)
    except ValueError as exc:
        raise SignatureVerificationError(f"Webhook body is not valid JSON: {exc}") from exc
    if not isinstance(envelope, dict):
        raise SignatureVerificationError("Webhook body is not a JSON object")
    return envelope


# ---------------------------------------------------------------------------
# Live subscription
# ---------------------------------------------------------------------------

class EventStream:
    """Client for the project events WebSocket (``/api/v1/events/ws``).

    Delivery is at-most-once and there is no replay cursor: on reconnect
    you resume from *now*, so re-read state you care about from the REST
    API afterwards.
    """

    def __init__(
        self,
        base_url: str,
        *,
        api_key: Optional[str] = None,
        access_token: Optional[str] = None,
        project_id: Optional[str] = None,
        patterns: Optional[List[str]] = None,
    ):
        if not any([api_key, access_token]):
            raise ValueError("One of api_key or access_token is required")
        self.base_url = (base_url or "").rstrip("/")
        self.api_key = api_key
        self.access_token = access_token
        self.project_id = project_id
        self.patterns: List[str] = list(patterns) if patterns else ["*"]
        self._ws: Optional[Any] = None
        self._connected = False

    async def __aenter__(self) -> "EventStream":
        await self.connect()
        return self

    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        await self.close()

    @property
    def _ws_url(self) -> str:
        url = self.base_url.replace("http://", "ws://").replace("https://", "wss://")
        return f"{url}/api/v1/events/ws"

    async def connect(self) -> None:
        """Open the socket and complete the auth handshake."""
        if websockets is None:
            raise ImportError(
                "The 'websockets' library is required for event streaming. "
                "Install it with: pip install hikigai-agentsdk[live]"
            )
        if self._connected:
            return
        self._ws = await websockets.connect(self._ws_url, max_size=2 * 1024 * 1024)
        auth: Dict[str, Any] = {"type": "auth", "patterns": self.patterns}
        if self.api_key:
            auth["api_key"] = self.api_key
        else:
            auth["access_token"] = self.access_token
        if self.project_id:
            auth["project_id"] = self.project_id
        await self._ws.send(json.dumps(auth))

        ready = json.loads(await self._ws.recv())
        if ready.get("type") == "error":
            raise EventStreamError(
                ready.get("code", "unauthorized"), ready.get("message", "auth failed")
            )
        # The server normalizes patterns (caps the count, defaults to "*");
        # trust its echo over what we asked for.
        self.patterns = ready.get("patterns", self.patterns)
        self.project_id = ready.get("project_id", self.project_id)
        self._connected = True
        logger.info(
            "[events] connected project=%s patterns=%s", self.project_id, self.patterns
        )

    async def close(self) -> None:
        if self._ws is not None:
            try:
                await self._ws.send(json.dumps({"type": "disconnect"}))
            except Exception:
                pass
            try:
                await self._ws.close()
            except Exception:
                pass
        self._ws = None
        self._connected = False

    async def reconnect(self) -> None:
        """Reopen the socket with the current patterns (no replay)."""
        self._connected = False
        self._ws = None
        await self.connect()

    async def _send(self, frame: Dict[str, Any]) -> None:
        if not self._connected or self._ws is None:
            raise RuntimeError("EventStream is not connected; call connect() first")
        await self._ws.send(json.dumps(frame))

    async def subscribe(self, patterns: List[str]) -> None:
        """Replace the active pattern set (not additive)."""
        self.patterns = list(patterns) if patterns else ["*"]
        await self._send({"type": "subscribe", "patterns": self.patterns})

    async def ping(self) -> None:
        await self._send({"type": "ping"})

    def __aiter__(self) -> AsyncIterator[Dict[str, Any]]:
        return self.events()

    async def events(self) -> AsyncIterator[Dict[str, Any]]:
        """Yield CloudEvents envelopes (control frames are handled inline)."""
        if not self._connected or self._ws is None:
            raise RuntimeError("EventStream is not connected; call connect() first")
        async for raw in self._ws:
            try:
                frame = json.loads(raw)
            except (TypeError, ValueError):
                continue
            if not isinstance(frame, dict):
                continue
            frame_type = frame.get("type")
            if frame_type == "event":
                envelope = frame.get("event")
                if isinstance(envelope, dict):
                    yield envelope
            elif frame_type == "error":
                raise EventStreamError(
                    frame.get("code", "error"), frame.get("message", "stream error")
                )
            elif frame_type == "ready":
                self.patterns = frame.get("patterns", self.patterns)
            # pong and unknown frames are ignored (forward compatibility).

    async def frames(self) -> AsyncIterator[Dict[str, Any]]:
        """Yield raw server frames, including ``ready``/``pong`` control frames."""
        if not self._connected or self._ws is None:
            raise RuntimeError("EventStream is not connected; call connect() first")
        async for raw in self._ws:
            try:
                frame = json.loads(raw)
            except (TypeError, ValueError):
                continue
            if isinstance(frame, dict):
                yield frame


# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------

class EventsClient:
    """Client for tenant webhooks and the project events WebSocket."""

    def __init__(self, client: Any):
        self._client = client
        self._api = client.api

    # -- webhook subscriptions ---------------------------------------------

    def create_webhook(
        self,
        *,
        url: str,
        event_types: List[str],
        description: Optional[str] = None,
    ) -> WebhookSubscription:
        """Register an endpoint. The returned ``secret`` is shown only here.

        ``event_types`` are glob patterns over event types — ``["agent.*"]``,
        ``["job.completed", "invocation.completed"]``, or ``["*"]`` for
        everything visible to your project.
        """
        result = self._api.post(
            "/api/v1/webhooks",
            json={
                "url": url,
                "event_types": list(event_types),
                **({"description": description} if description is not None else {}),
            },
        )
        return WebhookSubscription.from_dict(result)

    def list_webhooks(self) -> List[WebhookSubscription]:
        """All webhook subscriptions for the current project."""
        result = self._api.get("/api/v1/webhooks")
        return [WebhookSubscription.from_dict(w) for w in result.get("webhooks", [])]

    def get_webhook(self, webhook_id: str) -> WebhookSubscription:
        return WebhookSubscription.from_dict(self._api.get(f"/api/v1/webhooks/{webhook_id}"))

    def update_webhook(
        self,
        webhook_id: str,
        *,
        url: Optional[str] = None,
        event_types: Optional[List[str]] = None,
        description: Optional[str] = None,
        is_active: Optional[bool] = None,
    ) -> WebhookSubscription:
        """Patch a subscription. Omitted fields are left unchanged.

        Pause deliveries without losing the subscription (and its secret)
        with ``is_active=False``.
        """
        body: Dict[str, Any] = {}
        if url is not None:
            body["url"] = url
        if event_types is not None:
            body["event_types"] = list(event_types)
        if description is not None:
            body["description"] = description
        if is_active is not None:
            body["is_active"] = is_active
        result = self._api.request("PATCH", f"/api/v1/webhooks/{webhook_id}", json=body)
        return WebhookSubscription.from_dict(result)

    def delete_webhook(self, webhook_id: str) -> Dict[str, Any]:
        return self._api.delete(f"/api/v1/webhooks/{webhook_id}")

    def rotate_secret(self, webhook_id: str) -> str:
        """Issue a new signing secret and return it (shown only here).

        Deliveries signed with the old secret stop verifying immediately,
        so roll the new value into your endpoint promptly.
        """
        result = self._api.post(f"/api/v1/webhooks/{webhook_id}/rotate-secret")
        return result["secret"]

    def test_webhook(self, webhook_id: str) -> Dict[str, Any]:
        """Send a synthetic ``webhook.test`` event to the endpoint now.

        Returns ``{"delivered": bool, "delivery": {...}}`` — the fastest way
        to confirm reachability and signature verification end to end.
        """
        return self._api.post(f"/api/v1/webhooks/{webhook_id}/test")

    # -- delivery status ----------------------------------------------------

    def list_deliveries(
        self,
        webhook_id: str,
        *,
        status: Optional[str] = None,
        limit: int = 50,
    ) -> List[WebhookDelivery]:
        """Recent deliveries, newest first. Filter with ``status="dead"``."""
        params: Dict[str, Any] = {"limit": limit}
        if status is not None:
            params["status"] = status
        result = self._api.get(f"/api/v1/webhooks/{webhook_id}/deliveries", params=params)
        return [WebhookDelivery.from_dict(d) for d in result.get("deliveries", [])]

    def redeliver(self, webhook_id: str, delivery_id: str) -> Dict[str, Any]:
        """Retry a failed or dead delivery immediately."""
        return self._api.post(
            f"/api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver"
        )

    # -- signature verification (also importable standalone) ----------------

    @staticmethod
    def verify_signature(
        secret: str,
        signature_header: str,
        body: str,
        tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
    ) -> bool:
        """See :func:`verify_webhook_signature`."""
        return verify_webhook_signature(secret, signature_header, body, tolerance_seconds)

    @staticmethod
    def parse_event(
        secret: str,
        signature_header: str,
        body: str,
        tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
    ) -> Dict[str, Any]:
        """See :func:`parse_webhook_event`."""
        return parse_webhook_event(secret, signature_header, body, tolerance_seconds)

    # -- live subscription --------------------------------------------------

    def stream(self, patterns: Optional[List[str]] = None) -> EventStream:
        """Open a live event subscription over WebSocket.

        ``patterns`` defaults to ``["*"]``. Requires the ``websockets``
        extra (``pip install hikigai-agentsdk[live]``).
        """
        return EventStream(
            self._api.base_url,
            api_key=getattr(self._client, "api_key", None),
            project_id=getattr(self._client, "project_id", None),
            patterns=patterns,
        )


================================================================================
File: hikigai/agentsdk/telemetry.py
================================================================================

"""
OpenTelemetry instrumentation for the Hikigai AgentSDK.

Provides automatic tracing for agent deployments, invocations, and lifecycle
operations. When the OpenTelemetry SDK packages are installed, spans are
emitted for every client call. When the packages are absent the module
degrades gracefully to no-op stubs so the SDK never fails to import.
"""

import logging
from typing import Optional

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Graceful import: work with or without opentelemetry installed
# ---------------------------------------------------------------------------
try:
    from opentelemetry import trace
    from opentelemetry.sdk.trace import TracerProvider
    from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
    from opentelemetry.sdk.resources import Resource, SERVICE_NAME

    _HAS_OTEL = True
except ImportError:
    _HAS_OTEL = False
    trace = None  # type: ignore[assignment]

# Module-level tracer (initialised lazily via ``init_telemetry``)
_tracer: Optional[object] = None


def init_telemetry(
    service_name: str = "hikigai-agentsdk",
    endpoint: Optional[str] = None,
) -> None:
    """
    Initialise OpenTelemetry tracing for the AgentSDK.

    Args:
        service_name: The logical service name reported in traces.
        endpoint: Optional OTLP collector endpoint.  When *None* a
                  ``ConsoleSpanExporter`` is used (useful during development).
    """
    global _tracer

    if not _HAS_OTEL:
        logger.debug(
            "OpenTelemetry SDK not installed – telemetry disabled. "
            "Install with: pip install opentelemetry-api opentelemetry-sdk"
        )
        return

    resource = Resource.create({SERVICE_NAME: service_name})
    provider = TracerProvider(resource=resource)

    if endpoint:
        try:
            from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
                OTLPSpanExporter,
            )

            provider.add_span_processor(
                BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))
            )
            logger.info("OpenTelemetry OTLP exporter initialised: %s", endpoint)
        except ImportError:
            logger.warning(
                "opentelemetry-exporter-otlp not installed – falling back to console exporter"
            )
            provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
    else:
        provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
        logger.debug("OpenTelemetry console exporter initialised (dev mode)")

    trace.set_tracer_provider(provider)
    _tracer = trace.get_tracer(__name__)
    logger.info("OpenTelemetry tracing initialised for %s", service_name)


def get_tracer():
    """Return the module-level tracer, or *None* if telemetry is not active."""
    return _tracer


# ---------------------------------------------------------------------------
# Structured trace spans per Healthcare Agent Ecosystem spec Section 9.2
# ---------------------------------------------------------------------------

class _NoOpSpan:
    """Minimal stub when OTel is not available."""

    def set_attribute(self, key: str, value) -> None:  # noqa: ANN001
        pass

    def set_status(self, *args, **kwargs) -> None:
        pass

    def add_event(self, name: str, attributes=None) -> None:
        pass

    def end(self) -> None:
        pass

    def __enter__(self):
        return self

    def __exit__(self, *args):
        pass


def _span(name: str, attributes: dict | None = None):
    """Start a trace span, returning a no-op stub when OTel is disabled."""
    tracer = get_tracer()
    if tracer is None:
        return _NoOpSpan()
    span = tracer.start_span(name, attributes=attributes or {})
    return span


class AgentTraceSpans:
    """Healthcare spec Section 9.2 — required trace span helpers.

    Every agent invocation must produce these spans:
        agent.invocation          — root span
        agent.input_validation    — input schema validation
        agent.llm.call            — each LLM call
        agent.tool.call           — each tool invocation
        agent.mcp.request         — each MCP server request
        agent.safety_check        — safety validation of output
        agent.output_validation   — output schema validation
        agent.output              — final output metadata
    """

    @staticmethod
    def invocation(agent_id: str, invocation_id: str, model: str = ""):
        """Root span for the entire agent invocation."""
        return _span("agent.invocation", {
            "agent.id": agent_id,
            "agent.invocation_id": invocation_id,
            "agent.model": model,
        })

    @staticmethod
    def input_validation(agent_id: str):
        """Span wrapping input schema validation."""
        return _span("agent.input_validation", {"agent.id": agent_id})

    @staticmethod
    def llm_call(
        agent_id: str,
        model: str,
        prompt_tokens: int = 0,
        completion_tokens: int = 0,
        temperature: float = 0.0,
    ):
        """Span for a single LLM call."""
        return _span("agent.llm.call", {
            "agent.id": agent_id,
            "llm.model": model,
            "llm.prompt_tokens": prompt_tokens,
            "llm.completion_tokens": completion_tokens,
            "llm.total_tokens": prompt_tokens + completion_tokens,
            "llm.temperature": temperature,
        })

    @staticmethod
    def tool_call(
        agent_id: str,
        tool_name: str,
        tool_category: str = "read-only",
        data_source: str = "",
    ):
        """Span for a single tool invocation."""
        return _span("agent.tool.call", {
            "agent.id": agent_id,
            "tool.name": tool_name,
            "tool.category": tool_category,
            "tool.data_source": data_source,
        })

    @staticmethod
    def mcp_request(agent_id: str, mcp_server: str, mcp_tool: str):
        """Span for an MCP server request."""
        return _span("agent.mcp.request", {
            "agent.id": agent_id,
            "mcp.server": mcp_server,
            "mcp.tool": mcp_tool,
        })

    @staticmethod
    def safety_check(agent_id: str):
        """Span for output safety validation."""
        return _span("agent.safety_check", {"agent.id": agent_id})

    @staticmethod
    def output_validation(agent_id: str):
        """Span for output schema validation."""
        return _span("agent.output_validation", {"agent.id": agent_id})

    @staticmethod
    def output(
        agent_id: str,
        status: str = "",
        confidence: float = 0.0,
        total_tokens: int = 0,
        execution_time_ms: float = 0.0,
    ):
        """Span recording final output metadata."""
        return _span("agent.output", {
            "agent.id": agent_id,
            "output.status": status,
            "output.confidence": confidence,
            "output.token_total": total_tokens,
            "output.execution_time_ms": execution_time_ms,
        })
