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

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

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

__version__ = "0.2.10"

from hikigai.agentsdk.client import AgentClient
from hikigai.agentsdk.rooms import RoomError, RoomSession
from hikigai.agentsdk.identity_verification import (
    EndUserVerificationStatus,
    IdentityVerificationClient,
    PatientVerificationStatus,
    VerificationSession,
)
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,
    VadConfig,
)
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,
    PlanLimitError,
)

__all__ = [
    "RoomSession",
    "RoomError",
    "__version__",
    # Client
    "AgentClient",
    # Patient identity verification (CLEAR / ID.me)
    "IdentityVerificationClient",
    "VerificationSession",
    "PatientVerificationStatus",
    "EndUserVerificationStatus",
    # 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",
    "VadConfig",
    # Schemas
    "InputSchema",
    "OutputSchema",
    "StringField",
    "IntegerField",
    "BooleanField",
    "ArrayField",
    "ObjectField",
    # Models
    "DeployedAgent",
    "DeploymentResult",
    # Tools
    "tool",
    "FunctionTool",
    "OpenAPITool",
    "MCPTool",
    # Exceptions
    "HikigaiError",
    "AuthenticationError",
    "RateLimitError",
    "ValidationError",
    "DeploymentError",
    "ConfigurationError",
    "PlanLimitError",
]


================================================================================
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 identity_verification(self):
        """Patient identity verification (identity proofing) via CLEAR / ID.me.

        Agents usually reach this through the platform MCP server
        (``mcp://platform/identity-verification``); the SDK client is for the
        service code around an agent (start a verification, check status).
        """
        if not hasattr(self, "_identity_verification_client"):
            from hikigai.agentsdk.identity_verification import IdentityVerificationClient
            self._identity_verification_client = IdentityVerificationClient(self)
        return self._identity_verification_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,
        cloud_service: Optional[str] = None,
        region: Optional[str] = None,
        service_config: Optional[Dict[str, Any]] = None,
        cloud_credential_id: Optional[str] = None,
        cloud_credential_name: Optional[str] = None,
    ) -> 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)
            cloud_service: Optional catalog service id (e.g. ``gcp-cloud-run``,
                ``aws-lambda``, ``aws-ecs-fargate``). On AWS, pass
                ``aws-lambda`` or ``aws-ecs-fargate`` to choose the runtime;
                if omitted with ``cloud_provider="aws"``, defaults to Lambda
                (when enabled). Omit together with ``cloud_provider`` to use
                the org/project **deploy-default** credential (and that
                credential's provider).
            region: Cloud region. Defaults to the provider's default.
            service_config: Service-specific settings, validated against
                that service's JSON Schema. Dry-run a selection first
                with ``AppClient.cloud.validate_target`` to get a precise
                error instead of a mid-deployment failure.
            cloud_credential_id: Deploy into a specific BYOC credential by id.
            cloud_credential_name: Same, but by the name shown in Cloud
                accounts (e.g. ``"aws-cloud-mcp"``). Prefer this over id when
                overriding the deploy default. Omit both name and id to use
                the single org/project default cloud account.

            Provider vs runtime (AWS)::

                # AWS Lambda (explicit):
                client.deploy(config, cloud_service="aws-lambda")

                # AWS ECS Fargate (explicit):
                client.deploy(config, cloud_service="aws-ecs-fargate")

                # AWS via provider only → Lambda default when enabled:
                AgentConfig(..., cloud_provider="aws")

                # Or override default with a named AWS account:
                client.deploy(config, cloud_credential_name="my-aws")

            
        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)
        # Deployment target — omitted keys let the backend use the deploy-default
        # credential (team orgs) or platform account (personal).
        if cloud_service is not None:
            payload["cloud_service"] = cloud_service
        if region is not None:
            payload["region"] = region
        if service_config is not None:
            payload["service_config"] = service_config
        if cloud_credential_id is not None:
            payload["cloud_credential_id"] = cloud_credential_id
        if cloud_credential_name is not None:
            payload["cloud_credential_name"] = cloud_credential_name
        
        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,
        attachments: Optional[List[Dict[str, Any]]] = 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
            attachments: Optional multimodal files (image, PDF, or audio such as
                mp3/wav) when the agent's ``input_modality`` includes that kind.
                Each item is one of ``{data_base64, mime_type}``, ``{url}``, or
                ``{storage_object_id}``. Spoken conversation stays on ``live()``.
            
        Returns:
            Dict[str, Any]: Raw invocation response
        """
        # Prepare payload
        payload: Dict[str, Any] = {"input": input}
        if session_id:
            payload["session_id"] = session_id
        if provider:
            payload["provider"] = provider
        if model:
            payload["model"] = model
        if attachments:
            payload["attachments"] = attachments

        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:
                self.api.raise_for_error_response(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,
            "vad": config.vad.model_dump(exclude_none=True) if config.vad else None,

            # 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 — omit cloud_provider so the backend can use
            # the org/project deploy-default credential (and its 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,
        }
        if config.cloud_provider is not None:
            payload["cloud_provider"] = config.cloud_provider
        if config.cloud_credential_name is not None:
            payload["cloud_credential_name"] = config.cloud_credential_name

        return payload
    
    def _serialize_sub_agent(self, sub_agent: "SubAgentConfig") -> Dict[str, Any]:
        """Recursively serialize a sub-agent configuration."""
        source_agent_id = getattr(sub_agent, "source_agent_id", None)
        source = (getattr(sub_agent, "source", None) or "").strip().lower()
        if source_agent_id or source in {"marketplace", "deployed"}:
            # Reference-only: platform hydrates instruction/schemas from the source agent
            ref: Dict[str, Any] = {
                "name": sub_agent.name,
                "reused": True,
            }
            if source_agent_id:
                ref["source_agent_id"] = source_agent_id
            if source:
                ref["source"] = source
            if getattr(sub_agent, "description", None):
                ref["description"] = sub_agent.description
            if getattr(sub_agent, "display_name", None):
                ref["display_name"] = sub_agent.display_name
            if getattr(sub_agent, "category", None):
                ref["category"] = sub_agent.category
            if getattr(sub_agent, "tags", None):
                ref["tags"] = sub_agent.tags
            return ref

        # Serialize basic fields (own / inline agent)
        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,
            "live_handoff": getattr(sub_agent, "live_handoff", None) or "task_completed",
        }

        # 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/jobs.py
================================================================================

"""JobsClient — enqueue and manage background jobs."""

from __future__ import annotations

import json
import logging
import time
from typing import Any, Dict, Iterator, List, Optional, Union

from hikigai.core.exceptions import HikigaiError

from .models.jobs import EnqueuedJob, JobDetail, JobStatus

logger = logging.getLogger(__name__)


class JobsClient:
    """Client for the platform background job queue."""

    def __init__(self, api_client):
        self.api = api_client

    def enqueue(
        self,
        job_type: str,
        payload: Dict[str, Any],
        *,
        agent_id: Optional[str] = None,
        priority: str = "standard",
        schedule: Optional[Dict[str, Any]] = None,
        retry_policy: Optional[Dict[str, Any]] = None,
        dead_letter: Optional[Dict[str, Any]] = None,
        idempotency_key: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> EnqueuedJob:
        body: Dict[str, Any] = {
            "job_type": job_type,
            "payload": payload,
            "priority": priority,
        }
        if agent_id:
            body["agent_id"] = agent_id
        if schedule:
            body["schedule"] = schedule
        if retry_policy:
            body["retry_policy"] = retry_policy
        if dead_letter:
            body["dead_letter"] = dead_letter
        if idempotency_key:
            body["idempotency_key"] = idempotency_key
        if metadata:
            body["metadata"] = metadata

        response = self.api.post("/api/v1/jobs/enqueue", json=body)
        return EnqueuedJob(
            id=response["job_id"],
            job_type=response["job_type"],
            status=JobStatus(response["status"]),
            priority=response["priority"],
            scheduled_at=response.get("scheduled_at"),
            status_url=response["status_url"],
            stream_url=response["stream_url"],
            created_at=response["created_at"],
            max_attempts=response.get("max_attempts", 5),
        )

    def get(self, job_id: str) -> JobDetail:
        response = self.api.get(f"/api/v1/jobs/{job_id}")
        return self._to_job_detail(response)

    def list(
        self,
        *,
        status: Optional[str] = None,
        job_type: Optional[str] = None,
        agent_id: Optional[str] = None,
        page: int = 1,
        per_page: int = 20,
    ) -> List[JobDetail]:
        params: Dict[str, Any] = {"page": page, "per_page": per_page}
        if status:
            params["status"] = status
        if job_type:
            params["job_type"] = job_type
        if agent_id:
            params["agent_id"] = agent_id
        response = self.api.get("/api/v1/jobs", params=params)
        return [self._to_job_detail(j) for j in response.get("jobs", [])]

    def cancel(self, job_id: str) -> JobDetail:
        response = self.api.post(f"/api/v1/jobs/{job_id}/cancel", json={})
        return self._to_job_detail(response)

    def replay(
        self,
        job_id: str,
        *,
        override_retry_policy: Optional[Dict[str, Any]] = None,
        override_payload: Optional[Dict[str, Any]] = None,
    ) -> EnqueuedJob:
        body: Dict[str, Any] = {}
        if override_retry_policy:
            body["override_retry_policy"] = override_retry_policy
        if override_payload:
            body["override_payload"] = override_payload
        response = self.api.post(f"/api/v1/jobs/{job_id}/replay", json=body)
        return EnqueuedJob(
            id=response["job_id"],
            job_type=response["job_type"],
            status=JobStatus(response["status"]),
            priority=response["priority"],
            scheduled_at=response.get("scheduled_at"),
            status_url=response["status_url"],
            stream_url=response["stream_url"],
            created_at=response["created_at"],
            max_attempts=response.get("max_attempts", 5),
        )

    def wait(
        self,
        job_id: str,
        *,
        timeout: float = 600.0,
        poll_interval: float = 5.0,
        terminal_statuses: Optional[set] = None,
    ) -> JobDetail:
        terminal = terminal_statuses or {
            JobStatus.COMPLETED.value,
            JobStatus.FAILED.value,
            JobStatus.DEAD_LETTERED.value,
            JobStatus.CANCELLED.value,
        }
        start = time.time()
        while time.time() - start < timeout:
            detail = self.get(job_id)
            if detail.status.value in terminal:
                return detail
            time.sleep(poll_interval)
        raise HikigaiError(f"Timed out waiting for job {job_id}")

    def stream(self, job_id: str) -> Iterator[Dict[str, Any]]:
        """Stream SSE job events (best-effort; falls back to polling if SSE unavailable)."""
        try:
            with self.api.stream("GET", f"/api/v1/jobs/{job_id}/stream") as response:
                event_type = "message"
                for line in response.iter_lines():
                    if not line:
                        continue
                    text = line.decode() if isinstance(line, bytes) else line
                    if text.startswith("event:"):
                        event_type = text.split(":", 1)[1].strip()
                    elif text.startswith("data:"):
                        data = json.loads(text.split(":", 1)[1].strip())
                        data["event_type"] = event_type
                        yield data
        except Exception as exc:
            logger.warning("SSE stream unavailable (%s); falling back to polling", exc)
            last_status = None
            while True:
                detail = self.get(job_id)
                if detail.status.value != last_status:
                    yield {
                        "event_type": "status",
                        "job_id": job_id,
                        "status": detail.status.value,
                        "attempt": detail.current_attempt,
                    }
                    last_status = detail.status.value
                if detail.status.value in {
                    JobStatus.COMPLETED.value,
                    JobStatus.FAILED.value,
                    JobStatus.DEAD_LETTERED.value,
                    JobStatus.CANCELLED.value,
                }:
                    yield {
                        "event_type": detail.status.value,
                        "job_id": job_id,
                        "status": detail.status.value,
                        "result": detail.result,
                        "error": detail.last_error,
                    }
                    break
                time.sleep(2)

    def _to_job_detail(self, response: Dict[str, Any]) -> JobDetail:
        return JobDetail(
            id=response["job_id"],
            job_type=response["job_type"],
            status=JobStatus(response["status"]),
            priority=response["priority"],
            agent_id=response.get("agent_id"),
            current_attempt=response.get("current_attempt", 0),
            max_attempts=response.get("max_attempts", 5),
            last_error=response.get("last_error"),
            last_error_code=response.get("last_error_code"),
            retry_policy=response.get("retry_policy", {}),
            schedule_config=response.get("schedule_config"),
            scheduled_at=response.get("scheduled_at"),
            started_at=response.get("started_at"),
            completed_at=response.get("completed_at"),
            result=response.get("result"),
            attempts=response.get("attempts", []),
        )


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

"""RoomSession — WebSocket rooms & broadcast client (mirrors StreamSession ergonomics).

Usage:
    room = RoomSession(base_url, api_key=..., project_id=...)
    async with room:
        await room.join("game-42")
        await room.publish("game-42", "move", {"x": 1})
        async for frame in room:
            if frame["type"] == "message":
                ...

Reconnect: the session tracks the last seen ``seq`` per room; call
``reconnect()`` to reopen the socket and rejoin every room with
``last_seq`` so at-least-once rooms replay the gap. A ``cursor_expired``
error means the gap outgrew the server's replay buffer — resync app
state from scratch.

WebRTC: ``rtc_join`` announces this connection as a peer in a room it has
already joined, and the ``rtc_*`` helpers carry SDP and ICE between
peers. This client is signalling only — it holds no media stack, so plug
the frames into whatever peer connection you use (``aiortc`` on Python,
``RTCPeerConnection`` in a browser)::

    await room.join("call-1")
    ack = await room.rtc_join("call-1")     # -> ice_servers, peers, polite
    async for frame in room:
        if frame["type"] == "rtc_peer_joined" and frame["initiator"]:
            await room.rtc_offer("call-1", frame["id"], await make_offer())
        elif frame["type"] == "rtc_offer":
            await room.rtc_answer("call-1", frame["sender"], await answer(frame["sdp"]))
"""

from __future__ import annotations

import json
import logging
import uuid
from typing import Any, AsyncIterator, Dict, List, Optional

_logger = logging.getLogger(__name__)

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


class RoomError(Exception):
    """Server-reported room error (carries the normative code)."""

    def __init__(self, code: str, message: str, room: Optional[str] = None):
        super().__init__(f"{code}: {message}")
        self.code = code
        self.room = room


class RoomSession:
    """Client for the rooms WebSocket endpoint (``/api/v1/rooms/ws``)."""

    def __init__(
        self,
        base_url: str,
        *,
        api_key: Optional[str] = None,
        access_token: Optional[str] = None,
        room_token: Optional[str] = None,
        project_id: Optional[str] = None,
    ):
        if not any([api_key, access_token, room_token]):
            raise ValueError("One of api_key, access_token, or room_token is required")
        self.base_url = base_url.rstrip("/")
        self.api_key = api_key
        self.access_token = access_token
        self.room_token = room_token
        self.project_id = project_id
        self.connection_id: Optional[str] = None
        self.capabilities: List[str] = []
        self.last_seq: Dict[str, int] = {}  # room -> last delivered seq
        self._joined: set = set()
        self._rtc_rooms: set = set()
        self._ws: Optional[Any] = None
        self._connected = False

    # ------------------------------------------------------------------
    async def __aenter__(self) -> "RoomSession":
        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/rooms/ws"

    async def connect(self) -> None:
        if websockets is None:
            raise ImportError(
                "The 'websockets' library is required for rooms. "
                "Install it with: pip install websockets"
            )
        if self._connected:
            return
        self._ws = await websockets.connect(self._ws_url, max_size=2 * 1024 * 1024)
        auth: Dict[str, Any] = {"type": "auth"}
        if self.room_token:
            auth["room_token"] = self.room_token
        elif 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 RoomError(ready.get("code", "unauthorized"), ready.get("message", "auth failed"))
        self.connection_id = ready.get("connection_id")
        self.capabilities = ready.get("capabilities", [])
        self._connected = True
        _logger.info("[rooms] connected connection_id=%s", self.connection_id)

    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 and rejoin all rooms with their last seen seq.

        RTC peers are re-announced too, but the peer id changes with the
        connection: treat every peer in the new ``rtc_joined`` ack as a
        fresh peer and tear down the old peer connections.
        """
        rooms = list(self._joined)
        rtc_rooms = list(self._rtc_rooms)
        self._connected = False
        self._ws = None
        self._rtc_rooms = set()
        await self.connect()
        for room in rooms:
            await self.join(room, last_seq=self.last_seq.get(room))
        for room in rtc_rooms:
            await self.rtc_join(room)

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

    async def join(self, room: str, last_seq: Optional[int] = None) -> Dict[str, Any]:
        frame: Dict[str, Any] = {"type": "join", "room": room}
        if last_seq is not None:
            frame["last_seq"] = last_seq
        await self._send(frame)
        self._joined.add(room)
        return frame

    async def leave(self, room: str) -> None:
        await self._send({"type": "leave", "room": room})
        self._joined.discard(room)
        # Room membership is a precondition for being an RTC peer, so the
        # server drops the peer too — mirror that locally.
        self._rtc_rooms.discard(room)

    async def publish(
        self, room: str, event: str, data: Any = None, *, echo: bool = False
    ) -> None:
        await self._send(
            {"type": "publish", "room": room, "event": event, "data": data, "echo": echo}
        )

    async def who(self, room: str) -> None:
        await self._send({"type": "who", "room": room})

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

    async def refresh(self, room_token: str) -> None:
        self.room_token = room_token
        await self._send({"type": "refresh", "room_token": room_token})


    # ------------------------------------------------------------------
    # WebRTC signalling
    #
    # The server relays SDP/ICE between peers and never touches media, so
    # these helpers are deliberately transport-shaped: they send a frame
    # and return, and the responses arrive on the frame iterator.
    # ------------------------------------------------------------------
    async def rtc_join(
        self,
        room: str,
        *,
        mode: Optional[str] = None,
        agent_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Announce this connection as an RTC peer in an already-joined room.

        Returns the frame that was sent; the server's ``rtc_joined`` ack
        (with ``ice_servers``, existing ``peers`` and this peer's id)
        arrives on the iterator. Requires the ``rtc`` capability.
        """
        frame: Dict[str, Any] = {"type": "rtc_join", "room": room}
        if mode is not None:
            frame["mode"] = mode
        if agent_id is not None:
            frame["agent_id"] = agent_id
        if metadata is not None:
            frame["metadata"] = metadata
        await self._send(frame)
        self._rtc_rooms.add(room)
        return frame

    async def rtc_leave(self, room: str) -> None:
        """Leave the RTC session but stay a member of the room."""
        await self._send({"type": "rtc_leave", "room": room})
        self._rtc_rooms.discard(room)

    async def rtc_offer(self, room: str, target: str, sdp: str) -> None:
        """Send an SDP offer to one peer."""
        await self._send(
            {"type": "rtc_offer", "room": room, "target": target, "sdp": sdp}
        )

    async def rtc_answer(self, room: str, target: str, sdp: str) -> None:
        """Answer one peer's offer."""
        await self._send(
            {"type": "rtc_answer", "room": room, "target": target, "sdp": sdp}
        )

    async def rtc_candidate(
        self, room: str, target: str, candidate: Dict[str, Any]
    ) -> None:
        """Trickle one ICE candidate to a peer.

        Pass the ``RTCIceCandidateInit`` shape (``candidate``, ``sdpMid``,
        ``sdpMLineIndex``). An empty ``candidate`` string is the standard
        end-of-candidates signal and is forwarded as-is.
        """
        await self._send(
            {
                "type": "rtc_candidate",
                "room": room,
                "target": target,
                "candidate": candidate,
            }
        )

    async def rtc_who(self, room: str) -> None:
        """Request the current peer list; the reply arrives as ``rtc_who``."""
        await self._send({"type": "rtc_who", "room": room})

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

    async def _iter_frames(self) -> AsyncIterator[Dict[str, Any]]:
        """Yield server frames; tracks per-room last_seq and dedupes replays."""
        if not self._connected or self._ws is None:
            raise RuntimeError("RoomSession is not connected; call connect() first")
        async for raw in self._ws:
            try:
                frame = json.loads(raw)
            except (TypeError, ValueError):
                continue
            if frame.get("type") == "message":
                room = frame.get("room")
                seq = frame.get("seq")
                if isinstance(seq, int) and room:
                    # Client contract (PRD §7): dedupe by seq.
                    if seq <= self.last_seq.get(room, 0):
                        continue
                    self.last_seq[room] = seq
            yield frame


def make_client_id() -> str:
    return f"room-client-{uuid.uuid4().hex[:8]}"


================================================================================
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/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,
        })


================================================================================
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/identity_verification.py
================================================================================

"""
IdentityVerificationClient — patient identity proofing via CLEAR / ID.me.

Accessible via ``client.identity_verification``. Lets an agent (or the
service behind it) prove
WHO a patient is through a certified identity-proofing vendor without ever
talking to the vendor itself: the platform brokers the OpenID Connect flow,
keeps the verification record, and returns a vendor-neutral result.

Typical flow::

    iv = client.identity_verification

    # 1. Start: get a URL to send the patient to (their own browser).
    session = iv.start("idme", patient_reference="fhir-patient-42",
                       return_url="https://app.example.com/verify/done")
    redirect(session.verification_url)

    # 2. Later (webhook `identity.verification_completed`, or poll):
    session = iv.get(session.session_id)
    if session.status == "verified":
        print(session.assurance_level)      # e.g. "ial2/aal2"

    # 3. Confirm the verified identity matches the chart WITHOUT receiving
    #    the demographics.
    ok = iv.match(session.session_id, first_name="Jane", last_name="Doe",
                  date_of_birth="1990-01-02")["match"]

    # 4. Link it to the patient's app account once known, and re-check later.
    iv.link(session.session_id, end_user_id)
    iv.end_user_status(end_user_id).verified
    iv.patient_status("fhir-patient-42").verified

Provider configuration (usually done once from the console) is available via
``configure_provider`` / ``list_providers`` for automation.

Errors surface as the usual core exceptions: 400 -> ``ValidationError``,
401 -> ``AuthenticationError`` (vendor rejected the code exchange),
404 -> ``HikigaiError`` (provider not configured / session not found),
409 -> ``ConflictError`` (session no longer pending), 503 -> vendor down.
"""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

_BASE = "/api/v1/identity-verification"
TERMINAL_STATUSES = ("verified", "failed", "expired", "cancelled")


@dataclass
class VerificationSession:
    """A patient identity verification session (vendor-neutral)."""

    session_id: str
    project_id: str
    provider: str
    status: str
    environment: str = "sandbox"
    app_id: Optional[str] = None
    end_user_id: Optional[str] = None
    patient_reference: Optional[str] = None
    requested_assurance: Optional[str] = None
    assurance_level: Optional[str] = None
    provider_subject: Optional[str] = None
    id_token_verified: Optional[bool] = None
    failure_reason: Optional[str] = None
    has_attributes: bool = False
    can_match_demographics: bool = False
    initiated_via: str = "api"
    return_url: Optional[str] = None
    metadata: Optional[Dict[str, Any]] = None
    linked_at: Optional[str] = None
    end_user: Optional[Dict[str, Any]] = None
    created_at: Optional[str] = None
    expires_at: Optional[str] = None
    completed_at: Optional[str] = None
    verification_url: Optional[str] = None
    attributes: Optional[Dict[str, Any]] = None
    raw: Dict[str, Any] = field(default_factory=dict, repr=False)

    @property
    def is_terminal(self) -> bool:
        return self.status in TERMINAL_STATUSES

    @property
    def verified(self) -> bool:
        return self.status == "verified"

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "VerificationSession":
        return cls(
            session_id=d["session_id"],
            project_id=d.get("project_id", ""),
            provider=d.get("provider", ""),
            status=d.get("status", "pending"),
            environment=d.get("environment", "sandbox"),
            app_id=d.get("app_id"),
            end_user_id=d.get("end_user_id"),
            patient_reference=d.get("patient_reference"),
            requested_assurance=d.get("requested_assurance"),
            assurance_level=d.get("assurance_level"),
            provider_subject=d.get("provider_subject"),
            id_token_verified=d.get("id_token_verified"),
            failure_reason=d.get("failure_reason"),
            has_attributes=bool(d.get("has_attributes", False)),
            can_match_demographics=bool(d.get("can_match_demographics", False)),
            initiated_via=d.get("initiated_via", "api"),
            return_url=d.get("return_url"),
            metadata=d.get("metadata"),
            linked_at=d.get("linked_at"),
            end_user=d.get("end_user"),
            created_at=d.get("created_at"),
            expires_at=d.get("expires_at"),
            completed_at=d.get("completed_at"),
            verification_url=d.get("verification_url"),
            attributes=d.get("attributes"),
            raw=d,
        )


@dataclass
class EndUserVerificationStatus:
    end_user_id: str
    verified: bool
    session: Optional["VerificationSession"] = None

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "EndUserVerificationStatus":
        session = d.get("session")
        return cls(
            end_user_id=d.get("end_user_id", ""),
            verified=bool(d.get("verified", False)),
            session=VerificationSession.from_dict(session) if session else None,
        )


@dataclass
class PatientVerificationStatus:
    patient_reference: str
    verified: bool
    session: Optional[VerificationSession] = None

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "PatientVerificationStatus":
        session = d.get("session")
        return cls(
            patient_reference=d.get("patient_reference", ""),
            verified=bool(d.get("verified", False)),
            session=VerificationSession.from_dict(session) if session else None,
        )


class IdentityVerificationClient:
    """Client for patient identity verification (CLEAR / ID.me)."""

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

    # -- providers -----------------------------------------------------------

    def list_providers(self) -> Dict[str, Any]:
        """Configured providers (secrets masked) plus the supported-vendor catalog."""
        return self._api.get(f"{_BASE}/providers")

    def configure_provider(
        self,
        provider: str,
        *,
        environment: Optional[str] = None,
        display_name: Optional[str] = None,
        client_id: Optional[str] = None,
        client_secret: Optional[str] = None,
        api_key: Optional[str] = None,
        scope: Optional[str] = None,
        allowed_return_urls: Optional[List[str]] = None,
        endpoint_overrides: Optional[Dict[str, str]] = None,
        attribute_policy: Optional[Dict[str, Any]] = None,
        enabled: Optional[bool] = None,
    ) -> Dict[str, Any]:
        """Create or update the vendor configuration for this project.

        ``client_secret`` / ``api_key`` are write-only: reads return a masked hint.
        """
        body: Dict[str, Any] = {}
        for key, value in (
            ("environment", environment), ("display_name", display_name),
            ("client_id", client_id), ("client_secret", client_secret), ("api_key", api_key),
            ("scope", scope), ("allowed_return_urls", allowed_return_urls),
            ("endpoint_overrides", endpoint_overrides), ("attribute_policy", attribute_policy),
            ("enabled", enabled),
        ):
            if value is not None:
                body[key] = value
        return self._api.put(f"{_BASE}/providers/{provider}", json=body)

    def delete_provider(self, provider: str) -> Dict[str, Any]:
        return self._api.delete(f"{_BASE}/providers/{provider}")

    def test_provider(self, provider: str) -> Dict[str, Any]:
        """Check the vendor's discovery document is reachable with the stored config."""
        return self._api.post(f"{_BASE}/providers/{provider}/test", json={})

    # -- sessions --------------------------------------------------------------

    def start(
        self,
        provider: str,
        *,
        patient_reference: Optional[str] = None,
        app_id: Optional[str] = None,
        end_user_id: Optional[str] = None,
        return_url: Optional[str] = None,
        redirect_uri: Optional[str] = None,
        requested_assurance: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> VerificationSession:
        """Start a verification. Send the patient to ``session.verification_url``."""
        body: Dict[str, Any] = {"provider": provider}
        for key, value in (
            ("patient_reference", patient_reference), ("app_id", app_id),
            ("end_user_id", end_user_id), ("return_url", return_url),
            ("redirect_uri", redirect_uri), ("requested_assurance", requested_assurance),
            ("metadata", metadata),
        ):
            if value is not None:
                body[key] = value
        return VerificationSession.from_dict(self._api.post(f"{_BASE}/sessions", json=body))

    def get(self, session_id: str, *, include_attributes: bool = False) -> VerificationSession:
        params = {"include_attributes": "true"} if include_attributes else None
        return VerificationSession.from_dict(self._api.get(f"{_BASE}/sessions/{session_id}", params=params))

    def list(
        self,
        *,
        app_id: Optional[str] = None,
        patient_reference: Optional[str] = None,
        end_user_id: Optional[str] = None,
        status: Optional[str] = None,
        provider: Optional[str] = None,
        limit: int = 50,
        offset: int = 0,
    ) -> List[VerificationSession]:
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        for key, value in (
            ("app_id", app_id), ("patient_reference", patient_reference),
            ("end_user_id", end_user_id), ("status", status), ("provider", provider),
        ):
            if value is not None:
                params[key] = value
        result = self._api.get(f"{_BASE}/sessions", params=params)
        return [VerificationSession.from_dict(s) for s in result.get("sessions", [])]

    def complete(self, session_id: str, *, code: str, state: str) -> VerificationSession:
        """Finish a session when you registered your own redirect URI with the vendor."""
        return VerificationSession.from_dict(
            self._api.post(f"{_BASE}/sessions/{session_id}/complete", json={"code": code, "state": state})
        )

    def cancel(self, session_id: str) -> VerificationSession:
        return VerificationSession.from_dict(self._api.post(f"{_BASE}/sessions/{session_id}/cancel", json={}))

    def match(
        self, session_id: str, *, first_name: str, last_name: str, date_of_birth: str,
    ) -> Dict[str, Any]:
        """``{"session_id", "match": bool, "reason"}`` without disclosing the demographics on record."""
        return self._api.post(
            f"{_BASE}/sessions/{session_id}/match",
            json={"first_name": first_name, "last_name": last_name, "date_of_birth": date_of_birth},
        )

    def link(self, session_id: str, end_user_id: Optional[str]) -> VerificationSession:
        """Link a pending or verified session to an app end user (the patient's
        account). Pass ``None`` to unlink."""
        return VerificationSession.from_dict(
            self._api.post(f"{_BASE}/sessions/{session_id}/link", json={"end_user_id": end_user_id})
        )

    def end_user_status(self, end_user_id: str) -> EndUserVerificationStatus:
        """Latest successful verification linked to an app end user."""
        return EndUserVerificationStatus.from_dict(self._api.get(f"{_BASE}/end-users/{end_user_id}"))

    def patient_status(self, patient_reference: str, *, provider: Optional[str] = None) -> PatientVerificationStatus:
        params = {"provider": provider} if provider else None
        return PatientVerificationStatus.from_dict(
            self._api.get(f"{_BASE}/patients/{patient_reference}", params=params)
        )

    def wait(
        self, session_id: str, *, timeout_seconds: float = 900.0, poll_interval_seconds: float = 3.0,
    ) -> VerificationSession:
        """Block until the session reaches a terminal status (or the timeout).

        Prefer the ``identity.verification_completed`` webhook in production;
        polling is for scripts and tests.
        """
        deadline = time.monotonic() + timeout_seconds
        while True:
            session = self.get(session_id)
            if session.is_terminal or time.monotonic() >= deadline:
                return session
            time.sleep(max(0.5, poll_interval_seconds))


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

"""
LiveSession: Direct-connect WebSocket client for live-streaming agents.

Protocol v2: POST /live/session for auth, then WebSocket to Cloud Run with
live_token; JSON control frames + binary PCM audio chunks.
"""

from __future__ import annotations

import asyncio
import base64
import json
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Dict, Optional

logger = logging.getLogger(__name__)

LIVE_PROTOCOL_VERSION = 2

try:
    import websockets
except ImportError:
    websockets = None


@dataclass
class LiveEvent:
    """A single event received from the live-streaming agent."""

    type: str
    modality: Optional[str] = None
    content: Optional[str] = None
    source: Optional[str] = None
    mime_type: Optional[str] = None
    partial: Optional[bool] = None
    finished: Optional[bool] = None
    interrupted: Optional[bool] = None
    message: Optional[str] = None
    raw: Dict[str, Any] = field(default_factory=dict)

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "LiveEvent":
        return cls(
            type=data.get("type", ""),
            modality=data.get("modality"),
            content=data.get("content"),
            source=data.get("source"),
            mime_type=data.get("mime_type"),
            partial=data.get("partial"),
            finished=data.get("finished"),
            interrupted=data.get("interrupted"),
            message=data.get("message"),
            raw=data,
        )


class LiveSession:
    """Direct-connect WebSocket session for live-streaming agents."""

    def __init__(
        self,
        api_client: Any,
        agent_id: str,
        api_key: str,
        session_id: Optional[str] = None,
        user_id: str = "sdk-user",
        context: Optional[Dict[str, Any]] = None,
    ):
        self._api = api_client
        self._agent_id = agent_id
        self._api_key = api_key
        self._session_id = session_id or uuid.uuid4().hex
        self._user_id = user_id
        self._context = context or {}
        self._ws: Any = None
        self._closed = False

    async def connect(self) -> "LiveSession":
        if websockets is None:
            raise ImportError(
                "The 'websockets' package is required for live streaming. "
                "Install it with: pip install websockets"
            )

        logger.info(
            "[live] requesting direct session agent_id=%s session_id=%s",
            self._agent_id,
            self._session_id[:8],
        )

        session_info = self._api.post(
            f"/api/v1/agents/{self._agent_id}/live/session",
            json={
                "session_id": self._session_id,
                "user_id": self._user_id,
                "context": self._context or None,
            },
        )

        wss_url = session_info["wss_url"]
        live_token = session_info["live_token"]
        self._session_id = session_info.get("session_id", self._session_id)

        self._ws = await websockets.connect(
            wss_url,
            open_timeout=45.0,
            ping_interval=20.0,
            ping_timeout=60.0,
        )

        await self._ws.send(
            json.dumps(
                {
                    "type": "auth",
                    "live_token": live_token,
                    "session_id": self._session_id,
                    "protocol_version": LIVE_PROTOCOL_VERSION,
                }
            )
        )
        logger.info("[live] direct connect established session_id=%s", self._session_id[:8])
        return self

    async def close(self) -> None:
        if self._closed or self._ws is None:
            return
        self._closed = True
        try:
            await self._ws.send(json.dumps({"type": "disconnect"}))
            await self._ws.close()
        except Exception:
            pass
        logger.info("[live] session closed session_id=%s", self._session_id[:8])

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

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

    async def start(self, modality: str = "text", mime_type: str = "audio/pcm;rate=16000") -> None:
        self._ensure_open()
        payload: Dict[str, Any] = {
            "type": "start",
            "modality": modality,
            "protocol_version": LIVE_PROTOCOL_VERSION,
        }
        if modality == "audio":
            payload["mime_type"] = mime_type
        await self._ws.send(json.dumps(payload))

    async def send_audio_async(
        self,
        audio_b64: str,
        mime_type: str = "audio/pcm;rate=16000",
    ) -> None:
        """Legacy base64 JSON audio (v1). Prefer send_audio_bytes for v2."""
        self._ensure_open()
        await self._ws.send(
            json.dumps(
                {
                    "type": "data",
                    "modality": "audio",
                    "content": audio_b64,
                    "mime_type": mime_type,
                }
            )
        )

    async def send_audio_bytes(
        self,
        pcm_bytes: bytes,
        mime_type: str = "audio/pcm;rate=16000",
    ) -> None:
        """Send raw PCM as a binary WebSocket frame (protocol v2)."""
        self._ensure_open()
        await self._ws.send(pcm_bytes)
        logger.debug(
            "[live] sent binary audio bytes=%d session_id=%s",
            len(pcm_bytes),
            self._session_id[:8],
        )

    async def send_text(self, text: str) -> None:
        self._ensure_open()
        await self._ws.send(
            json.dumps({"type": "data", "modality": "text", "content": text})
        )

    async def end_turn(self) -> None:
        self._ensure_open()
        await self._ws.send(json.dumps({"type": "end"}))

    async def receive(self) -> LiveEvent:
        self._ensure_open()
        raw = await self._ws.recv()
        if isinstance(raw, bytes):
            return LiveEvent(
                type="data",
                modality="audio",
                content=base64.b64encode(raw).decode(),
                raw={"type": "data", "modality": "audio", "binary": True},
            )
        data = json.loads(raw)
        return LiveEvent.from_dict(data)

    async def __aiter__(self) -> AsyncIterator[LiveEvent]:
        try:
            while not self._closed:
                yield await self.receive()
        except Exception as e:
            if not self._closed:
                logger.debug("[live] session ended: %s", e)

    @property
    def session_id(self) -> str:
        return self._session_id

    @property
    def is_open(self) -> bool:
        return not self._closed and self._ws is not None

    def _ensure_open(self) -> None:
        if self._closed or self._ws is None:
            raise RuntimeError("LiveSession is not connected. Use 'async with' or call connect() first.")


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

"""Model package for AgentSDK."""

from hikigai.agentsdk.models.config import (
    AgentConfig,
    ToolConfig,
    ConnectorConfig,
    SubAgentConfig,
    PlannerConfig,
    GenerationConfig,
    VadConfig,
)
from hikigai.agentsdk.models.schemas import (
    InputSchema,
    OutputSchema,
    StringField,
    IntegerField,
    BooleanField,
    ArrayField,
    ObjectField,
)
from hikigai.agentsdk.models.agent import DeployedAgent, DeploymentResult
from hikigai.agentsdk.models.runtime import RuntimeConfig, HIPAAConfig

__all__ =  [
    "AgentConfig",
    "ToolConfig",
    "ConnectorConfig",
    "SubAgentConfig",
    "PlannerConfig",
    "GenerationConfig",
    "VadConfig",
    "InputSchema",
    "OutputSchema",
    "StringField",
    "IntegerField",
    "BooleanField",
    "ArrayField",
    "ObjectField",
    "DeployedAgent",
    "DeploymentResult",
    "RuntimeConfig",
    "HIPAAConfig",
]


================================================================================
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

# Modality atoms aligned with backend services.modalities
_MODALITY_ATOMS = frozenset({"text", "audio", "image", "video", "document"})


def _validate_modality_value(v: str) -> str:
    """Accept any _and_-joined combination of the platform modality atoms."""
    atoms = {part for part in v.replace("+", "_and_").split("_and_") if part}
    if not atoms or not atoms <= _MODALITY_ATOMS:
        raise ValueError(
            f"Invalid modality '{v}'. Atoms must be from: {', '.join(sorted(_MODALITY_ATOMS))}"
        )
    return v


# ============================================================================
# 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 VadConfig(BaseModel):
    """Automatic VAD for live audio agents (Gemini Live ``realtime_input_config``)."""

    silence_duration_ms: Optional[int] = Field(
        800,
        ge=200,
        le=2000,
        description="Silence in ms before the user turn is committed (default 800)",
    )
    end_of_speech_sensitivity: Optional[str] = Field(
        "low",
        description="'high' commits sooner after a pause; 'low' waits longer (default low)",
    )
    start_of_speech_sensitivity: Optional[str] = Field(
        "low",
        description="'high' starts capturing sooner; 'low' is less sensitive (default low)",
    )

    @field_validator("end_of_speech_sensitivity", "start_of_speech_sensitivity")
    @classmethod
    def validate_sensitivity(cls, v: Optional[str]) -> Optional[str]:
        if v is None:
            return v
        value = v.strip().lower()
        if value not in ("high", "low"):
            raise ValueError("sensitivity must be 'high' or 'low'")
        return value


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.5-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: Optional[str] = Field(
        None,
        description=(
            "Model for this sub-agent. Required for own agents; "
            "omit when source='marketplace' or source_agent_id is set."
        ),
    )
    
    instruction: Optional[str] = Field(
        None,
        max_length=50000,
        description=(
            "System prompt for this sub-agent. Required for own agents; "
            "omit when source='marketplace' or source_agent_id is set — "
            "the platform hydrates it from the source 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, image, video, document, or combinations (e.g. text_and_image)",
    )

    output_modality: str = Field(
        "text",
        description="Output modality: text, audio, image, document, or combinations (e.g. text_and_image)",
    )

    live_handoff: Literal["task_completed", "after_output"] = Field(
        "task_completed",
        description=(
            "How this leaf ends under Sequential/Loop/Parallel live. "
            "'task_completed' (default): stay on the leaf until the task_completed "
            "tool is called — use for multi-turn conversation. "
            "'after_output': one-shot — advance after model output + turn_complete "
            "(or a short idle if the Live session stalls). "
            "Ignored for simple live agents with no workflow parent."
        ),
    )
    
    # 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)"
    )

    # Provenance when composing from an existing project/marketplace agent
    source_agent_id: Optional[str] = Field(
        None,
        description="ID of the existing agent this sub-agent was reused from",
    )
    reused: Optional[bool] = Field(
        None,
        description="True when this sub-agent was selected from an existing agent",
    )
    display_name: Optional[str] = Field(
        None,
        description="Human-readable display name from the source agent",
    )
    category: Optional[str] = Field(
        None,
        description="Category from the source agent (marketplace-style metadata)",
    )
    tags: Optional[List[str]] = Field(
        None,
        description="Tags from the source agent (marketplace-style metadata)",
    )
    source: Optional[str] = Field(
        None,
        description="Origin of the reused agent: 'deployed' or 'marketplace'",
    )
    
    @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: Optional[str]) -> Optional[str]:
        """Ensure model is valid when provided."""
        if v is None or v == "":
            return None
        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 uses platform atoms (text/audio/image/video/document)."""
        return _validate_modality_value(v)
    
    @model_validator(mode='after')
    def validate_workflow_agent(self) -> "SubAgentConfig":
        """Validate workflow agent configurations and reuse-by-reference rules."""
        source = (self.source or "").strip().lower()
        is_reuse = bool(self.source_agent_id) or source in {"marketplace", "deployed"}

        if is_reuse:
            # Reuse path: platform hydrates instruction/model/schemas from the source agent
            # (by id, or by name when source=marketplace|deployed).
            object.__setattr__(self, "reused", True)
            if not self.model:
                object.__setattr__(self, "model", "gemini-2.5-flash")
            if not self.instruction:
                object.__setattr__(self, "instruction", None)
            return self

        if not self.instruction or len(self.instruction) < 10:
            raise ValueError(
                "instruction is required (min 10 chars) unless source_agent_id is set "
                "or source is 'marketplace'/'deployed'"
            )
        if not self.model:
            raise ValueError(
                "model is required unless source_agent_id is set "
                "or source is 'marketplace'/'deployed'"
            )

        # 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, image, video, document, or combinations (e.g. text_and_image)"
    )

    output_modality: str = Field(
        "text",
        description="Output modality: text, audio, image, document, or combinations (e.g. text_and_image)"
    )

    vad: Optional[VadConfig] = Field(
        None,
        description="Live automatic VAD. Applied only when input_modality includes audio.",
    )

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

    public: bool = Field(
        False,
        description="Publicly listed in marketplace"
    )
    
    # ================== Cloud Deployment ==================
    
    cloud_provider: Optional[str] = Field(
        None,
        description=(
            "Where to deploy: 'gcp', 'aws', 'aws-lambda', 'aws-ecs-fargate'. "
            "Omit to use the organization/project deploy-default credential. "
            "Shortcuts: 'aws-lambda' / 'aws-ecs-fargate' pick the AWS runtime; "
            "'gcp' → Cloud Run; bare 'aws' defaults to Lambda when enabled. "
            "You can also pass cloud_service on deploy() instead."
        ),
    )

    cloud_credential_name: Optional[str] = Field(
        None,
        description=(
            "Cloud account name from Settings → Cloud accounts "
            "(exact name used when the account was added). "
            "Omit to use the project/org deploy-default account."
        ),
    )
    
    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

    @field_validator("input_modality", "output_modality")
    @classmethod
    def validate_modality(cls, v: str) -> str:
        """Ensure modality uses platform atoms (text/audio/image/video/document)."""
        return _validate_modality_value(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 one of the supported planning-capable models.
            planning_models = [
                "gemini-2.5-flash", "gemini-2.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.5-flash", "gemini-2.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/models/jobs.py
================================================================================

"""Job queue SDK models."""

from __future__ import annotations

from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional

from pydantic import BaseModel, Field


class JobStatus(str, Enum):
    QUEUED = "queued"
    SCHEDULED = "scheduled"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    RETRYING = "retrying"
    DEAD_LETTERED = "dead_lettered"
    CANCELLED = "cancelled"


class RetryPolicy(BaseModel):
    strategy: str = "exponential"
    max_attempts: int = 5
    min_backoff_seconds: int = 30
    max_backoff_seconds: int = 600
    max_retry_duration_seconds: int = 3600
    jitter: bool = True


class ScheduleConfig(BaseModel):
    delay_seconds: Optional[int] = None
    execute_at: Optional[str] = None


class DeadLetterConfig(BaseModel):
    destination: str = "default"
    alert_channels: List[str] = Field(default_factory=list)


class EnqueuedJob(BaseModel):
    id: str
    job_type: str
    status: JobStatus
    priority: str
    scheduled_at: Optional[str] = None
    status_url: str
    stream_url: str
    created_at: str
    max_attempts: int = 5


class JobAttempt(BaseModel):
    attempt_number: int
    status: str
    error_message: Optional[str] = None
    error_code: Optional[str] = None
    duration_ms: Optional[int] = None
    attempted_at: Optional[str] = None
    completed_at: Optional[str] = None


class JobDetail(BaseModel):
    id: str
    job_type: str
    status: JobStatus
    priority: str
    agent_id: Optional[str] = None
    current_attempt: int
    max_attempts: int
    last_error: Optional[str] = None
    last_error_code: Optional[str] = None
    retry_policy: Dict[str, Any] = Field(default_factory=dict)
    schedule_config: Optional[Dict[str, Any]] = None
    scheduled_at: Optional[str] = None
    started_at: Optional[str] = None
    completed_at: Optional[str] = None
    result: Optional[Dict[str, Any]] = None
    attempts: List[JobAttempt] = Field(default_factory=list)


class JobStreamEvent(BaseModel):
    event_type: str
    job_id: str
    status: Optional[str] = None
    phase: Optional[str] = None
    progress_pct: Optional[int] = None
    message: Optional[str] = None
    attempt: Optional[int] = None
    error: Optional[str] = None
    next_retry_at: Optional[str] = None
