"""Agent Orchestrator for AiCippy.

Coordinates multiple specialized agents to complete complex tasks
with parallel execution, dependency resolution, and result merging.

This module provides:
- Multi-agent task orchestration
- Parallel execution with concurrency limits
- Task decomposition and dependency resolution
- Bedrock model invocation with circuit breaker pattern
- Progress tracking and result aggregation
"""

from __future__ import annotations

import asyncio
import base64
import json
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Final

from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn

from aicippy.agents.bedrock_agent import BedrockAgentClient
from aicippy.agents.browser_tools import BrowserTools
from aicippy.agents.mcp_tools import MCPTools
from aicippy.agents.models import (
    AGENT_SYSTEM_PROMPTS,
    AgentConfig,
    AgentResponse,
    AgentStatus,
    AgentTask,
    AgentType,
    TokenUsage,
)
from aicippy.agents.vertex_agent import VertexAgentClient
from aicippy.config import get_settings
from aicippy.exceptions import (
    ErrorContext,
    ModelInvocationError,
)
from aicippy.types import SessionId
from aicippy.utils.correlation import get_correlation_id
from aicippy.utils.logging import get_logger
from aicippy.utils.retry import CircuitBreaker, CircuitBreakerOpenError, async_retry
from aicippy.workspace.ref_folder import get_agentic_dir, get_ref_folder

# Optional imports handled at top level
try:
    from vertexai.generative_models import FunctionDeclaration, Tool
except Exception:
    # Optional: vertexai may not be installed; degrade gracefully
    FunctionDeclaration = Any  # type: ignore[assignment]
    Tool = Any  # type: ignore[assignment]

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence
    from types import TracebackType

    from rich.console import Console

    from aicippy.browser.manager import BrowserManager
    from aicippy.memory.workspace_memory import WorkspaceMemory
    from aicippy.platform.tenant_context import TenantContext

logger = get_logger(__name__)

# ============================================================================
# Constants
# ============================================================================

DEFAULT_MAX_AGENTS: Final[int] = 10
MAX_TASK_DECOMPOSITION_DEPTH: Final[int] = 10
BEDROCK_READ_TIMEOUT: Final[int] = 300
BEDROCK_CONNECT_TIMEOUT: Final[int] = 15
BEDROCK_MAX_RETRIES: Final[int] = 3

# Model quality parameters - unrestricted output
MODEL_TEMPERATURE: Final[float] = 0.4
MODEL_TOP_P: Final[float] = 0.95
MODEL_MAX_TOKENS_LLAMA: Final[int] = 8192  # Llama 4 Maverick hard cap
MODEL_MAX_TOKENS_CLAUDE: Final[int] = 8192  # Claude output
MODEL_MAX_TOKENS: Final[int] = 8192  # Default max output

# Confidence thresholds for self-evaluation
CONFIDENCE_HIGH: Final[float] = 0.85
CONFIDENCE_MEDIUM: Final[float] = 0.60
CONFIDENCE_LOW: Final[float] = 0.40

# Maximum retry attempts for failed agent tasks
AGENT_TASK_MAX_RETRIES: Final[int] = 5

# Content formatting and evaluation limits
CONTENT_PREVIEW_MAX_LENGTH: Final[int] = 4000
MIN_RELIABLE_RESPONSE_LENGTH: Final[int] = 50

# Supported image MIME types for multimodal
IMAGE_MIME_TYPES: Final[dict[str, str]] = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".webp": "image/webp",
    ".bmp": "image/bmp",
}

# Project detection markers (file -> project type)
_PROJECT_MARKERS: Final[dict[str, str]] = {
    "package.json": "Node.js/JavaScript",
    "pyproject.toml": "Python",
    "Cargo.toml": "Rust",
    "go.mod": "Go",
    "pom.xml": "Java (Maven)",
    "build.gradle": "Java/Kotlin (Gradle)",
    "pubspec.yaml": "Dart/Flutter",
    "Gemfile": "Ruby",
    "composer.json": "PHP",
    "Makefile": "C/C++ (Make)",
    "CMakeLists.txt": "C/C++ (CMake)",
    "docker-compose.yml": "Docker Compose",
    "Dockerfile": "Docker",
    ".terraform": "Terraform",
    "cdk.json": "AWS CDK",
    "serverless.yml": "Serverless Framework",
}

# Instruction appended to system prompt for file output consistency
_FILE_OUTPUT_INSTRUCTION: Final[str] = (
    "\n\nCRITICAL FILE OUTPUT FORMAT — YOU MUST FOLLOW THIS EXACTLY:"
    "\nWhen creating ANY file, you MUST use this EXACT format:"
    "\n**File: filename.ext**"
    "\n```lang"
    "\n<complete file content>"
    "\n```"
    "\nEXAMPLE: **File: index.html**"
    "\n```html"
    "\n<!DOCTYPE html>..."
    "\n```"
    "\nNEVER output code without **File: filename** on the line before the code block."
    "\nNEVER use generic code blocks without specifying the target filename."
    '\nNEVER say "save this as" — use **File: name** format instead.'
    "\n\nCONSISTENCY RULE — MANDATORY:"
    "\nBefore generating ANY code, check the WORKSPACE CONTEXT above for existing config files."
    "\nIf tailwind.config.js exists, use EXACTLY the color names, font names, and spacing values defined in it."
    "\nIf tsconfig.json exists, follow its path aliases and compiler options."
    "\nNEVER invent new class names, color tokens, or font families that conflict with existing config."
    "\nWhen modifying an existing file, output the COMPLETE file — never output partial snippets or diffs."
)

# Files to read for project context (max bytes each)
_PROJECT_CONTEXT_FILES: Final[list[str]] = [
    "package.json",
    "pyproject.toml",
    "README.md",
    "CLAUDE.md",
    ".aicippy.md",
    "aicippy.md",
]
# Config files whose FULL content is injected so the model generates
# code that is consistent with the project's existing configuration.
_PROJECT_CONFIG_FILES: Final[list[str]] = [
    "tailwind.config.js",
    "tailwind.config.ts",
    "tailwind.config.cjs",
    "tailwind.config.mjs",
    "vite.config.js",
    "vite.config.ts",
    "tsconfig.json",
    "tsconfig.app.json",
    "postcss.config.js",
    "postcss.config.cjs",
    "postcss.config.mjs",
    "next.config.js",
    "next.config.ts",
    "next.config.mjs",
    "nuxt.config.ts",
    "angular.json",
    "webpack.config.js",
    "eslint.config.js",
    ".eslintrc.json",
    "wrangler.toml",
    "Cargo.toml",
    "go.mod",
    "pubspec.yaml",
    "setup.cfg",
]
_MAX_CONTEXT_FILE_BYTES: Final[int] = 3000
_MAX_CONFIG_FILE_BYTES: Final[int] = 4000

# Workspace context constants
_MAX_DIR_ENTRIES: Final[int] = 120
_MAX_SUBDIR_PREVIEW: Final[int] = 30
_MAX_SRC_GRANDCHILDREN: Final[int] = 30
_MAX_SRC_GREAT_GRANDCHILDREN: Final[int] = 15
_MAX_EXTRA_DIR_CHILDREN: Final[int] = 25
_MAX_EXTRA_DIR_GRANDCHILDREN: Final[int] = 10
_MAX_LEARNED_PATTERNS: Final[int] = 15
_MAX_CONVERSATION_INSIGHTS: Final[int] = 10
_README_PREVIEW_LENGTH: Final[int] = 500


def _build_workspace_context(
    workspace_memory: WorkspaceMemory | None = None,
) -> str:
    """Build rich workspace context for system prompt injection.

    When *workspace_memory* is provided (a ``WorkspaceMemory`` instance),
    its cached project study is used for project type / tech stack /
    folder structure, avoiding redundant filesystem scans.  Key project
    files are still read fresh for maximum accuracy.

    Falls back to full filesystem detection when no workspace memory
    is available.
    """
    cwd = Path.cwd()
    parts: list[str] = [
        "\n\nWORKSPACE CONTEXT (REAL — DO NOT HALLUCINATE):",
        f"CWD: {cwd}",
    ]

    # Use cached workspace study if available
    ws_study: dict[str, Any] = {}
    if workspace_memory is not None:
        try:
            ws_study = workspace_memory.project_study
        except Exception as exc:
            logger.debug("workspace_study_read_failed", error=str(exc))
            ws_study = {}

    try:
        # 1. Project metadata and base file list
        _add_project_metadata_context(parts, cwd, ws_study)

        # 2. Key project files and config files
        _add_project_files_context(parts, cwd)

        # 3. Directory structure (src/ and others)
        _add_directory_structure_context(parts, cwd)

        # 4. Environment, Docker, and CI/CD
        _add_operational_context(parts, cwd)

        # 5. README preview
        _add_readme_context(parts, cwd)

    except Exception as exc:
        logger.debug("workspace_context_build_failed", error=str(exc))
        parts.append("Files: (unable to list)")

    parts.append(
        "\nIMPORTANT: Use this REAL path for all file operations. "
        "Never invent paths. Create files ONLY inside this directory.",
    )

    # 6. Learned patterns and insights
    if workspace_memory is not None:
        _add_memory_insights_context(parts, workspace_memory)

    return "\n".join(parts)


def _add_project_metadata_context(parts: list[str], cwd: Path, ws_study: dict[str, Any]) -> None:
    """Add project type, tech stack, and base file listing to context."""
    try:
        dir_entries = sorted(p.name for p in cwd.iterdir())[:_MAX_DIR_ENTRIES]
        parts.append(
            "Files: " + (", ".join(dir_entries) if dir_entries else "(empty)"),
        )

        if ws_study:
            ptype = ws_study.get("type", "")
            if ptype and ptype != "unknown":
                parts.append(f"Project type: {ptype}")
            stack = ws_study.get("tech_stack", [])
            if stack:
                parts.append(f"Tech stack: {', '.join(stack)}")
            ep = ws_study.get("entry_points", [])
            if ep:
                parts.append(f"Entry points: {', '.join(ep)}")
            fc = ws_study.get("file_count", 0)
            if fc:
                parts.append(f"Total files: {fc}")
        else:
            detected_types = [ptype for marker, ptype in _PROJECT_MARKERS.items() if (cwd / marker).exists()]
            if detected_types:
                parts.append("Project type: " + ", ".join(detected_types))

        if (cwd / ".git").exists():
            parts.append("Git: yes")
    except Exception as exc:
        logger.debug("project_metadata_context_failed", error=str(exc))


def _add_project_files_context(parts: list[str], cwd: Path) -> None:
    """Add contents of key project and config files to context."""
    # Project-specific files
    for fname in _PROJECT_CONTEXT_FILES:
        fpath = cwd / fname
        if fpath.is_file():
            try:
                content = fpath.read_text(encoding="utf-8")[:_MAX_CONTEXT_FILE_BYTES]
                parts.append(f"\n--- {fname} ---\n{content}")
            except Exception as exc:
                logger.debug("project_file_read_failed", file=fname, error=str(exc))

    # Active configuration files
    for cfg_name in _PROJECT_CONFIG_FILES:
        cfg_path = cwd / cfg_name
        if cfg_path.is_file():
            try:
                content = cfg_path.read_text(encoding="utf-8")[:_MAX_CONFIG_FILE_BYTES]
                parts.append(f"\n--- {cfg_name} (ACTIVE CONFIG) ---\n{content}")
            except Exception as exc:
                logger.debug("config_file_read_failed", file=cfg_name, error=str(exc))


def _add_directory_structure_context(parts: list[str], cwd: Path) -> None:
    """Add detailed directory structure (src/ and other key folders)."""
    # 1. src/ structure (3 levels deep)
    src_path = cwd / "src"
    if src_path.is_dir():
        src_tree: list[str] = []
        try:
            for child in sorted(src_path.iterdir()):
                rel = child.relative_to(cwd)
                if child.is_file():
                    src_tree.append(str(rel))
                elif child.is_dir() and not child.name.startswith("."):
                    src_tree.append(f"{rel}/")
                    try:
                        for grandchild in sorted(child.iterdir())[:_MAX_SRC_GRANDCHILDREN]:
                            gc_rel = grandchild.relative_to(cwd)
                            if grandchild.is_file():
                                src_tree.append(f"  {gc_rel}")
                            elif grandchild.is_dir() and not grandchild.name.startswith("."):
                                src_tree.append(f"  {gc_rel}/")
                                try:
                                    for ggchild in sorted(grandchild.iterdir())[:_MAX_SRC_GREAT_GRANDCHILDREN]:
                                        src_tree.append(f"    {ggchild.relative_to(cwd)}")
                                except Exception as exc:
                                    logger.debug("Great-grandchild scan failed", error=str(exc))
                    except Exception as exc:
                        logger.debug("Grandchild scan failed", error=str(exc))
            if src_tree:
                parts.append("\n--- src/ structure (full tree) ---\n" + "\n".join(src_tree[:120]))
        except Exception as exc:
            logger.debug("src_structure_scan_failed", error=str(exc))

    # 2. Other key directories
    _scan_extra_directories(parts, cwd)


def _scan_extra_directories(parts: list[str], cwd: Path) -> None:
    """Scan additional key directories for structure context."""
    extra_dirs = [
        "lib",
        "app",
        "pages",
        "components",
        "routes",
        "api",
        "tests",
        "infrastructure",
        "browser-extension",
    ]
    for extra_dir in extra_dirs:
        path = cwd / extra_dir
        if path.is_dir():
            tree: list[str] = []
            try:
                for child in sorted(path.iterdir())[:_MAX_EXTRA_DIR_CHILDREN]:
                    child_rel = child.relative_to(cwd)
                    if child.is_file():
                        tree.append(str(child_rel))
                    elif child.is_dir() and not child.name.startswith("."):
                        tree.append(f"{child_rel}/")
                        try:
                            for gc in sorted(child.iterdir())[:_MAX_EXTRA_DIR_GRANDCHILDREN]:
                                tree.append(f"  {gc.relative_to(cwd)}")
                        except Exception as exc:
                            logger.debug("extra_dir_grandchild_scan_failed", error=str(exc))
                if tree:
                    parts.append(f"\n--- {extra_dir}/ structure ---\n" + "\n".join(tree[:40]))
            except Exception as exc:
                logger.debug("extra_dir_scan_failed", dir=extra_dir, error=str(exc))


def _add_operational_context(parts: list[str], cwd: Path) -> None:
    """Add context for environment, Docker, and CI/CD."""
    try:
        dir_entries = [p.name for p in cwd.iterdir()]

        # Identify shallow subdirectories
        dirs = [e for e in dir_entries if (cwd / e).is_dir() and not e.startswith(".")]

        if dirs:
            parts.append(f"Subdirectories: {', '.join(dirs[:_MAX_SUBDIR_PREVIEW])}")

        # Environment files
        env_files = [e for e in dir_entries if e.startswith(".env") or e == ".env"]
        if env_files:
            parts.append("Environment files (DO NOT READ CONTENTS): " + ", ".join(env_files))

        # Docker
        dockerfiles = [
            e
            for e in dir_entries
            if e.lower().startswith("dockerfile") or e in {"docker-compose.yml", "docker-compose.yaml"}
        ]
        if dockerfiles:
            parts.append(f"Docker config: {', '.join(dockerfiles)}")

        # CI/CD
        cicd = []
        if (cwd / ".github" / "workflows").is_dir():
            cicd.append(".github/workflows/")
        if (cwd / ".gitlab-ci.yml").is_file():
            cicd.append(".gitlab-ci.yml")
        if (cwd / "Jenkinsfile").is_file():
            cicd.append("Jenkinsfile")
        if (cwd / ".circleci").is_dir():
            cicd.append(".circleci/")
        if (cwd / "bitbucket-pipelines.yml").is_file():
            cicd.append("bitbucket-pipelines.yml")
        if cicd:
            parts.append(f"CI/CD config: {', '.join(cicd)}")

    except Exception as exc:
        logger.debug("operational_context_failed", error=str(exc))


def _add_readme_context(parts: list[str], cwd: Path) -> None:
    """Add README.md preview to context."""
    readme_path = cwd / "README.md"
    if readme_path.is_file():
        try:
            preview = readme_path.read_text(encoding="utf-8")[:_README_PREVIEW_LENGTH]
            parts.append("\n--- README.md (first 500 chars) ---\n" + preview)
        except Exception as exc:
            logger.debug("readme_read_failed", error=str(exc))


def _add_memory_insights_context(parts: list[str], workspace_memory: WorkspaceMemory) -> None:
    """Add learned patterns and conversation insights from memory."""
    try:
        data = workspace_memory._data
        patterns = data.get("learned_patterns", {})
        if patterns:
            pattern_lines = [f"- {p}" for p in list(patterns.keys())[:_MAX_LEARNED_PATTERNS]]
            parts.append("\n--- LEARNED PATTERNS ---\n" + "\n".join(pattern_lines))

        insights = data.get("conversation_insights", [])
        if insights:
            insight_lines = [f"- {i.get('insight', '')}" for i in insights[-_MAX_CONVERSATION_INSIGHTS:]]
            parts.append("\n--- CONVERSATION INSIGHTS ---\n" + "\n".join(insight_lines))
    except Exception as exc:
        logger.debug("memory_insights_context_failed", error=str(exc))



# ============================================================================
# Agent Skill Metadata
# ============================================================================

AGENT_SKILL_METADATA: Final[dict[str, dict[str, Any]]] = {
    AgentType.ORCHESTRATOR.value: {
        "display_name": "Orchestrator",
        "description": "Coordinates agents, decomposes tasks, merges results",
        "primary_skills": [
            "task decomposition",
            "coordination",
            "result merging",
            "quality assurance",
        ],
        "secondary_skills": [
            "general programming",
            "architecture",
            "troubleshooting",
        ],
        "handles_keywords": ["coordinate", "plan", "combine", "overall", "general"],
    },
    AgentType.INFRA_CORE.value: {
        "display_name": "Infrastructure Core",
        "description": "AWS/GCloud/Azure infrastructure, IaC, networking, security",
        "primary_skills": [
            "CDK",
            "Terraform",
            "CloudFormation",
            "VPC",
            "IAM",
            "networking",
            "security groups",
        ],
        "secondary_skills": [
            "cost optimization",
            "compliance",
            "disaster recovery",
            "multi-account",
        ],
        "handles_keywords": [
            "infrastructure",
            "vpc",
            "subnet",
            "iam",
            "cdk",
            "terraform",
            "cloudformation",
            "security group",
            "network",
            "ec2",
            "ecs",
        ],
    },
    AgentType.BEDROCK_AI.value: {
        "display_name": "Bedrock AI",
        "description": "GenAI, LLM integration, RAG, prompt engineering, model selection",
        "primary_skills": [
            "Bedrock",
            "LLM",
            "RAG",
            "prompt engineering",
            "embeddings",
            "guardrails",
        ],
        "secondary_skills": [
            "SageMaker",
            "Vertex AI",
            "fine-tuning",
            "model evaluation",
        ],
        "handles_keywords": [
            "bedrock",
            "ai",
            "llm",
            "model",
            "prompt",
            "rag",
            "embedding",
            "guardrail",
            "knowledge base",
            "genai",
        ],
    },
    AgentType.API_GATEWAY.value: {
        "display_name": "API Gateway",
        "description": "API design, WebSocket, load balancing, GraphQL, service mesh",
        "primary_skills": [
            "API Gateway",
            "WebSocket",
            "CloudFront",
            "GraphQL",
            "REST API",
        ],
        "secondary_skills": [
            "load balancing",
            "service mesh",
            "gRPC",
            "rate limiting",
        ],
        "handles_keywords": [
            "api",
            "gateway",
            "websocket",
            "cloudfront",
            "graphql",
            "rest",
            "endpoint",
            "route",
            "load balancer",
        ],
    },
    AgentType.COGNITO_AUTH.value: {
        "display_name": "Authentication",
        "description": "Identity management, OAuth, JWT, MFA, session management",
        "primary_skills": [
            "Cognito",
            "OAuth2",
            "JWT",
            "OIDC",
            "MFA",
            "session management",
        ],
        "secondary_skills": [
            "Keycloak",
            "Auth0",
            "zero-trust",
            "SAML",
            "passkeys",
        ],
        "handles_keywords": [
            "auth",
            "login",
            "cognito",
            "jwt",
            "oauth",
            "token",
            "session",
            "mfa",
            "password",
            "identity",
        ],
    },
    AgentType.CLI_CORE.value: {
        "display_name": "CLI Development",
        "description": "Command-line tools, terminal UI, package distribution",
        "primary_skills": ["Typer", "Rich", "Click", "Textual", "CLI design"],
        "secondary_skills": ["PyPI publishing", "shell completions", "cross-platform"],
        "handles_keywords": [
            "cli",
            "command",
            "terminal",
            "typer",
            "rich",
            "textual",
            "console",
            "shell",
            "pip",
        ],
    },
    AgentType.MCP_BRIDGES.value: {
        "display_name": "MCP Bridges",
        "description": "Tool integration, API connectors, SDKs, webhooks",
        "primary_skills": [
            "MCP",
            "boto3",
            "SDK integration",
            "webhooks",
            "tool calling",
        ],
        "secondary_skills": ["database connectors", "message queues", "OAuth clients"],
        "handles_keywords": [
            "mcp",
            "tool",
            "connector",
            "bridge",
            "sdk",
            "webhook",
            "integration",
            "api client",
        ],
    },
    AgentType.KNOWLEDGE_INGEST.value: {
        "display_name": "Knowledge Ingestion",
        "description": "Data pipelines, crawling, vector stores, ETL, search",
        "primary_skills": [
            "web crawling",
            "document processing",
            "vector stores",
            "chunking",
            "ETL",
        ],
        "secondary_skills": [
            "data quality",
            "search optimization",
            "streaming ingestion",
        ],
        "handles_keywords": [
            "ingest",
            "crawl",
            "scrape",
            "vector",
            "embedding",
            "chunk",
            "etl",
            "pipeline",
            "index",
            "search",
        ],
    },
    AgentType.OBSERVABILITY.value: {
        "display_name": "Observability",
        "description": "Logging, metrics, tracing, alerting, SRE practices",
        "primary_skills": [
            "CloudWatch",
            "X-Ray",
            "OpenTelemetry",
            "structured logging",
            "alerting",
        ],
        "secondary_skills": [
            "Prometheus",
            "Grafana",
            "Datadog",
            "SLI/SLO",
            "incident response",
        ],
        "handles_keywords": [
            "log",
            "metric",
            "trace",
            "alert",
            "monitor",
            "cloudwatch",
            "observability",
            "sli",
            "slo",
            "dashboard",
        ],
    },
    AgentType.EMAIL_NOTIFY.value: {
        "display_name": "Email & Notifications",
        "description": "Email delivery, push notifications, SMS, messaging workflows",
        "primary_skills": ["SES", "email templates", "push notifications", "SMS"],
        "secondary_skills": ["deliverability", "compliance", "analytics"],
        "handles_keywords": [
            "email",
            "notification",
            "ses",
            "push",
            "sms",
            "template",
            "message",
            "alert",
            "notify",
        ],
    },
    AgentType.CICD_DEPLOY.value: {
        "display_name": "CI/CD & Deployment",
        "description": "Build automation, deployment, Docker, release management",
        "primary_skills": [
            "GitHub Actions",
            "Docker",
            "CodePipeline",
            "PyPI publishing",
            "ECS deploy",
        ],
        "secondary_skills": [
            "feature flags",
            "database migrations",
            "security scanning",
            "ArgoCD",
        ],
        "handles_keywords": [
            "deploy",
            "ci",
            "cd",
            "github actions",
            "docker",
            "build",
            "release",
            "publish",
            "pipeline",
            "helm",
        ],
    },
}


# ============================================================================
# Data Classes
# ============================================================================


@dataclass(slots=True)
class AgentInstance:
    """Running agent instance with state tracking.

    Attributes:
        id: Unique agent identifier.
        agent_type: Type of specialized agent.
        config: Agent configuration.
        status: Current execution status.
        current_task: Currently executing task.
        total_tokens: Cumulative token usage.
        confidence: Self-assessed confidence score (0.0-1.0).
        retry_count: Number of retries for current task.
        started_at: Instance creation timestamp.
        last_activity: Last activity timestamp.

    """

    id: str
    agent_type: AgentType
    config: AgentConfig
    status: AgentStatus = AgentStatus.IDLE
    current_task: AgentTask | None = None
    total_tokens: TokenUsage = field(default_factory=TokenUsage)
    confidence: float = 1.0
    retry_count: int = 0
    started_at: datetime = field(default_factory=lambda: datetime.now(UTC))
    last_activity: datetime = field(default_factory=lambda: datetime.now(UTC))

    def update_activity(self) -> None:
        """Update last activity timestamp."""
        self.last_activity = datetime.now(UTC)

    def add_tokens(self, usage: TokenUsage) -> None:
        """Add token usage to cumulative total."""
        self.total_tokens = self.total_tokens + usage
        self.update_activity()


# ============================================================================
# Orchestrator
# ============================================================================


class AgentOrchestrator:
    """Multi-agent orchestrator for complex task execution.

    Coordinates specialized agents, manages parallel execution,
    handles dependencies, and merges results with comprehensive
    error handling and observability.

    Features:
    - Parallel agent execution up to configurable limit
    - Task decomposition with dependency resolution
    - Circuit breaker pattern for Bedrock API
    - Structured logging with correlation IDs
    - Graceful error recovery

    Example:
        >>> orchestrator = AgentOrchestrator(model_id="odin", max_agents=5)
        >>> response = await orchestrator.chat("Explain AWS Lambda")
        >>> print(response.content)

    Example with progress:
        >>> from rich.console import Console
        >>> console = Console()
        >>> response = await orchestrator.run_task_with_progress(
        ...     "Deploy a serverless API",
        ...     console,
        ... )

    """

    __slots__ = (
        "_agents",
        "_bedrock_client",
        "_browser_manager",
        "_browser_tools",
        "_circuit_breaker",
        "_completed_tasks",
        "_lock",
        "_max_agents",
        "_mcp_tools",
        "_model_id",
        "_session_id",
        "_settings",
        "_task_queue",
        "_tenant_ctx",
        "_vertex_client",
        "_workspace_memory",
    )

    def __init__(  # noqa: PLR0913 — All params are meaningful orchestrator configuration
        self,
        model_id: str | None = None,
        max_agents: int = DEFAULT_MAX_AGENTS,
        session_id: str | None = None,
        tenant_ctx: TenantContext | None = None,
        workspace_memory: WorkspaceMemory | None = None,
        *,
        browser_manager: BrowserManager | None = None,
    ) -> None:
        """Initialize the orchestrator.

        Args:
            model_id: Model ID to use (e.g., 'odin', 'arjuna'). Defaults to configured default.
            max_agents: Maximum number of parallel agents (capped by settings).
            session_id: Session identifier for tracking. Auto-generated if not provided.
            tenant_ctx: Optional tenant context for tenant-scoped operations.
            workspace_memory: Optional WorkspaceMemory for cached project context.
            browser_manager: Optional BrowserManager for web-based tools.

        Raises:
            ValueError: If max_agents is less than 1.

        """
        if max_agents < 1:
            msg = "max_agents must be at least 1"
            raise ValueError(msg)

        self._settings = get_settings()
        self._model_id = model_id or self._settings.get_model_id()
        self._max_agents = min(max_agents, self._settings.max_parallel_agents)
        self._session_id = SessionId(session_id or str(uuid.uuid4()))
        self._tenant_ctx = tenant_ctx
        self._workspace_memory = workspace_memory
        self._browser_manager = browser_manager

        self._browser_tools: BrowserTools | None = None
        if self._browser_manager:
            self._browser_tools = BrowserTools(self._browser_manager)
            
        self._mcp_tools = MCPTools()

        # Agent management
        self._agents: dict[str, AgentInstance] = {}
        self._task_queue: asyncio.Queue[AgentTask] = asyncio.Queue()
        self._completed_tasks: dict[str, AgentResponse] = {}
        self._lock = asyncio.Lock()

        # Circuit breaker for model APIs
        self._circuit_breaker = CircuitBreaker(
            failure_threshold=5,
            recovery_timeout=30.0,
            half_open_requests=3,
        )

        # Dual agent clients (lazy initialization)
        self._bedrock_client: BedrockAgentClient | None = None
        self._vertex_client: VertexAgentClient | None = None

        logger.info(
            "orchestrator_initialized",
            model_id=self._model_id,
            max_agents=self._max_agents,
            session_id=self._session_id,
            tenant_id=self._tenant_ctx.tenant_id if self._tenant_ctx else "",
        )

    @property
    def session_id(self) -> SessionId:
        """Get the session identifier."""
        return self._session_id

    @property
    def model_id(self) -> str:
        """Get the current model ID."""
        return self._model_id

    @property
    def max_agents(self) -> int:
        """Get the maximum number of parallel agents."""
        return self._max_agents

    @property
    def active_agent_count(self) -> int:
        """Get the number of currently active agents (running asyncio tasks)."""
        prefix = f"agent-{self._session_id}-"
        return sum(1 for task in asyncio.all_tasks() if task.get_name().startswith(prefix) and not task.done())

    def _save_execution_log(
        self,
        message: str,
        response: AgentResponse,
        correlation_id: str,
    ) -> None:
        """Persist an execution trace to ``aicippy-ref/agentic/`` if the folder exists."""
        try:
            cwd = Path.cwd()
            if get_ref_folder(cwd) is None:
                return  # no aicippy-ref folder — skip silently

            agentic_dir = get_agentic_dir(cwd)
            ts = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
            log_file = agentic_dir / f"{ts}_{correlation_id[:8]}.json"
            log_file.write_text(
                json.dumps(
                    {
                        "timestamp": datetime.now(UTC).isoformat(),
                        "session_id": str(self._session_id),
                        "correlation_id": correlation_id,
                        "model_id": self._model_id,
                        "message_length": len(message),
                        "response_length": len(response.content) if response.content else 0,
                        "is_error": response.is_error,
                        "agent_type": response.agent_type.value if response.agent_type else "unknown",
                        "input_tokens": response.usage.input_tokens if response.usage else 0,
                        "output_tokens": response.usage.output_tokens if response.usage else 0,
                    },
                    indent=2,
                ),
                encoding="utf-8",
            )
        except Exception as exc:
            logger.debug("execution_log_save_failed", error=str(exc))

    def _get_agent_clients(self) -> tuple[BedrockAgentClient, VertexAgentClient]:
        """Get both Bedrock and Vertex agent clients."""
        if self._bedrock_client is None or self._vertex_client is None:
            settings = get_settings()

            # Initialize Bedrock (odin)
            self._bedrock_client = BedrockAgentClient(
                agent_id=settings.bedrock_agent_id,
                agent_alias_id=settings.bedrock_agent_alias_id,
                session_id=str(self.session_id),
            )

            # Initialize Vertex (arjuna)
            vertex_model_id = settings.get_model_id("arjuna")
            self._vertex_client = VertexAgentClient(
                model_id=vertex_model_id,
                session_id=str(self.session_id),
            )

        return self._bedrock_client, self._vertex_client

    async def chat(
        self,
        message: str,
        image_paths: list[str] | None = None,
        _conversation_history: list[dict[str, Any]] | None = None,
        mode: str = "agent",
    ) -> AgentResponse:
        """Process a chat message and return response."""
        return await self.chat_stream(
            message=message,
            image_paths=image_paths,
            _conversation_history=_conversation_history,
            mode=mode,
            on_token=None,
        )

    async def chat_stream(
        self,
        message: str,
        image_paths: list[str] | None = None,
        _conversation_history: list[dict[str, Any]] | None = None,
        mode: str = "agent",
        on_token: Callable[[str], None] | None = None,
    ) -> AgentResponse:
        """Send a chat message and stream tokens in real-time."""
        if not message or not message.strip():
            return AgentResponse.error_response("Empty message", AgentType.ORCHESTRATOR)

        correlation_id = get_correlation_id()
        logger.info(
            "chat_started",
            message_length=len(message),
            model=self._model_id,
            mode=mode,
            images=len(image_paths) if image_paths else 0,
            correlation_id=correlation_id,
        )

        # Build system prompt
        sys_prompt = AGENT_SYSTEM_PROMPTS[AgentType.ORCHESTRATOR]
        if mode == "ultrathinking":
            sys_prompt += (
                "\n\nMODE: ULTRATHINKING ENABLED. Think deeply, analyze all edge cases, "
                "and provide an exhaustive, high-quality solution."
            )
        elif mode == "research":
            sys_prompt += (
                "\n\nMODE: WEB RESEARCH ENABLED. Proactively search for the latest documentation and syntaxes."
            )

        sys_prompt = sys_prompt + _build_workspace_context(self._workspace_memory)
        sys_prompt += _FILE_OUTPUT_INSTRUCTION
        full_prompt = f"{sys_prompt}\n\n{message}"

        bedrock_client, vertex_client = self._get_agent_clients()
        client = vertex_client if "arjuna" in self._model_id else bedrock_client

        async def tool_executor(name: str, args: dict[str, object]) -> object:
            if name.startswith("mcp_"):
                return await self._mcp_tools.call_tool(name, args)
            if self._browser_tools:
                return await self._browser_tools.call_tool(name, args)
            return "Error: Browser tools not available"

        try:
            result_content = await client.invoke(
                input_text=full_prompt,
                on_token=on_token,
                tool_executor=tool_executor,
            )
            return AgentResponse(content=result_content, usage=TokenUsage(0, 0))
        except Exception as e:
            logger.exception("chat_failed", error=str(e))
            return AgentResponse.error_response(str(e), AgentType.ORCHESTRATOR)

    async def run_task_with_progress(
        self,
        task: str,
        console: Console,
    ) -> AgentResponse:
        """Execute a task with progress display.

        Args:
            task: Task description to execute.
            console: Rich console for progress display.

        Returns:
            AgentResponse with combined results from all agents.

        Note:
            Complex tasks are automatically decomposed into subtasks
            and executed in parallel where dependencies allow.

        """
        if not task or not task.strip():
            return AgentResponse.error_response(
                "Empty task provided",
                AgentType.ORCHESTRATOR,
            )

        correlation_id = get_correlation_id()
        logger.info(
            "task_started",
            task=task[:100],
            max_agents=self._max_agents,
            correlation_id=correlation_id,
        )

        try:
            # Analyze task and create subtasks
            subtasks = await self._analyze_and_decompose(task)

            if not subtasks:
                # Simple task - process directly
                logger.debug(
                    "task_simple_execution",
                    correlation_id=correlation_id,
                )
                return await self.chat(task)

            logger.info(
                "task_decomposed",
                subtask_count=len(subtasks),
                correlation_id=correlation_id,
            )

            # Create progress display
            with Progress(
                SpinnerColumn(),
                TextColumn("[progress.description]{task.description}"),
                BarColumn(),
                TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
                console=console,
                transient=True,
            ) as progress:
                # Add progress tasks for each subtask
                progress_tasks: dict[str, TaskID] = {}
                for subtask in subtasks:
                    task_id = progress.add_task(
                        f"[cyan]{subtask.agent_type.value}[/cyan]: {subtask.description[:50]}",
                        total=100,
                    )
                    progress_tasks[subtask.id] = task_id

                # Execute subtasks with parallelism
                results = await self._execute_subtasks(
                    subtasks,
                    lambda task_id, pct: progress.update(
                        progress_tasks.get(task_id, TaskID(0)),
                        completed=pct,
                    ),
                )

            # Merge results
            merged = self._merge_results(results)

            logger.info(
                "task_completed",
                subtask_count=len(subtasks),
                successful=sum(1 for r in results.values() if not r.is_error),
                failed=sum(1 for r in results.values() if r.is_error),
                correlation_id=correlation_id,
            )
        except Exception as e:
            logger.exception(
                "task_failed",
                error=str(e),
                correlation_id=correlation_id,
            )
            return AgentResponse.error_response(
                f"Task execution failed: {e}",
                AgentType.ORCHESTRATOR,
            )
        else:
            return merged

    async def _analyze_and_decompose(self, task: str) -> list[AgentTask]:
        """Analyze a task and decompose into subtasks for parallel execution.

        Args:
            task: Task description to analyze.

        Returns:
            List of subtasks. Empty list if task is simple.

        """
        analysis_prompt = (
            "Analyze this task and decompose into non-conflicting subtasks for parallel agent execution.\n"
        )
        analysis_prompt += f"""

RULES FOR DECOMPOSITION:
1. Each subtask must be INDEPENDENT - no overlapping scope with other subtasks
2. Each agent must have a CLEAR, NON-CONFLICTING boundary of responsibility
3. All subtasks must work toward the SAME GOAL with UNIFORM output standards
4. If the task is simple enough for one agent, return an empty list
5. Assign the MOST SPECIFIC agent type for each subtask

Available agent types:
- infra-core: AWS CDK, IAM, VPC, security groups, networking, CloudFormation
- bedrock-ai: Bedrock agents, knowledge bases, guardrails, model config, AI/ML
- api-gateway: API Gateway, WebSocket, Lambda authorizers, CloudFront, ALB
- cognito-auth: Cognito, authentication, authorization, JWT, OAuth, sessions
- cli-core: Python CLI, Typer, Rich, terminal UI, command design
- mcp-bridges: Tool connectors, SDK integrations, API bridges, webhooks
- knowledge-ingest: Data pipelines, crawling, indexing, vector stores, RAG
- observability: CloudWatch, X-Ray, logging, monitoring, alerting, dashboards
- email-notify: SES, email templates, push notifications, SMS, messaging
- cicd-deploy: GitHub Actions, CodePipeline, Docker, deployment, testing

Task: {task}

Respond with a JSON array. Each subtask must have:
- description: Precise task description (what to produce, not what to think about)
- agent_type: Exactly one agent type from above
- priority: 0-10 (10 = execute first, foundational work)
- dependencies: Array of subtask indices (0-based) that must complete before this starts
- output_format: Expected output format (code/config/architecture/documentation/analysis)

For a simple task: []
"""

        try:
            response = await self._invoke_model(
                message=analysis_prompt,
                system_prompt="You are a task analysis assistant. Respond only with valid JSON.",
            )

            # Parse JSON response
            content = response.content.strip()

            # Extract JSON from markdown code blocks if present
            if "```json" in content:
                content = content.split("```json")[1].split("```")[0]
            elif "```" in content:
                content = content.split("```")[1].split("```")[0]

            content = content.strip()

            try:
                subtask_data = json.loads(content)
            except json.JSONDecodeError as e:
                logger.warning(
                    "task_decomposition_json_error",
                    error=str(e),
                    content_preview=content[:200],
                )
                return []

            if not subtask_data or not isinstance(subtask_data, list):
                return []

            # Convert to AgentTask objects with validation
            subtasks: list[AgentTask] = []
            for _i, data in enumerate(subtask_data):
                if not isinstance(data, dict):
                    continue

                description = data.get("description", "")
                agent_type_str = data.get("agent_type", "")

                if not description or not agent_type_str:
                    continue

                try:
                    agent_type = AgentType.from_string(agent_type_str)
                except (ValueError, KeyError):
                    logger.warning(
                        "task_decomposition_invalid_agent_type",
                        agent_type=agent_type_str,
                    )
                    continue

                task_id = str(uuid.uuid4())
                dependencies = []

                # Resolve dependency indices to task IDs
                for dep_idx in data.get("dependencies", []):
                    if isinstance(dep_idx, int) and 0 <= dep_idx < len(subtasks):
                        dependencies.append(subtasks[dep_idx].id)

                subtasks.append(
                    AgentTask(
                        id=task_id,
                        description=description,
                        agent_type=agent_type,
                        priority=min(10, max(0, int(data.get("priority", 5)))),
                        dependencies=dependencies,
                    ),
                )
        except Exception as e:
            logger.warning(
                "task_decomposition_failed",
                error=str(e),
            )
            return []
        else:
            return subtasks

    async def _execute_subtasks(
        self,
        subtasks: Sequence[AgentTask],
        progress_callback: Callable[[str, int], None],
    ) -> dict[str, AgentResponse]:
        """Execute subtasks with parallel execution and dependency handling.

        Args:
            subtasks: List of subtasks to execute.
            progress_callback: Callback for progress updates (task_id, percentage).

        Returns:
            Dictionary mapping task IDs to responses.

        """
        results: dict[str, AgentResponse] = {}
        pending: dict[str, AgentTask] = {task.id: task for task in subtasks}
        running: dict[str, asyncio.Task[AgentResponse]] = {}

        while pending or running:
            # Find tasks that can be started (all dependencies completed)
            ready = [task for task in pending.values() if all(dep in results for dep in task.dependencies)]

            # Sort by priority (higher first) and start tasks up to max_agents
            ready.sort(key=lambda t: t.priority, reverse=True)

            for task in ready:
                if len(running) >= self._max_agents:
                    break

                # Start task execution
                del pending[task.id]
                progress_callback(task.id, 10)

                async_task = asyncio.create_task(
                    self._execute_single_task(task, results, progress_callback),
                    name=f"agent-{self._session_id}-{task.agent_type.value}-{task.id[:8]}",
                )
                running[task.id] = async_task

            # Detect deadlock: tasks remain pending but none are ready and none are running
            if pending and not ready and not running:
                deadlocked_ids = list(pending.keys())
                logger.error(
                    "subtask_dependency_deadlock",
                    deadlocked_task_ids=deadlocked_ids,
                )
                for task_id in deadlocked_ids:
                    results[task_id] = AgentResponse.error_response(
                        "Task skipped due to unresolvable dependency cycle",
                        pending[task_id].agent_type,
                    )
                    progress_callback(task_id, 100)
                pending.clear()
                break

            if running:
                # Wait for at least one task to complete
                done, _ = await asyncio.wait(
                    running.values(),
                    return_when=asyncio.FIRST_COMPLETED,
                )

                # Process completed tasks -- snapshot keys to avoid mutation during iteration
                for completed_task in done:
                    # Find which task completed
                    completed_id: str | None = None
                    for tid, t in list(running.items()):
                        if t is completed_task:
                            completed_id = tid
                            break

                    if completed_id:
                        try:
                            results[completed_id] = completed_task.result()
                        except Exception as e:
                            logger.warning(
                                "subtask_exception",
                                task_id=completed_id,
                                error=str(e),
                            )
                            results[completed_id] = AgentResponse.error_response(
                                str(e),
                                AgentType.ORCHESTRATOR,
                            )

                        del running[completed_id]
                        progress_callback(completed_id, 100)

            # Small delay to prevent tight loop
            await asyncio.sleep(0.05)

        return results

    async def _execute_single_task(
        self,
        task: AgentTask,
        completed_results: dict[str, AgentResponse],
        progress_callback: Callable[[str, int], None],
    ) -> AgentResponse:
        """Execute a single subtask with context from dependencies.

        Includes self-evaluation of response confidence and automatic retry
        for failed or low-confidence results.

        Args:
            task: Task to execute.
            completed_results: Results from completed dependent tasks.
            progress_callback: Progress update callback.

        Returns:
            AgentResponse from task execution.

        """
        correlation_id = get_correlation_id()
        progress_callback(task.id, 20)

        # Build context from dependencies
        context_parts: list[str] = []
        for dep_id in task.dependencies:
            if dep_id in completed_results:
                dep_result = completed_results[dep_id]
                if not dep_result.is_error:
                    # Include substantial context for uniformity
                    content_preview = dep_result.content[:CONTENT_PREVIEW_MAX_LENGTH]
                    if len(dep_result.content) > CONTENT_PREVIEW_MAX_LENGTH:
                        content_preview += "... [truncated]"
                    context_parts.append(f"Previous result: {content_preview}")

        context = "\n\n".join(context_parts) if context_parts else ""
        progress_callback(task.id, 40)

        # Get system prompt for this agent type
        system_prompt = AGENT_SYSTEM_PROMPTS.get(
            task.agent_type,
            AGENT_SYSTEM_PROMPTS[AgentType.ORCHESTRATOR],
        )

        # Build message with context and uniformity directives
        message_parts = [f"Execute this task:\n{task.description}"]
        if context:
            message_parts.append(
                f"\nContext from completed tasks (maintain consistency with these results):\n{context}",
            )
        message_parts.append(
            "\nOUTPUT REQUIREMENTS:"
            "\n- Provide COMPLETE, production-grade output (code, config, or analysis)"
            "\n- Use consistent naming conventions and patterns with other agent outputs"
            "\n- Include ALL necessary imports, dependencies, and configuration"
            "\n- Do NOT truncate, simplify, or leave placeholders"
            "\n- Maintain the SAME quality standard regardless of task complexity",
        )

        message = "\n".join(message_parts)
        progress_callback(task.id, 60)

        # Execute with the specialized agent
        try:
            response = await self._invoke_model(
                message=message,
                system_prompt=system_prompt,
            )

            response.agent_type = task.agent_type
            response.task_id = task.id

            # Self-evaluate response confidence
            confidence = self._evaluate_response_confidence(response)
            response.metadata["confidence"] = confidence

            # If confidence is critically low, attempt retry
            if confidence < CONFIDENCE_LOW and not response.is_error:
                logger.info(
                    "subtask_low_confidence_retry",
                    task_id=task.id,
                    agent_type=task.agent_type.value,
                    confidence=confidence,
                    correlation_id=correlation_id,
                )
                retry_response = await self._retry_failed_task(
                    task,
                    (
                        "Previous response had low confidence"
                        f" ({confidence:.2f}). Output may be"
                        " incomplete or uncertain."
                    ),
                    completed_results,
                    progress_callback,
                )
                retry_confidence = self._evaluate_response_confidence(retry_response)
                retry_response.metadata["confidence"] = retry_confidence
                retry_response.metadata["is_retry"] = True

                # Use retry only if it improved confidence
                if retry_confidence > confidence:
                    response = retry_response

            progress_callback(task.id, 90)

            logger.debug(
                "subtask_completed",
                task_id=task.id,
                agent_type=task.agent_type.value,
                confidence=response.metadata.get("confidence", 1.0),
                correlation_id=correlation_id,
            )

        except Exception as e:
            logger.warning(
                "subtask_failed",
                task_id=task.id,
                agent_type=task.agent_type.value,
                error=str(e),
                correlation_id=correlation_id,
            )

            # Attempt retry on failure
            error_response = await self._retry_failed_task(
                task,
                str(e),
                completed_results,
                progress_callback,
            )
            if not error_response.is_error:
                logger.info(
                    "subtask_recovered_via_retry",
                    task_id=task.id,
                    agent_type=task.agent_type.value,
                    correlation_id=correlation_id,
                )
                return error_response

            return AgentResponse.error_response(str(e), task.agent_type)
        else:
            return response

    def _merge_results(
        self,
        results: dict[str, AgentResponse],
    ) -> AgentResponse:
        """Merge results from multiple agents into a single response.

        Includes confidence scoring summary and flags low-confidence results.

        Args:
            results: Dictionary of task results.

        Returns:
            Merged AgentResponse with combined content, usage, and confidence metadata.

        """
        if not results:
            return AgentResponse(content="No results to merge.")

        # Combine content by agent type
        content_parts: list[str] = []
        total_usage = TokenUsage()
        all_artifacts: list[dict[str, Any]] = []
        error_count = 0
        confidence_scores: list[float] = []
        low_confidence_agents: list[str] = []

        for _task_id, response in results.items():
            agent_label = response.agent_type.value if response.agent_type else "unknown"
            confidence = response.metadata.get("confidence", 1.0)
            confidence_scores.append(confidence)

            if response.is_error:
                error_count += 1
                content_parts.append(f"## {agent_label} (Error)\n{response.error}")
            # Add confidence warning for low-confidence results
            elif confidence < CONFIDENCE_MEDIUM:
                low_confidence_agents.append(agent_label)
                content_parts.append(
                    f"## {agent_label} (Confidence: {confidence:.0%} - verify recommended)\n{response.content}",
                )
            else:
                content_parts.append(f"## {agent_label}\n{response.content}")

            if response.usage:
                total_usage = total_usage + response.usage

            all_artifacts.extend(response.artifacts)

        merged_content = "\n\n---\n\n".join(content_parts)

        # Add summary header for errors or low confidence
        headers: list[str] = []
        if error_count > 0:
            headers.append(f"**Note:** {error_count} of {len(results)} tasks encountered errors.")
        if low_confidence_agents:
            headers.append(
                f"**Confidence Warning:** The following agents reported low confidence: "
                f"{', '.join(low_confidence_agents)}. Results should be verified.",
            )
        if headers:
            merged_content = "\n".join(headers) + "\n\n" + merged_content

        # Calculate aggregate confidence
        avg_confidence = sum(confidence_scores) / len(confidence_scores) if confidence_scores else 1.0

        return AgentResponse(
            content=merged_content,
            agent_type=AgentType.ORCHESTRATOR,
            usage=total_usage,
            artifacts=all_artifacts,
            metadata={
                "aggregate_confidence": avg_confidence,
                "agent_count": len(results),
                "error_count": error_count,
                "low_confidence_count": len(low_confidence_agents),
            },
        )

    @staticmethod
    def _build_image_content(image_paths: list[str]) -> list[dict[str, Any]]:
        """Build multimodal image content blocks from file paths."""
        content_blocks: list[dict[str, Any]] = []
        for path_str in image_paths:
            path = Path(path_str)
            if not path.exists():
                continue
            suffix = path.suffix.lower()
            mime_type = IMAGE_MIME_TYPES.get(suffix)
            if not mime_type:
                continue
            image_data = base64.standard_b64encode(path.read_bytes()).decode("ascii")
            content_blocks.append(
                {
                    "type": "image",
                    "source": {
                        "type": "base64",
                        "media_type": mime_type,
                        "data": image_data,
                    },
                },
            )
        return content_blocks

    @async_retry(max_attempts=3, min_wait=1.0, max_wait=10.0)
    async def _invoke_model(
        self,
        message: str,
        system_prompt: str,
        _image_paths: list[str] | None = None,
        _conversation_history: list[dict[str, Any]] | None = None,
        _mode: str = "agent",
        on_token: Callable[[str], None] | None = None,
    ) -> AgentResponse:
        """Invoke the selected model with retry logic and circuit breaker."""
        correlation_id = get_correlation_id()
        bedrock_client, vertex_client = self._get_agent_clients()

        # Select client based on model_id
        client = vertex_client if "arjuna" in self._model_id else bedrock_client

        full_prompt = f"{system_prompt}\n\n{message}"

        async def tool_executor(name: str, args: dict[str, object]) -> object:
            if name.startswith("mcp_"):
                return await self._mcp_tools.call_tool(name, args)
            if self._browser_tools:
                return await self._browser_tools.call_tool(name, args)
            return "Error: Browser tools not available"

        # Vertex AI tool definitions
        vertex_tools = []
        if "arjuna" in self._model_id:
            declarations = []
            if self._browser_tools:
                for tool_def in self._browser_tools.get_tool_definitions():
                    declarations.append(
                        FunctionDeclaration(
                            name=tool_def["name"],
                            description=tool_def["description"],
                            parameters=tool_def["parameters"],
                        ),
                    )
            
            for tool_def in self._mcp_tools.get_tool_definitions():
                declarations.append(
                    FunctionDeclaration(
                        name=tool_def["name"],
                        description=tool_def["description"],
                        parameters=tool_def["parameters"],
                    ),
                )

            if declarations:
                vertex_tools = [Tool(function_declarations=declarations)]

        try:
            # Execute through circuit breaker
            async def invoke() -> str:
                if "arjuna" in self._model_id:
                    return await client.invoke(
                        input_text=full_prompt,
                        on_token=on_token,
                        tools=vertex_tools,
                        tool_executor=tool_executor,
                    )
                return await client.invoke(
                    input_text=full_prompt,
                    on_token=on_token,
                    tool_executor=tool_executor,
                )

            result_content = await self._circuit_breaker.call(invoke)

            # Placeholder for usage data; the client should return this.
            usage = TokenUsage(input_tokens=0, output_tokens=0)

        except CircuitBreakerOpenError:
            logger.warning(
                "circuit_breaker_open",
                model=self._model_id,
                correlation_id=correlation_id,
            )
            raise
        except Exception as e:
            logger.exception(
                "model_invocation_failed",
                error=str(e),
                model=self._model_id,
                correlation_id=correlation_id,
            )
            # Wrap exception in a standard error type
            raise ModelInvocationError(
                message=f"[{self._model_id}] {e}",
                context=ErrorContext(
                    correlation_id=correlation_id,
                    operation="model_invocation",
                    details={"model_id": self._model_id},
                ),
            ) from e
        else:
            return AgentResponse(content=result_content, usage=usage)

    @staticmethod
    def get_agent_skill_metadata(agent_type: AgentType) -> dict[str, Any]:
        """Get skill metadata for an agent type.

        Args:
            agent_type: The agent type to look up.

        Returns:
            Dictionary with display_name, description, primary_skills,
            secondary_skills, and handles_keywords.

        """
        return AGENT_SKILL_METADATA.get(
            agent_type.value,
            {
                "display_name": agent_type.value,
                "description": "General-purpose agent",
                "primary_skills": [],
                "secondary_skills": [],
                "handles_keywords": [],
            },
        )

    @staticmethod
    def find_best_agent_for_query(query: str) -> AgentType:
        """Find the best agent type for a given query based on keyword matching.

        Uses the AGENT_SKILL_METADATA keyword mappings to score each agent
        and returns the one with the highest relevance score.

        Args:
            query: The user query or task description.

        Returns:
            The most relevant AgentType. Falls back to ORCHESTRATOR.

        """
        query_lower = query.lower()
        best_agent = AgentType.ORCHESTRATOR
        best_score = 0

        for agent_type_str, metadata in AGENT_SKILL_METADATA.items():
            if agent_type_str == AgentType.ORCHESTRATOR.value:
                continue

            score = 0
            for keyword in metadata.get("handles_keywords", []):
                if keyword in query_lower:
                    score += 2
            for skill in metadata.get("primary_skills", []):
                if skill.lower() in query_lower:
                    score += 1

            if score > best_score:
                best_score = score
                try:
                    best_agent = AgentType.from_string(agent_type_str)
                except ValueError:
                    logger.warning(
                        "agent_type_parse_failed",
                        agent_type=agent_type_str,
                        exc_info=True,
                    )

        return best_agent

    def _evaluate_response_confidence(self, response: AgentResponse) -> float:
        """Evaluate the confidence of an agent response based on heuristics.

        Checks for indicators of uncertainty, errors, or incomplete output.

        Args:
            response: The agent response to evaluate.

        Returns:
            Confidence score between 0.0 and 1.0.

        """
        if response.is_error:
            return 0.0

        content = response.content.lower()
        confidence = 1.0

        # Check for uncertainty indicators
        uncertainty_phrases = [
            "i'm not sure",
            "i am not sure",
            "i'm uncertain",
            "i don't know",
            "i cannot determine",
            "it's unclear",
            "may not be accurate",
            "this might not",
            "i believe",
            "possibly",
            "approximately",
        ]
        for phrase in uncertainty_phrases:
            if phrase in content:
                confidence -= 0.1

        # Check for empty or very short responses
        if len(response.content.strip()) < MIN_RELIABLE_RESPONSE_LENGTH:
            confidence -= 0.3

        # Check for placeholder indicators
        placeholder_phrases = [
            "todo",
            "placeholder",
            "not implemented",
            "coming soon",
            "tbd",
        ]
        for phrase in placeholder_phrases:
            if phrase in content:
                confidence -= 0.15

        # Ensure confidence stays in valid range
        return max(0.0, min(1.0, confidence))

    async def _retry_failed_task(
        self,
        task: AgentTask,
        original_error: str,
        completed_results: dict[str, AgentResponse],
        _progress_callback: Callable[[str, int], None],
    ) -> AgentResponse:
        """Retry a failed agent task with enhanced error context.

        Provides the original error to the agent so it can adjust its approach.

        Args:
            task: The task that failed.
            original_error: Error message from the original attempt.
            completed_results: Results from completed dependent tasks.
            progress_callback: Progress update callback.

        Returns:
            AgentResponse from the retry attempt.

        """
        logger.info(
            "agent_task_retry",
            task_id=task.id,
            agent_type=task.agent_type.value,
            original_error=original_error[:200],
        )

        # Build retry message with error context
        context_parts: list[str] = []
        for dep_id in task.dependencies:
            if dep_id in completed_results:
                dep_result = completed_results[dep_id]
                if not dep_result.is_error:
                    content_preview = dep_result.content[:2000]
                    context_parts.append(f"Previous result: {content_preview}")

        context = "\n\n".join(context_parts) if context_parts else ""

        system_prompt = AGENT_SYSTEM_PROMPTS.get(
            task.agent_type,
            AGENT_SYSTEM_PROMPTS[AgentType.ORCHESTRATOR],
        )

        retry_message = (
            f"RETRY: A previous attempt at this task failed with error:\n{original_error}\n\n"
            f"Please try a different approach to complete this task:\n{task.description}"
        )
        if context:
            retry_message += f"\n\nContext from completed tasks:\n{context}"

        retry_message += (
            "\nOUTPUT REQUIREMENTS:"
            "\n- Provide COMPLETE, production-grade output"
            "\n- Address the failure from the previous attempt"
            "\n- Include ALL necessary imports and configuration"
        )

        try:
            response = await self._invoke_model(
                message=retry_message,
                system_prompt=system_prompt,
            )
            response.agent_type = task.agent_type
            response.task_id = task.id
            return response  # noqa: TRY300 — Intentional: return success before except handles failure
        except Exception as e:
            logger.warning(
                "agent_task_retry_failed",
                task_id=task.id,
                error=str(e),
            )
            return AgentResponse.error_response(
                f"Retry also failed: {e}",
                task.agent_type,
            )

    async def close(self) -> None:
        """Clean up orchestrator resources and cancel any running tasks."""
        self._agents.clear()
        self._completed_tasks.clear()

        # Clear task queue
        while not self._task_queue.empty():
            try:
                self._task_queue.get_nowait()
            except asyncio.QueueEmpty:
                break

        # Cancel any still-running agent tasks from _execute_subtasks
        # (scoped to this orchestrator's session to avoid cancelling other instances' tasks)
        prefix = f"agent-{self._session_id}-"
        for task in asyncio.all_tasks():
            if task.get_name().startswith(prefix) and not task.done():
                task.cancel()

        logger.debug("orchestrator_closed", session_id=self._session_id)

    async def __aenter__(self) -> AgentOrchestrator:
        """Async context manager entry."""
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> None:
        """Async context manager exit with cleanup."""
        await self.close()
