"""Workflow Supervisor — adaptive multi-agent workflow execution.

The supervisor sits between workflow definitions and the orchestrator.
It decides what to do next based on results, not a hardcoded sequence.

Architecture:
    Workflow definition (declarative steps)
      → Supervisor (LLM-powered coordinator)
        → Orchestrator (execution engine: dispatch, tools, contract)

The supervisor can:
- Execute steps sequentially or in parallel
- Loop back when quality is insufficient
- Skip steps that aren't needed
- Abort early on blocking issues
- Dynamically insert steps based on results
"""

from __future__ import annotations

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

from agntspace.llm.base import Message

if TYPE_CHECKING:
    from agntspace.agent.orchestrator import Orchestrator
    from agntspace.llm.base import LLMProvider
    from agntspace.skills.loader import SkillLoader

log = logging.getLogger(__name__)

# Maximum supervisor iterations to prevent runaway loops
MAX_ITERATIONS = 15

# Per-step timeout (seconds). Prevents a stuck agent (hung LLM call,
# slow scrape, etc.) from blocking the whole workflow indefinitely.
# Surfaced as a ``[TIMEOUT]`` step result so the supervisor can loop
# with feedback or abort — same surface as any other step error.
# Deliberately generous (240s = 4min) so legitimate long work
# (multi-URL scrapes, reasoning-tier LLM calls) can complete.
STEP_TIMEOUT_SECONDS = 240.0

# Supervisor decision LLM timeout (seconds). The _decide() call chooses
# the next action (dispatch / loop / skip / done / abort) via an LLM
# request — if that call hangs, the whole workflow freezes silently
# (no dispatch fires, no timeout, no activity event). Short ceiling
# because _decide prompts are small (~300 output tokens) and always use
# the reasoning tier; a call that runs >60s is stuck, not slow.
# On timeout we abort the workflow with a clear reason.
DECIDE_TIMEOUT_SECONDS = 60.0


@dataclass
class CritiqueSpec:
    """Declarative critique configuration on a workflow step.

    When present, after the primary specialist produces output, the
    supervisor dispatches ``agent`` with the ``skill`` (defaults to
    ``critique-protocol``) to rate the output. If the parsed score is
    below ``threshold``, the primary is dispatched again with the
    critique feedback — up to ``max_revisions`` times. The rubric
    override is appended to the default rubric from the skill's YAML.

    This is opt-in per step: workflows without a ``critique`` block on
    a step behave exactly as before (no new dispatches, no regressions).
    Step-level defaults for threshold/max_revisions are filled in from
    ``critique-protocol/config/critique.yaml`` at resolution time.
    """

    agent: str                                      # agent_key of the critic
    skill: str = "critique-protocol"                # skill the critic runs
    threshold: float | None = None                  # override for YAML default
    max_revisions: int | None = None                # override for YAML default
    rubric: list[str] = field(default_factory=list)  # extra dimensions beyond default


@dataclass
class WorkflowStep:
    """A step in a workflow definition."""

    id: str
    agent_key: str
    label: str
    description: str
    skills: list[str] = field(default_factory=list)
    tools: list[str] = field(default_factory=list)
    critique: CritiqueSpec | None = None  # optional review loop (opt-in per step)


@dataclass
class WorkflowDef:
    """Declarative workflow definition."""

    id: str
    name: str
    description: str
    supervisor: str = ""  # agent key of the supervisor for this workflow
    steps: list[WorkflowStep] = field(default_factory=list)


@dataclass
class StepResult:
    """Result from executing a single workflow step."""

    step_id: str
    agent_key: str
    content: str
    input_tokens: int = 0
    output_tokens: int = 0
    duration_ms: int = 0
    iteration: int = 1  # which attempt (1 = first, 2+ = feedback loop)
    # Critique-loop fields: populated when the step has a CritiqueSpec
    # and a review has been run against this output. None means either
    # the step has no critique configured, or the critique dispatch
    # itself failed (falls open — primary output is accepted).
    critique_score: float | None = None
    critique_feedback: str = ""
    critique_agent: str = ""
    critique_passed: bool | None = None  # True = above threshold, False = revision requested


@dataclass
class CritiqueResult:
    """Parsed output of one critique dispatch."""

    score: float
    justification: str
    concerns: list[str] = field(default_factory=list)
    raw_content: str = ""
    critic_agent: str = ""
    parse_ok: bool = True  # False when score couldn't be parsed (fails open)


@dataclass
class SupervisorDecision:
    """Parsed decision from the supervisor LLM."""

    action: str  # "dispatch", "parallel", "loop", "skip", "done", "abort"
    steps: list[str] = field(default_factory=list)  # step IDs to dispatch
    feedback: str = ""  # feedback context for loop actions
    reason: str = ""  # why this decision


@dataclass
class WorkflowExecution:
    """Full execution record for a workflow run."""

    workflow_id: str
    started_at: str = ""
    completed_at: str = ""
    status: str = "running"  # running, completed, aborted
    step_results: list[StepResult] = field(default_factory=list)
    supervisor_log: list[dict[str, Any]] = field(default_factory=list)
    total_iterations: int = 0


_SUPERVISOR_PROMPT = """You are {supervisor_name}, a {supervisor_role}.

{supervisor_personality}

{supervisor_expertise}

## Workflow: {workflow_name}
{workflow_description}

## Your Team
{team_description}

## Available Steps
{steps_description}

## Execution History
{execution_history}

## Actions
Based on the results so far and your domain expertise, decide what to do next:

- **dispatch <step_id>** — run a specific step next
- **parallel <step_id_1> <step_id_2>** — run multiple independent steps concurrently
- **loop <step_id> "<feedback>"** — re-run a step with feedback (quality was insufficient)
- **skip <step_id> "<reason>"** — skip a step that isn't needed based on results
- **done "<summary>"** — workflow is complete
- **abort "<reason>"** — stop early due to a blocking issue

## Rules
- You are the TEAM LEAD. Decide what your team does next — don't do the work yourself.
- Use your domain expertise to judge result quality — a generic "completed" is not enough.
- Don't loop more than 2 times on the same step — if it's still failing, move on and report.
- Parallelise when steps are genuinely independent.
- Skip steps when results make them unnecessary.
- Be efficient — don't run steps that won't add value.

## Output Format
Respond with EXACTLY ONE decision line, then a brief reason:

```decision
<action> <args>
```
reason: <why you chose this>"""


class WorkflowSupervisor:
    """LLM-powered workflow coordinator.

    Reads a workflow definition, dispatches steps through the orchestrator,
    and uses an LLM to decide what to do next based on results.
    """

    def __init__(
        self,
        orchestrator: Orchestrator,
        llm: LLMProvider,
        skill_loader: SkillLoader | None = None,
    ) -> None:
        self._orchestrator = orchestrator
        self._llm = llm
        self._skill_loader = skill_loader
        # Cached critique config so we don't re-read the skill YAML on every
        # step. Loaded lazily from `critique-protocol/config/critique.yaml`.
        self._critique_config: dict[str, Any] | None = None
        # Reflexion: bounded per-(agent, step) self-correction buffer, used
        # between critique-driven revisions. Scope is per `execute()` call
        # — see `_reset_reflexion` at the top of execute(). Config lives in
        # `_meta/reflexion-protocol/config/reflexion.yaml`.
        self._reflexion_buffer: Any | None = None
        self._reflexion_config: dict[str, Any] | None = None

    async def execute(
        self,
        workflow: WorkflowDef,
        context: str = "",
        on_step: Any | None = None,  # callback(step_id, status, result)
    ) -> WorkflowExecution:
        """Execute a workflow adaptively under supervisor control.

        Opens a ``workflow-<id>-*`` correlation scope for the whole run so
        every sub-dispatch, tool call, and decision rooted in this
        workflow shares one correlation id — distinguishable in
        ``/decisions`` from HTTP-originated calls (``http-*``), heartbeat
        pursuit (``goal-pursuit-*``), and watcher ingest (``watcher-*``).

        Args:
            workflow: The workflow definition to execute.
            context: Additional context to provide to each step.
            on_step: Optional callback for real-time progress updates.

        Returns:
            WorkflowExecution with all step results and supervisor decisions.
        """
        from agntspace.activity.decisions import (
            correlation_scope,
            new_correlation_id,
        )

        # Reset per-execution reflexion buffer so reflections from a prior
        # `execute()` call never bleed into this workflow's revisions.
        self._reset_reflexion()

        with correlation_scope(new_correlation_id(f"workflow-{workflow.id}")):
            return await self._execute_inner(workflow, context, on_step)

    async def _execute_inner(
        self,
        workflow: WorkflowDef,
        context: str,
        on_step: Any | None,
    ) -> WorkflowExecution:
        execution = WorkflowExecution(
            workflow_id=workflow.id,
            started_at=datetime.now(UTC).isoformat(),
        )

        # Track which steps have been executed and how many times
        step_attempts: dict[str, int] = {s.id: 0 for s in workflow.steps}
        step_map = {s.id: s for s in workflow.steps}

        for iteration in range(MAX_ITERATIONS):
            execution.total_iterations = iteration + 1

            # Ask the supervisor what to do next
            decision = await self._decide(workflow, execution)
            execution.supervisor_log.append({
                "iteration": iteration + 1,
                "action": decision.action,
                "steps": decision.steps,
                "feedback": decision.feedback,
                "reason": decision.reason,
                "timestamp": datetime.now(UTC).isoformat(),
            })

            log.info(
                "Supervisor [%s] iter %d: %s %s — %s",
                workflow.id, iteration + 1, decision.action,
                " ".join(decision.steps), decision.reason,
            )

            if decision.action == "done":
                execution.status = "completed"
                execution.completed_at = datetime.now(UTC).isoformat()
                break

            if decision.action == "abort":
                execution.status = "aborted"
                execution.completed_at = datetime.now(UTC).isoformat()
                log.warning("Supervisor aborted workflow %s: %s", workflow.id, decision.reason)
                break

            if decision.action == "skip":
                for sid in decision.steps:
                    step_attempts[sid] = -1  # mark as skipped
                    if on_step:
                        on_step(sid, "skipped", decision.reason)
                continue

            if decision.action in ("dispatch", "loop"):
                for sid in decision.steps:
                    step = step_map.get(sid)
                    if not step:
                        log.warning("Supervisor referenced unknown step: %s", sid)
                        continue

                    step_attempts[sid] = step_attempts.get(sid, 0) + 1
                    if step_attempts[sid] > 3:
                        log.warning("Step %s exceeded max attempts, skipping", sid)
                        continue

                    if on_step:
                        on_step(sid, "running", None)

                    # Build task prompt. The workflow-level ``context``
                    # (e.g. ``goal:<slug>`` or an ad-hoc question) gets
                    # prepended so the dispatched agent actually sees the
                    # target — without this the agent would have to guess
                    # from the step description alone, and the planner
                    # would ask "what's the target?" (real bug seen in
                    # live autoresearch run 6). Revision feedback is
                    # layered on top when the supervisor chose ``loop``.
                    task_parts: list[str] = []
                    if context and context.strip():
                        task_parts.append(f"Workflow target:\n{context.strip()}")
                    task_parts.append(step.description)
                    task = "\n\n".join(task_parts)
                    if decision.action == "loop" and decision.feedback:
                        original = task
                        task = (
                            f"REVISION REQUESTED — previous output was insufficient.\n\n"
                            f"Feedback: {decision.feedback}\n\n"
                            f"Original task: {original}\n\n"
                            f"Please address the feedback and produce an improved result."
                        )

                    # Critique-driven dispatch when the step has a CritiqueSpec;
                    # otherwise single dispatch. Either way we get a list of
                    # StepResults (one when no critique/no revisions, multiple
                    # when critique forced revisions). The LAST entry is the
                    # accepted output; earlier entries are the rejected drafts
                    # kept for audit.
                    results = await self._dispatch_step_with_critique(
                        step=step,
                        task=task,
                        context=context,
                        first_attempt=step_attempts[sid],
                        execution=execution,
                    )
                    for r in results:
                        execution.step_results.append(r)

                    if on_step and results:
                        on_step(sid, "completed", results[-1].content[:200])

            elif decision.action == "parallel":
                # Dispatch multiple steps concurrently
                import asyncio

                tasks = []
                for sid in decision.steps:
                    step = step_map.get(sid)
                    if not step:
                        continue
                    step_attempts[sid] = step_attempts.get(sid, 0) + 1
                    if on_step:
                        on_step(sid, "running", None)
                    tasks.append(self._dispatch_step(step, step.description, context, step_attempts[sid]))

                if tasks:
                    results = await asyncio.gather(*tasks, return_exceptions=True)
                    for r in results:
                        if isinstance(r, StepResult):
                            execution.step_results.append(r)
                            if on_step:
                                on_step(r.step_id, "completed", r.content[:200])
                        elif isinstance(r, Exception):
                            log.exception("Parallel step failed: %s", r)
        else:
            # Hit MAX_ITERATIONS
            execution.status = "completed"
            execution.completed_at = datetime.now(UTC).isoformat()
            log.warning("Supervisor hit max iterations for workflow %s", workflow.id)

        return execution

    # ─── Critique protocol ────────────────────────────────────────────
    #
    # When a WorkflowStep has a CritiqueSpec, the supervisor dispatches
    # a critic agent after the primary's output and parses a structured
    # score. Below the threshold, the primary is re-dispatched with the
    # critique feedback — up to `max_revisions` times. All revisions
    # land in `execution.step_results` with `iteration` incrementing;
    # the last one is the accepted output. Critique dispatch events are
    # logged to `execution.supervisor_log` so the decision trail shows
    # both the work AND the review.
    #
    # The protocol is STRATEGY (rubric text, score format, thresholds)
    # living in `_meta/critique-protocol/config/critique.yaml`, loaded
    # via `_get_critique_config()`. The engine only decides WHEN to
    # fire and HOW to parse the score line — Rule 12 substrate.

    def _get_critique_config(self) -> dict[str, Any]:
        """Load the critique config from the skill YAML (cached)."""
        if self._critique_config is not None:
            return self._critique_config
        config: dict[str, Any] = {}
        if self._skill_loader is not None:
            try:
                loaded = self._skill_loader.load_skill_config_file(
                    "critique-protocol", "critique.yaml"
                )
                if isinstance(loaded, dict):
                    config = loaded
            except Exception:
                log.debug("critique config load failed; using fallback", exc_info=True)
        # arch:deterministic — fallback when critique-protocol skill YAML
        # is missing or malformed. Preserves the engine's ability to gate
        # on threshold and cap revisions without relying on the vault.
        self._critique_config = {
            "default_threshold": config.get("default_threshold", 0.70),
            "default_max_revisions": config.get("default_max_revisions", 2),
            "default_rubric": config.get("default_rubric") or [],
            "output_format": config.get("output_format") or {},
        }
        return self._critique_config

    def _resolve_threshold(self, spec: CritiqueSpec) -> float:
        """Step-level override wins; otherwise fall back to YAML default."""
        if spec.threshold is not None:
            return float(spec.threshold)
        return float(self._get_critique_config()["default_threshold"])

    def _resolve_max_revisions(self, spec: CritiqueSpec) -> int:
        if spec.max_revisions is not None:
            return max(0, int(spec.max_revisions))
        return max(0, int(self._get_critique_config()["default_max_revisions"]))

    def _build_critique_task(
        self,
        *,
        step: WorkflowStep,
        primary_agent: str,
        primary_output: str,
        original_task: str,
        rubric_override: list[str],
    ) -> str:
        """Assemble the review task for the critic.

        The critic's system prompt comes from the `critique-protocol`
        skill (loaded automatically when its skill name is in the
        agent's skills list). The task body gives the critic the
        primary's output and any step-specific rubric additions.
        """
        cfg = self._get_critique_config()
        rubric_lines: list[str] = []
        for dim in cfg.get("default_rubric") or []:
            if isinstance(dim, dict):
                name = dim.get("name", "")
                question = dim.get("question", "")
                if name and question:
                    rubric_lines.append(f"- **{name}**: {question}")
        # Filter falsy/whitespace overrides so YAML typos like
        # `rubric: ["brand-voice", "", null]` don't produce stray
        # `- ** **` / `**None**` bullets in the critic's prompt.
        for extra in rubric_override or []:
            if not extra:
                continue
            text = str(extra).strip()
            if not text:
                continue
            rubric_lines.append(f"- **{text}** (step-specific dimension)")
        rubric_block = "\n".join(rubric_lines) if rubric_lines else "- accuracy, groundedness, completeness, clarity, safety"

        # Bound the primary output so a 500KB draft doesn't blow past
        # small critics' context windows. At truncation we append a clear
        # marker so the critic knows it only saw a prefix and can rate
        # what it has rather than trying to guess the missing tail.
        _MAX_PRIMARY_CHARS = 40_000
        primary_body = primary_output or ""
        if len(primary_body) > _MAX_PRIMARY_CHARS:
            primary_body = (
                primary_body[:_MAX_PRIMARY_CHARS]
                + f"\n\n[... truncated — original output was {len(primary_output)} chars, "
                f"review based on first {_MAX_PRIMARY_CHARS} only]"
            )

        return (
            f"Review the output produced by **{primary_agent}** in response to the "
            f"task below. Score it on the rubric and list specific concerns.\n\n"
            f"## Rubric\n{rubric_block}\n\n"
            f"## Original task\n{original_task}\n\n"
            f"## Output to review\n{primary_body}\n\n"
            f"Follow the critique-protocol skill's output format exactly. "
            f"The supervisor's parser reads the first `SCORE:` line."
        )

    @staticmethod
    def _parse_critique(text: str) -> CritiqueResult:
        """Extract score, justification, and concerns from critic output.

        Tolerant parser: if `SCORE:` is missing or malformed, returns
        `parse_ok=False` with `score=1.0` so the supervisor falls OPEN —
        accepts the primary's output rather than looping forever on a
        broken review. A genuinely bad review should produce `SCORE: 0.0`;
        a missing review shouldn't block the workflow.
        """
        import re as _re

        if not text:
            return CritiqueResult(
                score=1.0, justification="Empty critique response",
                raw_content="", parse_ok=False,
            )

        score_match = _re.search(
            r"(?im)^\s*SCORE\s*:\s*(-?[0-9]*\.?[0-9]+)", text
        )
        if not score_match:
            return CritiqueResult(
                score=1.0,
                justification="Could not parse SCORE line — accepting primary output",
                raw_content=text,
                parse_ok=False,
            )
        try:
            raw_score = float(score_match.group(1))
        except ValueError:
            return CritiqueResult(
                score=1.0,
                justification="SCORE value not numeric",
                raw_content=text,
                parse_ok=False,
            )
        score = max(0.0, min(1.0, raw_score))

        # Justification — one paragraph after "JUSTIFICATION:" up to
        # the CONCERNS header or end of text.
        just_match = _re.search(
            r"(?is)JUSTIFICATION\s*:\s*(.+?)(?=\n\s*CONCERNS\s*:|\Z)", text
        )
        justification = just_match.group(1).strip() if just_match else ""

        # Concerns — bullet lines after "CONCERNS:" header.
        concerns: list[str] = []
        concerns_match = _re.search(
            r"(?is)CONCERNS\s*:\s*(.+?)\Z", text
        )
        if concerns_match:
            for line in concerns_match.group(1).splitlines():
                stripped = line.strip()
                if stripped.startswith(("-", "*")):
                    body = stripped.lstrip("-* ").strip()
                    if body:
                        concerns.append(body)

        return CritiqueResult(
            score=score,
            justification=justification,
            concerns=concerns,
            raw_content=text,
            parse_ok=True,
        )

    def _format_revision_feedback(self, critique: CritiqueResult) -> str:
        """Compose the feedback block the primary sees on a revision pass."""
        lines = [f"A reviewer scored this output {critique.score:.2f} and requested revision."]
        if critique.justification:
            lines.append(f"\nReviewer justification:\n{critique.justification}")
        if critique.concerns:
            lines.append("\nSpecific concerns to address:")
            for c in critique.concerns:
                lines.append(f"- {c}")
        return "\n".join(lines)

    # ─── Reflexion between retries (Shinn et al. 2023, re-implemented) ───

    def _reset_reflexion(self) -> None:
        """Wipe the per-execution reflexion buffer. Called at `execute()` start."""
        from agntspace.agent.reflexion import ReflectionBuffer, load_reflexion_config

        if self._reflexion_config is None:
            self._reflexion_config = load_reflexion_config(self._skill_loader)
        cfg = self._reflexion_config
        self._reflexion_buffer = ReflectionBuffer(
            max_per_step=int(cfg.get("max_reflections_per_step", 3)),
        )

    def _reflexion_enabled(self) -> bool:
        cfg = self._reflexion_config
        return bool(cfg and cfg.get("enabled", True) and self._reflexion_buffer is not None)

    async def _reflect_after_failure(
        self,
        *,
        step: WorkflowStep,
        task: str,
        result: StepResult,
        critique: CritiqueResult,
        execution: WorkflowExecution,
    ) -> None:
        """After a below-threshold critique, produce + store a reflection.

        Fail-open: any error in the reflection pipeline logs at DEBUG and
        returns silently. The caller's revision loop continues unchanged.
        Successful reflections append a ``reflexion`` entry to
        ``execution.supervisor_log`` for audit.
        """
        if not self._reflexion_enabled():
            return
        from agntspace.agent.reflexion import generate_reflection

        cfg = self._reflexion_config or {}
        try:
            reflection = await generate_reflection(
                llm=self._llm,
                config=cfg,
                task=task,
                output=result.content,
                critique=critique.justification,
                score=critique.score,
                model_router=getattr(self, "_model_router", None),
            )
        except Exception:
            log.debug("reflexion: reflect_after_failure raised", exc_info=True)
            return
        if reflection is None:
            return

        buf = self._reflexion_buffer
        if buf is not None:
            buf.append(result.agent_key, step.id, reflection)

        execution.supervisor_log.append({
            "action": "reflexion",
            "step": step.id,
            "agent": result.agent_key,
            "score_before": critique.score,
            "reflection": reflection.text,
            "timestamp": datetime.now(UTC).isoformat(),
        })

    def _reflexion_block_for(self, step: WorkflowStep, agent_key: str) -> str:
        """Return the reflections block to prepend to the next revision task,
        or empty string when disabled / no reflections."""
        if not self._reflexion_enabled():
            return ""
        from agntspace.agent.reflexion import format_reflections_block

        buf = self._reflexion_buffer
        if buf is None:
            return ""
        refs = buf.get(agent_key, step.id)
        return format_reflections_block(refs)

    async def _run_critique(
        self,
        *,
        step: WorkflowStep,
        primary_result: StepResult,
        original_task: str,
        context: str,
        execution: WorkflowExecution,
    ) -> CritiqueResult | None:
        """Dispatch the critique agent against the primary output.

        Returns None when critique can't run (no spec, error) — caller
        treats that as "fall open, accept primary". Logs the critique
        dispatch into `execution.supervisor_log`.
        """
        spec = step.critique
        if spec is None:
            return None

        if primary_result.content.startswith(("[ERROR]", "[TIMEOUT]")):
            # Primary itself failed (either raw error or per-step timeout);
            # no point dispatching a critic against an error string. Caller
            # will see `critique_passed=None` and skip revision — the
            # primary error surfaces unchanged so the supervisor can
            # loop-with-feedback or abort on it.
            return None

        task = self._build_critique_task(
            step=step,
            primary_agent=primary_result.agent_key,
            primary_output=primary_result.content,
            original_task=original_task,
            rubric_override=spec.rubric or [],
        )

        t0 = time.monotonic()
        try:
            response = await self._orchestrator.dispatch(
                agent_name=spec.agent,
                task=task,
                context=context,
            )
        except Exception as exc:
            log.exception("Critique dispatch failed: %s", exc)
            execution.supervisor_log.append({
                "action": "critique_failed",
                "step": step.id,
                "critic": spec.agent,
                "error": str(exc),
                "timestamp": datetime.now(UTC).isoformat(),
            })
            return None

        duration_ms = int((time.monotonic() - t0) * 1000)
        critique = self._parse_critique(response.content)
        critique.critic_agent = spec.agent

        execution.supervisor_log.append({
            "action": "critique",
            "step": step.id,
            "critic": spec.agent,
            "score": critique.score,
            "justification": critique.justification[:500],
            "concerns": critique.concerns,
            "parse_ok": critique.parse_ok,
            "duration_ms": duration_ms,
            "timestamp": datetime.now(UTC).isoformat(),
        })
        return critique

    async def _dispatch_step_with_critique(
        self,
        *,
        step: WorkflowStep,
        task: str,
        context: str,
        first_attempt: int,
        execution: WorkflowExecution,
    ) -> list[StepResult]:
        """Run a step dispatch plus the optional critique/revision loop.

        Contract:
            - No CritiqueSpec → single dispatch, return ``[primary_result]``.
            - With CritiqueSpec → dispatch primary, run critic; if score
              below threshold AND revisions remain, re-dispatch primary
              with critique feedback. Cap at ``max_revisions`` rounds.
            - Primary error output (``[ERROR] ...``) → skip critique and
              return the error result so the caller / supervisor can
              decide whether to loop or abort.

        Every StepResult in the returned list carries the critique fields
        (score, feedback, agent, passed) for the critique that evaluated
        it — so the audit trail shows both the work and its review. The
        last entry is the accepted output; earlier entries are rejected
        drafts preserved for inspection.

        Returned list is never empty. Critique-driven revisions do NOT
        count against the supervisor's manual 3-attempt cap — they are a
        separately bounded, workflow-declared loop.
        """
        results: list[StepResult] = []
        spec = step.critique

        # Fast path: no critique spec, single dispatch.
        if spec is None:
            result = await self._dispatch_step(step, task, context, first_attempt)
            results.append(result)
            return results

        threshold = self._resolve_threshold(spec)
        max_revisions = self._resolve_max_revisions(spec)

        current_task = task
        attempt = first_attempt

        # revision_round = 0 is the primary; 1..max_revisions are re-runs
        # following a below-threshold critique. The loop always exits with
        # at least one result in the list.
        for revision_round in range(max_revisions + 1):
            result = await self._dispatch_step(step, current_task, context, attempt)

            # Errors from the primary dispatch bypass critique — a critic
            # scoring an error message is nonsense. Return the error and
            # let the supervisor handle it via its normal error path.
            if result.content.startswith("[ERROR]"):
                results.append(result)
                return results

            critique = await self._run_critique(
                step=step,
                primary_result=result,
                original_task=step.description,
                context=context,
                execution=execution,
            )

            if critique is None:
                # Critique dispatch failed — fall OPEN (accept primary).
                # critique_passed stays None so the downstream trail
                # distinguishes "critique unavailable" from "critique ran".
                results.append(result)
                return results

            # Attach critique metadata to the primary's StepResult.
            result.critique_score = critique.score
            result.critique_feedback = critique.justification
            result.critique_agent = critique.critic_agent
            result.critique_passed = critique.score >= threshold
            results.append(result)

            if result.critique_passed:
                return results

            # Below threshold — revision planning. Stop if out of budget.
            if revision_round >= max_revisions:
                log.info(
                    "Step %s exhausted critique revisions (%d) with score %.2f below threshold %.2f",
                    step.id, max_revisions, critique.score, threshold,
                )
                return results

            # Reflexion: produce a self-correction BEFORE building the next
            # revision task. The reflection goes into a bounded buffer
            # keyed by (agent_key, step_id) and is injected into the task
            # below alongside the critique feedback. Fail-open: any
            # failure in the reflection pipeline is a silent no-op.
            await self._reflect_after_failure(
                step=step,
                task=current_task,
                result=result,
                critique=critique,
                execution=execution,
            )

            # Prepare the next revision pass. Primary sees the critique
            # feedback prepended to the original task so it knows what to
            # improve, PLUS any accumulated reflections for this step.
            # Iteration count bumps so StepResults distinguish drafts in
            # the UI + decision log.
            attempt += 1
            reflections_block = self._reflexion_block_for(step, result.agent_key)
            reflections_suffix = f"\n\n{reflections_block}" if reflections_block else ""
            current_task = (
                f"REVISION REQUESTED — a reviewer scored your previous output "
                f"{critique.score:.2f}, below the threshold of {threshold:.2f}.\n\n"
                f"{self._format_revision_feedback(critique)}"
                f"{reflections_suffix}\n\n"
                f"Original task: {step.description}\n\n"
                f"Produce an improved version addressing the concerns above."
            )

        return results

    async def _dispatch_step(
        self,
        step: WorkflowStep,
        task: str,
        context: str,
        attempt: int,
    ) -> StepResult:
        """Dispatch a single step through the orchestrator with a timeout.

        Wraps the underlying ``orchestrator.dispatch`` in
        ``asyncio.wait_for(STEP_TIMEOUT_SECONDS)`` so a stuck agent (hung
        LLM call, slow multi-URL scrape, unresponsive sub-loop) surfaces
        as a ``[TIMEOUT]`` step result rather than blocking the whole
        workflow. The supervisor's decision loop handles ``[TIMEOUT]``
        the same way it handles ``[ERROR]`` — loop-with-feedback or
        abort — so no new surface area for the LLM side.
        """
        import asyncio as _asyncio

        t0 = time.monotonic()
        try:
            result = await _asyncio.wait_for(
                self._orchestrator.dispatch(
                    agent_name=step.agent_key,
                    task=task,
                    context=context,
                ),
                timeout=STEP_TIMEOUT_SECONDS,
            )
            duration_ms = int((time.monotonic() - t0) * 1000)
            return StepResult(
                step_id=step.id,
                agent_key=step.agent_key,
                content=result.content,
                input_tokens=result.input_tokens,
                output_tokens=result.output_tokens,
                duration_ms=duration_ms,
                iteration=attempt,
            )
        except TimeoutError:
            duration_ms = int((time.monotonic() - t0) * 1000)
            log.warning(
                "Step %s timed out after %.0fs (agent=%s)",
                step.id, STEP_TIMEOUT_SECONDS, step.agent_key,
            )
            return StepResult(
                step_id=step.id,
                agent_key=step.agent_key,
                content=(
                    f"[TIMEOUT] Step '{step.id}' exceeded "
                    f"{int(STEP_TIMEOUT_SECONDS)}s while agent "
                    f"'{step.agent_key}' was executing. No artifact produced."
                ),
                duration_ms=duration_ms,
                iteration=attempt,
            )
        except Exception as exc:
            duration_ms = int((time.monotonic() - t0) * 1000)
            log.exception("Step %s dispatch failed", step.id)
            return StepResult(
                step_id=step.id,
                agent_key=step.agent_key,
                content=f"[ERROR] Step failed: {exc}",
                duration_ms=duration_ms,
                iteration=attempt,
            )

    async def _decide(
        self,
        workflow: WorkflowDef,
        execution: WorkflowExecution,
    ) -> SupervisorDecision:
        """Ask the supervisor LLM what to do next."""
        # Build step descriptions
        steps_desc = []
        for s in workflow.steps:
            agent = self._orchestrator.agents.get(s.agent_key)
            agent_name = agent.name if agent else s.agent_key
            status = "available"
            # Check execution history
            attempts = sum(1 for r in execution.step_results if r.step_id == s.id)
            if attempts > 0:
                last = next(r for r in reversed(execution.step_results) if r.step_id == s.id)
                _failed = "[ERROR]" in last.content or "[TIMEOUT]" in last.content
                status = f"failed ({attempts}x)" if _failed else f"completed ({attempts}x)"
            # Check if skipped
            for log_entry in execution.supervisor_log:
                if log_entry["action"] == "skip" and s.id in log_entry["steps"]:
                    status = "skipped"
            steps_desc.append(
                f"- **{s.id}**: {s.label} (agent: {agent_name}) — {s.description}\n"
                f"  Status: {status}"
            )

        # Build execution history
        history_parts = []
        if not execution.step_results:
            history_parts.append("No steps executed yet. This is the beginning of the workflow.")
        else:
            for r in execution.step_results:
                step = next((s for s in workflow.steps if s.id == r.step_id), None)
                label = step.label if step else r.step_id
                # Truncate content for supervisor context
                content_preview = r.content[:500] if len(r.content) > 500 else r.content
                history_parts.append(
                    f"### Step: {label} ({r.step_id}) — attempt {r.iteration}\n"
                    f"Agent: {r.agent_key} | Duration: {r.duration_ms}ms | "
                    f"Tokens: {r.input_tokens}→{r.output_tokens}\n"
                    f"Result:\n{content_preview}"
                )

        # Resolve supervisor agent personality
        sup_agent = self._orchestrator.agents.get(workflow.supervisor) if workflow.supervisor else None
        if sup_agent:
            sup_name = sup_agent.name
            sup_role = sup_agent.role
            sup_personality = sup_agent.personality
            sup_expertise = sup_agent.capabilities
            team_desc = "\n".join(
                f"- **{self._orchestrator.agents[k].name}** ({k})" if k in self._orchestrator.agents
                else f"- {k} (not loaded)"
                for k in sup_agent.team
            ) or "No team defined."
        else:
            sup_name = "Workflow Supervisor"
            sup_role = "workflow coordinator"
            sup_personality = "Efficient and adaptive. Makes routing decisions based on results."
            sup_expertise = ""
            team_desc = "Team members defined per workflow step."

        prompt = _SUPERVISOR_PROMPT.format(
            supervisor_name=sup_name,
            supervisor_role=sup_role,
            supervisor_personality=sup_personality,
            supervisor_expertise=sup_expertise,
            workflow_name=workflow.name,
            workflow_description=workflow.description,
            team_description=team_desc,
            steps_description="\n".join(steps_desc),
            execution_history="\n\n".join(history_parts),
        )

        # Wrap the supervisor LLM call in a timeout. Without this, a stuck
        # provider call (GPT-5 reasoning-tier hangs, network drop mid-stream,
        # etc) freezes the whole workflow silently — no dispatch, no
        # timeout, no activity event. On hit, return a synthesized abort
        # decision so the caller ends the run cleanly with a reason.
        import asyncio as _asyncio

        try:
            response = await _asyncio.wait_for(
                self._llm.complete(
                    messages=[Message(role="user", content="Decide the next action for this workflow.")],
                    system=prompt,
                    max_tokens=300,
                    temperature=0.1,
                ),
                timeout=DECIDE_TIMEOUT_SECONDS,
            )
        except TimeoutError:
            log.warning(
                "Supervisor _decide() timed out after %.0fs for workflow %s",
                DECIDE_TIMEOUT_SECONDS, workflow.id,
            )
            return SupervisorDecision(
                action="abort",
                steps=[],
                feedback="",
                reason=(
                    f"Supervisor decision LLM call exceeded "
                    f"{int(DECIDE_TIMEOUT_SECONDS)}s. Aborting workflow to "
                    f"avoid a silent hang — inspect LLM provider health."
                ),
            )

        return self._parse_decision(response.content)

    @staticmethod
    def _parse_decision(text: str) -> SupervisorDecision:
        """Parse the supervisor's decision from LLM output."""
        import re

        # Extract the decision block
        decision_match = re.search(r"```decision\s*\n(.+?)\n```", text, re.DOTALL)
        decision_line = decision_match.group(1).strip() if decision_match else text.strip()

        # Also try without code block — supervisor might just write the action
        if not decision_match:
            # Look for action keywords at start of any line
            for line in text.split("\n"):
                line = line.strip()
                if line.startswith(("dispatch ", "parallel ", "loop ", "skip ", "done ", "abort ")):
                    decision_line = line
                    break

        # Extract reason
        reason = ""
        reason_match = re.search(r"reason:\s*(.+)", text, re.IGNORECASE)
        if reason_match:
            reason = reason_match.group(1).strip()

        # Parse action
        parts = decision_line.split(maxsplit=1)
        action = parts[0].lower() if parts else "done"
        args = parts[1] if len(parts) > 1 else ""

        if action == "done":
            return SupervisorDecision(action="done", reason=reason or args.strip('" '))

        if action == "abort":
            return SupervisorDecision(action="abort", reason=reason or args.strip('" '))

        if action == "dispatch":
            return SupervisorDecision(action="dispatch", steps=[args.strip()], reason=reason)

        if action == "parallel":
            step_ids = args.split()
            return SupervisorDecision(action="parallel", steps=step_ids, reason=reason)

        if action == "loop":
            # loop step_id "feedback text"
            loop_match = re.match(r'(\S+)\s+"(.+)"', args)
            if loop_match:
                return SupervisorDecision(
                    action="loop",
                    steps=[loop_match.group(1)],
                    feedback=loop_match.group(2),
                    reason=reason,
                )
            # Fallback: just step_id
            return SupervisorDecision(action="loop", steps=[args.split()[0]] if args else [], reason=reason)

        if action == "skip":
            skip_match = re.match(r'(\S+)\s+"(.+)"', args)
            if skip_match:
                return SupervisorDecision(
                    action="skip",
                    steps=[skip_match.group(1)],
                    reason=reason or skip_match.group(2),
                )
            return SupervisorDecision(action="skip", steps=[args.split()[0]] if args else [], reason=reason)

        # Fallback: treat as dispatch
        return SupervisorDecision(action="dispatch", steps=[action], reason=reason)
