"""Subagent orchestration: dispatch subagents with role-scoped prompts."""

from __future__ import annotations

import asyncio
import contextlib
import logging
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING

import aiosqlite

from agntspace.llm.base import LLMProvider, Message
from agntspace.vault.reader import VaultReader

if TYPE_CHECKING:
    from agntspace.security.contract import ContractEnforcer
    from agntspace.skills.loader import SkillLoader

log = logging.getLogger(__name__)


def _orch_log_tool_crash(
    orch: object,
    agent_name: str,
    tool_name: str,
    tool_input: dict,
    exc: BaseException,
    branch: str,
) -> None:
    """Fix 8 (orchestrator): subagent tool exceptions reach ErrorLogger +
    high-importance activity event, identical contract to
    ``Agent._log_tool_crash``. Without this, a subagent tool crash becomes
    ``{"error": str(e)}`` and vanishes — the dispatch_log shows nothing,
    the activity log shows nothing, the LLM gets a poisoned tool result.
    """
    import asyncio as _asyncio
    import traceback as _tb

    log.exception("Subagent '%s' tool crash in %s (%s): %s", agent_name, tool_name, branch, exc)

    err_logger = getattr(orch, "_error_logger", None)
    if err_logger is not None and hasattr(err_logger, "log_exception"):
        try:
            loop = _asyncio.get_running_loop()
            loop.create_task(err_logger.log_exception(
                exc,
                source=f"subagent:{agent_name}:{tool_name}",
                context={
                    "agent": agent_name,
                    "tool": tool_name,
                    "branch": branch,
                    "input_keys": sorted(tool_input.keys()) if isinstance(tool_input, dict) else [],
                },
                status_code=500,
            ))
        except RuntimeError:
            tb_text = "".join(_tb.format_exception(type(exc), exc, exc.__traceback__))[:4000]
            log.error("Subagent tool crash (no loop): %s\n%s", tool_name, tb_text)
        except Exception:
            log.debug("ErrorLogger scheduling failed", exc_info=True)

    activity = getattr(orch, "_activity", None)
    if activity is not None and hasattr(activity, "log"):
        try:
            activity.log(
                "tool", "crashed",
                f"Subagent {agent_name} tool {tool_name} crashed: "
                f"{type(exc).__name__}: {str(exc)[:160]}",
                {
                    "agent": agent_name,
                    "tool": tool_name,
                    "branch": branch,
                    "error_type": type(exc).__name__,
                    "input_keys": sorted(tool_input.keys()) if isinstance(tool_input, dict) else [],
                },
                importance="high",
            )
        except Exception:
            log.debug("activity log failed for orchestrator tool crash", exc_info=True)


def _load_orch_recovery_rules(orch: object) -> dict:
    """Read circuit-breaker thresholds from the chat-recovery skill.

    Subagent loops use the same thresholds as the agent loops (one
    YAML file, one source of truth). Returns the parsed YAML dict or
    an empty dict — caller is expected to apply hardcoded defaults.
    Mtime-gated cache is on the loader instance for symmetry; per-call
    file reads are cheap (~10 us in cache-hit case).
    """
    import yaml

    skill_loader = getattr(orch, "_skill_loader", None)
    if skill_loader is None:
        return {}
    try:
        skills_dir = skill_loader._skills_dir
    except AttributeError:
        return {}
    config_path = skills_dir / "_meta" / "chat-recovery" / "config" / "recovery.yaml"
    if not config_path.is_file():
        return {}
    try:
        with open(config_path) as fh:
            loaded = yaml.safe_load(fh) or {}
        return loaded if isinstance(loaded, dict) else {}
    except Exception:
        return {}


def _last_user_text(messages: list[Message]) -> str:
    """Return the content of the last user message, empty string if none.

    Used by the orchestrator's namespace-narrowing pass to select tool
    filters based on the current turn's intent. Falls back to empty
    string on any shape mismatch so the narrowing degrades to "don't
    narrow" rather than crashing dispatch.
    """
    if not messages:
        return ""
    for msg in reversed(messages):
        role = getattr(msg, "role", None) if not isinstance(msg, dict) else msg.get("role")
        if role != "user":
            continue
        content = getattr(msg, "content", None) if not isinstance(msg, dict) else msg.get("content")
        if isinstance(content, str):
            return content
        # Multimodal content can be a list of blocks; extract text parts.
        if isinstance(content, list):
            parts = []
            for block in content:
                if isinstance(block, dict) and block.get("type") == "text":
                    parts.append(str(block.get("text", "")))
            return " ".join(parts)
        return ""
    return ""


@dataclass
class SubagentDef:
    """Parsed subagent definition from vault/system/agents/*.md."""

    name: str
    role: str
    personality: str
    capabilities: str
    boundaries: str
    skills: list[str] = field(default_factory=list)
    access: str = "read-only"
    status: str = "active"
    allowed_tools: list[str] = field(default_factory=list)  # explicit override (empty = auto-infer)
    agent_type: str = "specialist"  # "specialist" or "supervisor"
    team: list[str] = field(default_factory=list)  # agent keys this supervisor manages
    workflows: list[str] = field(default_factory=list)  # workflow IDs this supervisor runs
    model_tier: str = "capable"  # "reasoning", "capable", or "fast"


# ── Tool permission tiers ──
# Auto-inferred from access level. Each tier includes all tools from lower tiers.

_READ_TOOLS = frozenset({  # arch:deterministic — tool-registry classification (which built-in tools are structurally read-only); derived from the tool definitions themselves, not a user-tunable policy
    "search_vault", "read_vault_file", "list_tasks", "list_goals",
    "list_projects", "recall_memory", "query_memory_graph", "query_knowledge",
    "list_subagents",
    "read_inbox", "read_email", "get_inbox_count", "research_kb",
})

_DRAFT_TOOLS = _READ_TOOLS | frozenset({
    "save_draft", "journal_write", "create_task", "create_project",
    "add_project_milestone", "log_project_progress", "update_project",
})

_WRITE_TOOLS = _DRAFT_TOOLS | frozenset({
    "send_email", "correct_memory", "delegate_to_subagent",
})

ACCESS_TOOL_TIERS: dict[str, frozenset[str]] = {
    "read-only": _READ_TOOLS,
    "read-write-drafts": _DRAFT_TOOLS,
    "read-write": _WRITE_TOOLS,
}

# Skills that grant additional tools beyond the access tier.
# If a subagent has the skill, it gets these tools even if the tier
# wouldn't normally include them.
SKILL_TOOL_GRANTS: dict[str, list[str]] = {
    "email-drafter": ["save_draft", "read_inbox", "read_email"],
    "triage": ["read_inbox", "read_email", "get_inbox_count"],
    "report-generator": ["search_vault", "read_vault_file", "research_kb"],
    "vault-manager": ["search_vault", "read_vault_file", "correct_memory"],
    "daily-briefing": ["read_inbox", "get_inbox_count", "list_tasks", "list_goals", "list_projects"],
    "knowledge-taxonomy": ["research_kb", "search_vault"],
    "meeting-prep": ["read_inbox", "read_email", "list_tasks", "search_vault"],
    "review": ["read_vault_file", "search_vault"],
    "ontology": ["update_ontology", "query_knowledge", "query_memory_graph"],
    "epistemic-audit": ["query_knowledge", "query_memory_graph", "search_vault"],
    "conceptual-analysis": ["query_knowledge", "query_memory_graph", "search_vault"],
    "presentation": ["create_presentation", "ingest_output", "search_vault", "query_knowledge"],
    # Gap 14 fix — specialists carrying this skill get save_artifact so
    # delegated-task output persists across dispatches. Skill is also
    # declared in the skill's SKILL.md `tools_granted` as the canonical
    # source; this entry is the back-compat / fallback path.
    "delegated-artifact": ["save_artifact"],
}


@dataclass
class SubagentResult:
    """Result from a subagent dispatch."""

    agent_name: str
    content: str
    input_tokens: int = 0
    output_tokens: int = 0


class Orchestrator:
    """Loads subagent definitions and dispatches tasks to them."""

    def __init__(
        self,
        vault: VaultReader,
        llm: LLMProvider,
        skill_loader: SkillLoader | None = None,
        contract: ContractEnforcer | None = None,
        db: aiosqlite.Connection | None = None,
    ) -> None:
        self._vault = vault
        self._llm = llm
        self._skill_loader = skill_loader
        self._contract = contract
        self._db = db
        self._agents: dict[str, SubagentDef] = {}
        self._active_dispatches: dict[str, str] = {}  # agent_key → task
        self._dispatch_semaphores: dict[str, asyncio.Semaphore] = {}  # per-agent concurrency gate
        self._active_tasks: dict[str, str] = {}  # background activity: agent_key → task
        self._registry: object | None = None  # injected post-hoc for tool access
        self._model_router: object | None = None  # injected post-hoc for tier resolution
        self._tool_executor: object | None = None  # injected post-hoc for tool execution
        self._heartbeat: object | None = None  # injected post-hoc for pulse recording
        self._writer: object | None = None  # injected post-hoc — VaultWriter for trusted_scope

    def load_agents(self) -> dict[str, SubagentDef]:
        """Load all subagent definitions from vault/system/agents/."""
        self._agents.clear()
        files = self._vault.list_files("system/agents", "*.md")

        for f in files:
            try:
                doc = self._vault.read(f"system/agents/{f.name}")
                meta = doc.metadata
                self._agents[f.stem] = SubagentDef(
                    name=meta.get("title", f.stem),
                    role=meta.get("role", ""),
                    personality=self._extract_section(doc.content, "Personality"),
                    capabilities=self._extract_section(doc.content, "Capabilities")
                        or self._extract_section(doc.content, "Domain Expertise"),
                    boundaries=self._extract_section(doc.content, "Boundaries"),
                    skills=meta.get("skills", []),
                    access=meta.get("access", "read-only"),
                    status=meta.get("status", "active"),
                    allowed_tools=meta.get("allowed_tools", []),
                    agent_type=meta.get("type", "specialist"),
                    team=meta.get("team", []),
                    workflows=meta.get("workflows", []),
                    model_tier=meta.get("model_tier", "capable"),
                )
            except Exception:
                log.warning("Failed to load agent: %s", f.name)

        log.debug("Loaded %d subagent definitions", len(self._agents))
        return self._agents

    @property
    def agents(self) -> dict[str, SubagentDef]:
        if not self._agents:
            self.load_agents()
        return self._agents

    def list_agents(self) -> list[dict]:
        """List available subagents for API/UI."""
        return [
            {
                "key": key,
                "name": a.name,
                "role": a.role,
                "status": a.status,
                "skills": a.skills,
                "access": a.access,
                "effective_tools": sorted(self.get_allowed_tools(key)),
                "type": a.agent_type,
                "team": a.team,
                "workflows": a.workflows,
            }
            for key, a in self.agents.items()
        ]

    def get_allowed_tools(self, agent_key: str) -> set[str]:
        """Compute the effective tool set for a subagent.

        Resolution order:
        1. If allowed_tools is explicitly set → use that (power-user override)
        2. Otherwise → access tier tools + skill-granted tools
        3. Intersect with contract-allowed tools (global ceiling)
        """
        agent_def = self.agents.get(agent_key)
        if not agent_def:
            return set()

        if agent_def.allowed_tools:
            # Explicit override
            tools = set(agent_def.allowed_tools)
        else:
            # Auto-infer from access tier
            tools = set(ACCESS_TOOL_TIERS.get(agent_def.access, _READ_TOOLS))

            # Add skill-granted tools (prefer skill's own tools_granted, fallback to hardcoded map)
            for skill_name in agent_def.skills:
                if self._skill_loader:
                    skill = self._skill_loader.get_skill(skill_name)
                    if skill and skill.tools_granted:
                        tools.update(skill.tools_granted)
                        continue
                grants = SKILL_TOOL_GRANTS.get(skill_name, [])
                tools.update(grants)

        # Add external MCP tools for read-write agents
        # (MCP tools are opaque — we can't know if they read or write,
        # so only full-access agents get them unless explicitly listed)
        if agent_def.access == "read-write" and self._registry and self._registry._mcp_client:
            for ext in self._registry._mcp_client.get_external_tool_definitions():
                tools.add(ext["name"])

        # Contract ceiling: remove any tool whose contract action is blocked
        if self._contract:
            from agntspace.agent.tools import TOOL_CONTRACT_MAP
            blocked = set()
            for tool_name in tools:
                action = TOOL_CONTRACT_MAP.get(tool_name)
                if action:
                    result = self._contract.check(action)
                    if not result.allowed:
                        blocked.add(tool_name)
            tools -= blocked

        return tools

    def _get_semaphore(self, agent_key: str) -> asyncio.Semaphore:
        """Get or create a per-agent semaphore (max 1 concurrent dispatch)."""
        if agent_key not in self._dispatch_semaphores:
            self._dispatch_semaphores[agent_key] = asyncio.Semaphore(1)
        return self._dispatch_semaphores[agent_key]

    async def dispatch(
        self,
        agent_name: str,
        task: str,
        context: str = "",
        *,
        skill_scope: str | None = None,
        policy_id: str = "",
        run_id: str = "",
    ) -> SubagentResult:
        """Dispatch a task to a specific subagent.

        Contract enforcement: the main agent's contract is always checked
        before dispatch and its rules are injected into the subagent's
        system prompt. Subagents inherit at least the same restrictions.

        Args:
            skill_scope: When set, constrains the tool definitions to
                ONLY the named skill's ``tools_granted`` + a minimal
                read-only base set (vault_search, query_knowledge). This
                prevents the access-tier's full tool set from confusing
                small local LLMs — they see 3 tools instead of 18 and
                are far more likely to call the right one. Used by
                ``Agent.invoke_skill()`` to focus the librarian on
                ``run_wiki_job`` instead of ``read_email``.
            policy_id: Forward-compat — the evolution layer passes the
                selected policy ID (Phase 3 activates real selection).
                Phase 1: received but unused; the run_id/policy_id
                contextvars set by ``Agent.invoke_skill`` already
                propagate to every decision record inside this dispatch.
            run_id: Forward-compat — evolution run identifier. Same
                propagation story as policy_id.
        """
        # Phase 1 note: policy_id + run_id come through the contextvars
        # already, so these kwargs are accepted for future tool-subset
        # narrowing (Phase 3) without requiring a signature change.
        _ = policy_id, run_id
        agent_def = self.agents.get(agent_name)
        if not agent_def:
            raise ValueError(f"Unknown subagent: {agent_name}")

        if agent_def.status != "active":
            raise ValueError(f"Subagent '{agent_name}' is not active")

        # Contract gate: check before any subagent runs
        if self._contract:
            result = self._contract.check("delegate_task")
            if not result.allowed:
                raise ValueError(f"Contract blocked delegation: {result.reason}")

        # Per-agent semaphore: second dispatch to the same agent waits
        # instead of silently overwriting the first in _active_dispatches.
        async with self._get_semaphore(agent_name):
            # Pass task + skill_scope through so _build_subagent_prompt
            # can optionally re-order/truncate the injected skill set by
            # semantic relevance (Voyager integration, 2026-04-24). Tool
            # grants remain computed from agent_def.skills in full.
            system = self._build_subagent_prompt(
                agent_name, agent_def, context, task=task, skill_scope=skill_scope,
            )
            messages = [Message(role="user", content=task)]

            # Track active dispatch
            self._active_dispatches[agent_name] = task
            started = datetime.now(UTC).isoformat()
            t0 = time.monotonic()

            # Record dispatch start pulse
            if self._heartbeat and hasattr(self._heartbeat, "record_pulse"):
                try:
                    await self._heartbeat.record_pulse(
                        "dispatch", details=f"{agent_name}: {task[:120]}"
                    )
                except Exception:
                    pass

            try:
                response = await self._dispatch_with_tools(
                    agent_name, agent_def, messages, system,
                    skill_scope=skill_scope,
                )
            finally:
                self._active_dispatches.pop(agent_name, None)

            duration_ms = int((time.monotonic() - t0) * 1000)

            log.info(
                "Subagent '%s' completed: %d input, %d output tokens (%dms)",
                agent_name,
                response.input_tokens,
                response.output_tokens,
                duration_ms,
            )

            # Log to database
            await self._log_dispatch(
                agent_key=agent_name,
                agent_name=agent_def.name,
                task=task,
                context=context,
                content=response.content,
                input_tokens=response.input_tokens,
                output_tokens=response.output_tokens,
                started_at=started,
                duration_ms=duration_ms,
            )

            # Log to activity logger
            if hasattr(self, "_activity") and self._activity:
                try:
                    self._activity.log("dispatch", "completed",
                                       f"'{agent_name}' completed in {duration_ms}ms: {task[:80]}",
                                       {"agent": agent_name, "task": task[:200],
                                        "duration_ms": duration_ms,
                                        "tokens": response.input_tokens + response.output_tokens})
                except Exception:
                    pass

            # Log to decision logger — record WHY this dispatch happened
            decisions = getattr(self, "_decisions", None)
            if decisions is not None:
                try:
                    await decisions.record(
                        actor="main",
                        action_type="dispatch",
                        action_target=agent_name,
                        contract_action="delegate_task",
                        contract_result="allowed",
                        triggered_by="chat_tool",
                        context_skills=list(agent_def.skills or []),
                        rationale=f"Dispatched {agent_name} ({agent_def.name}): {task[:120]}",
                        details={
                            "task_preview": task[:200],
                            "duration_ms": duration_ms,
                            "input_tokens": response.input_tokens,
                            "output_tokens": response.output_tokens,
                            "agent_type": agent_def.type if hasattr(agent_def, "type") else "specialist",
                        },
                    )
                except Exception:
                    log.debug("Decision log record failed for dispatch %s", agent_name, exc_info=True)

            return SubagentResult(
                agent_name=agent_name,
                content=response.content,
                input_tokens=response.input_tokens,
                output_tokens=response.output_tokens,
            )

    async def _dispatch_with_tools(
        self,
        agent_name: str,
        agent_def: SubagentDef,
        messages: list[Message],
        system: str,
        *,
        skill_scope: str | None = None,
    ) -> object:
        """Run a subagent dispatch with tool execution if tools are available.

        If the orchestrator has a registry and tool executor, subagents can
        call tools within their permission scope. Otherwise falls back to
        plain text completion.

        When ``skill_scope`` is set, the tool list is constrained to ONLY
        the named skill's ``tools_granted`` + a minimal read-only base
        (vault_search, query_knowledge, list_subagents). This dramatically
        reduces tool confusion on small local LLMs — the librarian sees 3
        relevant tools instead of 18 irrelevant ones.
        """
        import json as _json

        # Resolve model tier from agent definition (e.g., "fast" → "claude-haiku-4-5")
        resolved_model = None
        if agent_def.model_tier and self._model_router and hasattr(self._model_router, "resolve"):
            resolved_model = self._model_router.resolve(agent_def.model_tier)

        # Get filtered tool definitions for this agent.
        # When skill_scope is set (invoke_skill path), use ONLY the
        # skill's tools_granted instead of the full access tier — this
        # is the key fix for the librarian calling read_email instead
        # of run_wiki_job on small models. The minimal read base
        # ensures the agent can still search/query during the skill run.
        tool_defs = None
        if self._registry and self._tool_executor:
            if skill_scope and self._skill_loader:
                skill = self._skill_loader.get_skill(skill_scope)
                if skill and skill.tools_granted:
                    _MINIMAL_READ = {"vault_search", "query_knowledge", "list_subagents"}
                    allowed = set(skill.tools_granted) | _MINIMAL_READ
                else:
                    allowed = self.get_allowed_tools(agent_name)
            else:
                allowed = self.get_allowed_tools(agent_name)
            if allowed:
                all_tools = self._registry.get_tool_definitions()
                tool_defs = [t for t in all_tools if t["name"] in allowed]

                # Namespace-based narrowing — second filter AFTER access tier
                # and skill_scope. Reduces small-model tool confusion on turns
                # where the agent doesn't need its full access tier. Runs only
                # when (a) skill_scope is NOT set (skill scope is already
                # tighter), (b) a namespace resolver is available, (c) the
                # allowed set is still broad enough to benefit (>= 6 tools).
                resolver = getattr(self, "_namespace_resolver", None)
                if resolver is not None and not skill_scope and tool_defs and len(tool_defs) >= 6:
                    turn_text = _last_user_text(messages)
                    if turn_text:
                        relevant = resolver.select_relevant(turn_text)
                        allowed_names = [t["name"] for t in tool_defs]
                        narrowed_names = set(resolver.filter_tool_names(allowed_names, relevant))
                        if narrowed_names and len(narrowed_names) < len(tool_defs):
                            tool_defs = [t for t in tool_defs if t["name"] in narrowed_names]
                            log.debug(
                                "Namespace-narrowed %s: %d → %d tools (relevant=%s)",
                                agent_name, len(allowed_names), len(tool_defs),
                                sorted(relevant),
                            )

        if tool_defs and self._tool_executor:
            # Tool-enabled dispatch — same loop pattern as Agent.process_task()
            # Wrap the entire tool loop in trusted_scope so every vault
            # write from tool handlers passes the runtime guard. Without
            # this, tools like update_ontology, correct_memory,
            # journal_write — which call writer.write() without
            # contract_action — raise ContractBypassError when called
            # inside a correlation scope (invoke_skill, channel webhook).
            from contextlib import ExitStack
            _exit_stack = ExitStack()
            _writer = getattr(self, "_writer", None)
            if _writer and hasattr(_writer, "trusted_scope"):
                _exit_stack.enter_context(_writer.trusted_scope("subagent_tool"))

            valid_tools = {t["name"] for t in tool_defs}
            max_rounds = 5

            # Fix 4 + 5 + 1 — outcome tracking + circuit breaker + exhaustion
            # signal for the subagent loop. Mirrors Agent.chat_stream / chat /
            # process_task. We do NOT install a mutation_scope here because
            # invoke_skill installs one outside this call; the subagent's
            # tool calls flow through note_tool_call() which feeds whatever
            # scope is active. If no scope is active (direct dispatch from
            # chat tool / workflow), the counters are silently no-ops — the
            # counters live on the parent if there is one.
            _orch_tools_attempted: list[str] = []
            _orch_round_error_counts: list[int] = []
            _orch_round_total_counts: list[int] = []
            _orch_round_errors = 0
            _orch_round_total = 0
            _orch_cb = _load_orch_recovery_rules(self).get("circuit_breaker") or {
                "min_rounds_before_break": 2,
                "error_rate_threshold": 0.5,
                "window_size_rounds": 2,
            }

            with _exit_stack:
              for _ in range(max_rounds):
                response = await self._llm.complete_with_tools(
                    messages=messages,
                    tools=tool_defs,
                    system=system,
                    max_tokens=4096,
                    model=resolved_model,
                )

                if response.tool_calls:
                    # Validate tool names
                    invalid = [tc for tc in response.tool_calls if tc.name not in valid_tools]
                    if invalid:
                        log.warning(
                            "Subagent '%s' tried invalid tools: %s",
                            agent_name, ", ".join(tc.name for tc in invalid),
                        )
                        break  # fall through to return what we have

                    messages.append(Message(
                        role="assistant",
                        content=response.raw_content or response.content,
                    ))

                    for tc in response.tool_calls:
                        log.info("Subagent '%s' tool: %s", agent_name, tc.name)
                        _orch_tools_attempted.append(tc.name)  # Fix 4
                        # Initialise once per tool — contract_action is only
                        # populated below when self._contract is truthy. The
                        # decision-record block at the bottom of the loop
                        # references this even when no contract is wired
                        # (tests, embedded use), so default to "".
                        contract_action = ""

                        # Per-tool contract enforcement
                        if self._contract:
                            from agntspace.agent.tools import TOOL_CONTRACT_MAP
                            contract_action = TOOL_CONTRACT_MAP.get(tc.name)
                            if not contract_action and self._registry:
                                tool_reg = self._registry._tools.get(tc.name)
                                if tool_reg:
                                    contract_action = getattr(tool_reg, "contract_action", None)
                            if contract_action:
                                check = self._contract.check(contract_action)
                                if not check.allowed:
                                    log.warning("Contract blocked subagent '%s' tool '%s': %s",
                                                agent_name, tc.name, check.reason)
                                    messages.append(Message(
                                        role="tool",
                                        content=f'{{"error": "Contract blocked: {check.reason}"}}',
                                        tool_call_id=tc.id,
                                    ))
                                    # Decision log — record the block with full context
                                    decisions = getattr(self, "_decisions", None)
                                    if decisions is not None:
                                        try:
                                            await decisions.record(
                                                actor=agent_name,
                                                action_type="tool",
                                                action_target=tc.name,
                                                contract_action=contract_action,
                                                contract_result="blocked",
                                                triggered_by="subagent",
                                                context_skills=list(agent_def.skills or []),
                                                rationale=f"Contract blocked: {check.reason}",
                                            )
                                        except Exception:
                                            pass
                                    continue

                        # ── trust pre-check (subagent = always autonomous) ──
                        _trust_svc = getattr(self, "_trust_service", None)
                        _orch_trust_result = None
                        if _trust_svc and contract_action:
                            _proactivity = (
                                self._contract.rules.proactivity
                                if self._contract else "advisor"
                            )
                            _orch_trust_result = _trust_svc.check_tool_trust(
                                contract_action, _proactivity, is_autonomous=True,
                                agent_key=agent_name,
                            )
                            if not _orch_trust_result.action_allowed:
                                log.warning(
                                    "Trust blocked subagent '%s' tool '%s': %s",
                                    agent_name, tc.name, _orch_trust_result.reason,
                                )
                                messages.append(Message(
                                    role="tool",
                                    content=f'{{"error": "Trust blocked: {_orch_trust_result.reason}"}}',
                                    tool_call_id=tc.id,
                                ))
                                decisions = getattr(self, "_decisions", None)
                                if decisions is not None:
                                    try:
                                        await decisions.record(
                                            actor=agent_name,
                                            action_type="tool",
                                            action_target=tc.name,
                                            contract_action=contract_action,
                                            contract_result="trust_blocked",
                                            triggered_by="subagent",
                                            context_skills=list(agent_def.skills or []),
                                            rationale=f"Trust blocked: {_orch_trust_result.reason}",
                                            trust_level=_orch_trust_result.effective_level,
                                            trust_domain=_orch_trust_result.domain,
                                        )
                                    except Exception:
                                        pass
                                continue

                        # Fix 2 + 6: time the dispatch + prefer async_handler.
                        # The previous "ToolExecutor first, fall back to
                        # registry on Unknown tool" was racy — the executor
                        # response is checked by string-match on "Unknown tool:".
                        # Now we resolve the same way Agent._dispatch_tool
                        # does: async_handler > sync handler in copy_context
                        # > ToolExecutor fallback. Identical semantics for
                        # cloud and local.
                        import time as _orch_time
                        _tool_started = _orch_time.perf_counter()

                        # Route MCP tools to the async MCP client
                        if tc.name.startswith("mcp_") and self._registry and self._registry._mcp_client:
                            try:
                                result = await self._registry._mcp_client.call_tool(tc.name, tc.input)
                            except Exception as e:
                                _orch_log_tool_crash(self, agent_name, tc.name, tc.input, e, "mcp")
                                result = {"error": f"MCP tool failed: {e}"}
                        else:
                            # Resolve via registry first (async_handler preferred).
                            # ToolExecutor.execute is sync — call directly without
                            # thread pool because we're in the orchestrator's own
                            # event loop (tools execute fast in subagent context).
                            tool_def = (
                                self._registry._tools.get(tc.name)
                                if self._registry and hasattr(self._registry, "_tools") else None
                            )
                            if tool_def is not None and tool_def.async_handler is not None:
                                try:
                                    result = await tool_def.async_handler(tc.input)
                                except Exception as e:
                                    _orch_log_tool_crash(self, agent_name, tc.name, tc.input, e, "async_handler")
                                    result = {"error": str(e)}
                            elif tool_def is not None and tool_def.handler is not None:
                                try:
                                    result = tool_def.handler(tc.input)
                                except Exception as e:
                                    _orch_log_tool_crash(self, agent_name, tc.name, tc.input, e, "sync_handler")
                                    result = {"error": str(e)}
                            else:
                                try:
                                    result = self._tool_executor.execute(tc.name, tc.input)
                                except Exception as e:
                                    _orch_log_tool_crash(self, agent_name, tc.name, tc.input, e, "tool_executor")
                                    result = {"error": str(e)}

                        _tool_duration_ms = int((_orch_time.perf_counter() - _tool_started) * 1000)

                        messages.append(Message(
                            role="tool",
                            content=_json.dumps(result),
                            tool_call_id=tc.id,
                        ))

                        # Track outcome for circuit breaker + zero-effect
                        _orch_round_total += 1
                        if isinstance(result, dict) and "error" in result:
                            _orch_round_errors += 1
                        if isinstance(result, dict) and not result.get("error"):
                            from agntspace.activity.decisions import note_tool_call
                            try:
                                note_tool_call()
                            except Exception:
                                pass

                        # Decision log — record subagent tool call WITH outcome (Fix 2)
                        decisions = getattr(self, "_decisions", None)
                        if decisions is not None:
                            try:
                                from agntspace.activity.decisions import classify_tool_result
                                _r_status, _r_err = classify_tool_result(result)
                                _r_details = {
                                    "input_keys": sorted(tc.input.keys()) if isinstance(tc.input, dict) else [],
                                }
                                if isinstance(result, dict):
                                    _r_details["result_keys"] = sorted(result.keys())[:12]
                                    if "error" in result and isinstance(result["error"], str):
                                        _r_details["error_preview"] = result["error"][:240]
                                if _r_status == "ok":
                                    _r_rationale = f"Subagent {agent_name} invoked {tc.name}"
                                else:
                                    _r_rationale = f"Subagent {agent_name} {tc.name} {_r_status} ({_r_err})"
                                await decisions.record(
                                    actor=agent_name,
                                    action_type="tool",
                                    action_target=tc.name,
                                    contract_action=contract_action or "",
                                    contract_result="allowed",
                                    triggered_by="subagent",
                                    context_skills=list(agent_def.skills or []),
                                    rationale=_r_rationale,
                                    details=_r_details,
                                    result_status=_r_status,
                                    duration_ms=_tool_duration_ms,
                                    error_type=_r_err,
                                )
                            except Exception:
                                log.debug("Subagent decision record failed for %s", tc.name, exc_info=True)

                    _orch_round_error_counts.append(_orch_round_errors)
                    _orch_round_total_counts.append(_orch_round_total)
                    _orch_round_errors = 0
                    _orch_round_total = 0

                    # Fix 5: error-fatigue circuit breaker for subagent loop.
                    # Same threshold semantics as agent.chat() — break out
                    # when error rate over the sliding window exceeds threshold.
                    # Subagent doesn't run a "recovery summary" via LLM (the
                    # caller — workflow / chat tool / invoke_skill — owns
                    # recovery semantics), but we DO break the loop and
                    # record the trip so the caller sees it.
                    if _orch_cb is not None:
                        _min_rounds = int(_orch_cb.get("min_rounds_before_break", 2))
                        _threshold = float(_orch_cb.get("error_rate_threshold", 0.5))
                        _window = int(_orch_cb.get("window_size_rounds", 2))
                        if (
                            len(_orch_round_error_counts) >= _min_rounds
                            and _threshold > 0.0
                        ):
                            _win_e = sum(_orch_round_error_counts[-_window:])
                            _win_t = sum(_orch_round_total_counts[-_window:])
                            if _win_t > 0 and (_win_e / _win_t) >= _threshold:
                                log.warning(
                                    "Subagent '%s' circuit breaker fired: %.2f error rate (%d/%d)",
                                    agent_name, _win_e / _win_t, _win_e, _win_t,
                                )
                                _activity = getattr(self, "_activity", None)
                                if _activity is not None and hasattr(_activity, "log"):
                                    try:
                                        _activity.log(
                                            "llm", "subagent_circuit_broken",
                                            (
                                                f"Subagent {agent_name} circuit broke: "
                                                f"{_win_e}/{_win_t} tool errors in window"
                                            ),
                                            {
                                                "agent": agent_name,
                                                "tools_attempted": list(_orch_tools_attempted),
                                                "error_rate": round(_win_e / _win_t, 3),
                                            },
                                            importance="high",
                                        )
                                    except Exception:
                                        pass
                                # Return the last response with raw_content
                                # set to the circuit-broken signal so the
                                # caller can detect it explicitly.
                                response.content = (
                                    response.content
                                    or f"⚠ Subagent {agent_name} aborted after {_win_e} consecutive tool errors."
                                )
                                return response
                    continue

                # No tool calls — final response
                return response

            # Fix 1: exhausted rounds — emit a high-importance event so the
            # caller can see the subagent never settled. We still return the
            # last response (caller decides what to do with it) but the
            # event ensures it's not silent.
            if _orch_tools_attempted:
                _activity = getattr(self, "_activity", None)
                if _activity is not None and hasattr(_activity, "log"):
                    try:
                        _activity.log(
                            "llm", "subagent_round_exhausted",
                            (
                                f"Subagent {agent_name} exhausted {max_rounds} rounds "
                                f"with {len(_orch_tools_attempted)} tool calls — "
                                f"never produced a 'no tool calls' response"
                            ),
                            {
                                "agent": agent_name,
                                "tools_attempted": list(_orch_tools_attempted),
                            },
                            importance="high",
                        )
                    except Exception:
                        pass
            return response

        # Fallback: plain text completion (no tools available)
        return await self._llm.complete(
            messages=messages,
            system=system,
            max_tokens=4096,
            model=resolved_model,
        )

    async def dispatch_parallel(
        self,
        dispatches: list[tuple[str, str]],
        context: str = "",
    ) -> list[SubagentResult]:
        """Dispatch tasks to multiple subagents concurrently.

        Args:
            dispatches: list of (agent_name, task) tuples.
            context: shared context for all subagents.
        """
        import asyncio

        tasks = [
            self.dispatch(agent_name, task, context)
            for agent_name, task in dispatches
        ]
        return list(await asyncio.gather(*tasks))

    def select_agent(self, task_description: str) -> str | None:
        """Select the best subagent for a task based on role matching."""
        lower = task_description.lower()
        scores: dict[str, int] = {}

        for key, agent_def in self.agents.items():
            if agent_def.status != "active":
                continue
            score = 0
            role_words = agent_def.role.lower().split()
            for word in role_words:
                if word in lower:
                    score += 2
            cap_words = agent_def.capabilities.lower().split()
            for word in cap_words:
                if len(word) > 3 and word in lower:
                    score += 1
            if score > 0:
                scores[key] = score

        if not scores:
            return None
        return max(scores, key=lambda k: scores[k])

    def get_active_dispatches(self) -> dict[str, str]:
        """Get all currently active work: dispatches + background tasks."""
        merged = dict(self._active_tasks)
        merged.update(self._active_dispatches)  # dispatches take priority
        return merged

    @contextlib.contextmanager
    def track_activity(self, agent_key: str, task: str):
        """Context manager to track background activity (non-dispatch work).

        Makes background tasks (daily cycle, channel messages, etc.)
        visible in the constellation and workflow views.
        """
        self._active_tasks[agent_key] = task
        try:
            yield
        finally:
            self._active_tasks.pop(agent_key, None)

    @contextlib.asynccontextmanager
    async def track_activity_async(self, agent_key: str, task: str):
        """Async context manager to track background activity."""
        self._active_tasks[agent_key] = task
        # Record background activity as a pulse
        if self._heartbeat and hasattr(self._heartbeat, "record_pulse"):
            try:
                await self._heartbeat.record_pulse(
                    "dispatch", details=f"{agent_key}: {task[:120]}"
                )
            except Exception:
                pass
        try:
            yield
        finally:
            self._active_tasks.pop(agent_key, None)

    async def log_background_task(
        self,
        agent_key: str,
        task: str,
        context: str = "",
        content: str = "",
        input_tokens: int = 0,
        output_tokens: int = 0,
        started_at: str = "",
        duration_ms: int = 0,
    ) -> None:
        """Log a background task to dispatch_log for timeline visibility."""
        if not self._db:
            return
        agent_def = self._agents.get(agent_key)
        agent_name = agent_def.name if agent_def else agent_key
        await self._log_dispatch(
            agent_key=agent_key,
            agent_name=agent_name,
            task=task,
            context=context,
            content=content[:500] if content else "",
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            started_at=started_at or datetime.now(UTC).isoformat(),
            duration_ms=duration_ms,
        )

    async def get_dispatch_log(
        self, limit: int = 50, agent_key: str | None = None,
        goal_id: str | None = None,
    ) -> list[dict]:
        """Get recent dispatch history, optionally filtered by agent or goal."""
        if not self._db:
            return []
        query = "SELECT * FROM dispatch_log"
        clauses: list[str] = []
        params: list = []
        if agent_key:
            clauses.append("agent_key = ?")
            params.append(agent_key)
        if goal_id:
            clauses.append("goal_id = ?")
            params.append(goal_id)
        if clauses:
            query += " WHERE " + " AND ".join(clauses)
        query += " ORDER BY started_at DESC LIMIT ?"
        params.append(limit)
        async with self._db.execute(query, params) as cursor:
            rows = await cursor.fetchall()
        return [dict(r) for r in rows]

    async def get_agent_stats(self) -> list[dict]:
        """Get per-agent dispatch statistics."""
        if not self._db:
            return []
        query = """
            SELECT agent_key, agent_name,
                   COUNT(*) as total_dispatches,
                   SUM(input_tokens) as total_input_tokens,
                   SUM(output_tokens) as total_output_tokens,
                   AVG(duration_ms) as avg_duration_ms,
                   MAX(started_at) as last_dispatch
            FROM dispatch_log
            WHERE status = 'completed'
            GROUP BY agent_key
        """
        async with self._db.execute(query) as cursor:
            rows = await cursor.fetchall()
        return [dict(r) for r in rows]

    async def _log_dispatch(
        self,
        agent_key: str,
        agent_name: str,
        task: str,
        context: str,
        content: str,
        input_tokens: int,
        output_tokens: int,
        started_at: str,
        duration_ms: int,
    ) -> None:
        """Log a completed dispatch to the database."""
        if not self._db:
            return

        # Auto-inherit goal_id from contextvar (set by _wq_pursue_goal)
        goal_id = ""
        try:
            from agntspace.activity.decisions import current_goal_id
            goal_id = current_goal_id() or ""
        except Exception:
            pass

        try:
            await self._db.execute(
                """INSERT INTO dispatch_log
                   (agent_key, agent_name, task, context, status, content,
                    input_tokens, output_tokens, started_at, completed_at, duration_ms,
                    goal_id)
                   VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, ?, ?, ?, ?)""",
                (
                    agent_key, agent_name, task, context, content,
                    input_tokens, output_tokens, started_at,
                    datetime.now(UTC).isoformat(), duration_ms,
                    goal_id,
                ),
            )
            await self._db.commit()
        except Exception:
            log.warning("Failed to log dispatch", exc_info=True)

    def _build_subagent_prompt(
        self,
        agent_key: str,
        agent_def: SubagentDef,
        context: str,
        task: str = "",
        *,
        skill_scope: str | None = None,
    ) -> str:
        """Build a role-scoped system prompt for a subagent.

        Injects:
        1. Tool permissions (auto-inferred from access + skills)
        2. The full body of each assigned skill (optionally re-ordered
           and/or truncated by semantic relevance to ``task``, when the
           ``_meta/skill-retrieval`` skill is enabled)
        3. The contract rules — subagents inherit the main agent's contract

        Semantic skill retrieval (2026-04-24, Voyager-inspired):
        When ``task`` is non-empty AND a SkillLoader with an embedder is
        wired AND ``skill-retrieval.enabled: true`` in YAML, assigned
        skills are re-ordered by cosine similarity of their descriptors
        to ``task`` and optionally truncated to ``top_k``. Tool-grants
        are computed from ALL ``agent_def.skills`` regardless of filter
        state (see ``get_allowed_tools``) — filtering the prompt never
        filters the tool set. When ``skill_scope`` is set (invoke_skill
        path) and ``bypass_skill_scope: true`` (default), the filter is
        skipped to preserve the caller's explicit narrowing.
        """
        # Compute effective tools for this subagent
        allowed = self.get_allowed_tools(agent_key)

        parts = [
            f"# Role: {agent_def.name}",
            f"You are the {agent_def.name}, a {agent_def.role}.",
            "",
            f"## Personality\n{agent_def.personality}",
            f"## Capabilities\n{agent_def.capabilities}",
            f"## Boundaries\n{agent_def.boundaries}",
            "",
            "## Rules",
            "- All output goes to the main agent, not directly to the human.",
            "- Stay within your defined capabilities and boundaries.",
            f"- Access level: {agent_def.access}.",
        ]
        # Tool permissions — explicit about what this subagent can and cannot do
        if allowed:
            parts.extend([
                "",
                "## Permitted Tools",
                f"You may ONLY reference or request these tools: {', '.join(sorted(allowed))}",
                "Do NOT suggest or attempt any action that requires a tool not in this list.",
            ])
        else:
            parts.extend([
                "",
                "## Tool Restrictions",
                "You have NO tool access. Provide analysis, recommendations, and text only.",
            ])
        # Contract rules — subagents are bound by the same contract as the main agent
        contract_text = self._get_contract_rules_text()
        if contract_text:
            parts.extend(["", "## Contract (BINDING — inherited from main agent)", ""])
            parts.append(contract_text)
        # Inject assigned skill bodies — optionally re-ordered and/or
        # truncated by semantic relevance to ``task`` when the
        # ``_meta/skill-retrieval`` skill is enabled in its YAML.
        if agent_def.skills and self._skill_loader:
            ordered_skill_names = self._resolve_skill_injection_order(
                agent_def.skills, task, skill_scope,
            )
            skill_sections = []
            for skill_name in ordered_skill_names:
                skill = self._skill_loader.get_skill(skill_name)
                if skill and skill.body.strip():
                    skill_sections.append(
                        f"### {skill.title} ({skill.name})\n{skill.body.strip()}"
                    )
            if skill_sections:
                parts.extend(["", "## Skills", ""])
                parts.append("\n\n".join(skill_sections))
        if context:
            parts.extend(["", f"## Context\n{context}"])
        return "\n".join(parts)

    def _resolve_skill_injection_order(
        self,
        assigned_skills: list[str],
        task: str,
        skill_scope: str | None,
    ) -> list[str]:
        """Decide which assigned skills (and in what order) get injected.

        Returns a list of skill names to inject into the subagent's
        system prompt. When semantic retrieval is disabled, the task is
        empty, the embedder is missing, or ``skill_scope`` is set and
        ``bypass_skill_scope`` is on, the input list is returned
        unchanged — legacy behavior.

        Tool-grants are computed independently in ``get_allowed_tools``
        from the FULL ``agent_def.skills`` list, so this filter never
        narrows the permitted tool set. See
        ``_meta/skill-retrieval/SKILL.md`` for the full invariant list.

        Fail-open: any error or missing config falls through to the
        legacy order so a broken retrieval layer can't break dispatch.
        """
        if not assigned_skills:
            return []
        if not self._skill_loader:
            return list(assigned_skills)

        cfg = self._load_skill_retrieval_config()
        if not cfg.get("enabled", False):
            return list(assigned_skills)

        # When invoke_skill narrowed dispatch to a single skill, skip
        # semantic re-ordering unless the user explicitly disabled the
        # bypass. The caller already knows which skill matters.
        if skill_scope and cfg.get("bypass_skill_scope", True):
            return list(assigned_skills)

        if not task or not task.strip():
            return list(assigned_skills)

        top_k = int(cfg.get("top_k", 0) or 0)
        min_score = float(cfg.get("min_score", 0.0) or 0.0)

        # Ask SkillLoader for a cosine-ranked list scoped to the
        # assigned skills. top_k=0 means "return all matches" — the
        # matcher already caps at the assigned-skills length.
        try:
            effective_top_k = top_k if top_k > 0 else len(assigned_skills)
            matches = self._skill_loader.find_matching_skills(
                task_text=task,
                top_k=effective_top_k,
                scope=list(assigned_skills),
                min_score=min_score,
            )
        except Exception:
            log.debug(
                "skill-retrieval: find_matching_skills raised — falling back to legacy order",
                exc_info=True,
            )
            return list(assigned_skills)

        if not matches:
            # Fail-open: no embedder, no matches, or all skills below
            # min_score. Preserve legacy behavior.
            return list(assigned_skills)

        ranked = [name for name, _score in matches]

        if top_k <= 0:
            # No truncation — append any assigned skills that weren't
            # ranked (e.g. had no embedding because descriptor was empty)
            # at the end so nothing disappears from the prompt.
            seen = set(ranked)
            tail = [s for s in assigned_skills if s not in seen]
            return ranked + tail

        # Truncation mode: return only the top_k ranked names.
        return ranked[:top_k]

    def _load_skill_retrieval_config(self) -> dict:
        """Load ``_meta/skill-retrieval`` config. Returns {} on any error."""
        if not self._skill_loader:
            return {}
        try:
            cfg = self._skill_loader.get_skill_config("skill-retrieval", "retrieval")
        except Exception:
            return {}
        return cfg if isinstance(cfg, dict) else {}

    def _get_contract_rules_text(self) -> str:
        """Build a human-readable summary of contract rules for subagent prompts."""
        if not self._contract:
            return ""
        rules = self._contract.rules
        lines = [
            f"Default policy: {rules.default}-deny (actions not listed below are forbidden).",
            "",
        ]
        if rules.block:
            lines.append("**Blocked actions** (NEVER do these):")
            for a in sorted(rules.block):
                lines.append(f"- {a}")
            lines.append("")
        if rules.confirm:
            lines.append("**Requires user confirmation** (do NOT proceed without approval):")
            for a in sorted(rules.confirm):
                lines.append(f"- {a}")
            lines.append("")
        if rules.allow:
            lines.append("**Allowed actions:**")
            for a in sorted(rules.allow):
                lines.append(f"- {a}")
            lines.append("")
        if rules.gated_dirs:
            lines.append("**Gated directories** (require confirmation before access):")
            for d in rules.gated_dirs:
                lines.append(f"- {d}/")
            lines.append("")
        lines.append(f"Proactivity level: {rules.proactivity}")
        lines.append(f"Privacy mode: {rules.privacy_mode}")
        return "\n".join(lines)

    @staticmethod
    def _extract_section(content: str, heading: str) -> str:
        """Extract content under a markdown heading."""
        import re

        pattern = rf"## {re.escape(heading)}\s*\n(.*?)(?=\n## |\Z)"
        match = re.search(pattern, content, re.DOTALL)
        return match.group(1).strip() if match else ""
