# loopy-agent — public API reference for LLM ingestion

Generated by `scripts/generate_llms_txt.py`. This file captures every public symbol in the `loopy` package along with its docstring and source signature. Designed for AI coding assistants (Cursor, Claude Code, Continue, Aider, Cody, etc.) to ingest as project context.

Format: each symbol is fenced by `# Module` headers and `# Symbol` subheaders.


---


# Module `loopy`

## `loopy.AgentLoop` (class)

The agentic loop engine.

Example:
    async def my_planner(history):
        return "I will search for information about Python."

    async def my_actor(plan):
        return "Searched the web and found 3 results."

    async def my_observer(action):
        return "Found relevant docs about Python asyncio."

    async def my_reflector(history):
        return "Good progress, but need more details on threading."

    loop = AgentLoop(LoopConfig(
        planner=my_planner,
        actor=my_actor,
        observer=my_observer,
        reflector=my_reflector,
    ))

    results = await loop.run()

```python
class AgentLoop:
    """
    The agentic loop engine.

    Example:
        async def my_planner(history):
            return "I will search for information about Python."

        async def my_actor(plan):
            return "Searched the web and found 3 results."

        async def my_observer(action):
            return "Found relevant docs about Python asyncio."

        async def my_reflector(history):
            return "Good progress, but need more details on threading."

        loop = AgentLoop(LoopConfig(
            planner=my_planner,
            actor=my_actor,
            observer=my_observer,
            reflector=my_reflector,
        ))

        results = await loop.run()
    """

    def __init__(self, config: LoopConfig | None = None):
        self.config = config or LoopConfig()
        self.history: list[StepResult] = []
        # v0.8.0 — set by run() when resuming past a before-gate interrupt,
        # so _run_step can skip that single (step, phase) gate on re-entry.
        self._skip_before: tuple[int, str] | None = None

    async def run(
        self,
        initial_context: str = "",
        *,
        resume_from: Interrupt | None = None,
    ) -> Interrupt | list[StepResult]:
        """
        Execute the full agentic loop.

        v0.8.0 — HITL interrupts: if ``LoopConfig.interrupt_before`` or
        ``LoopConfig.interrupt_after`` matches the current phase, this
        method returns an :class:`Interrupt` instance instead of a list
        of ``StepResult``. To continue, pass the Interrupt back via
        ``resume_from=Interrupt(decision="approve")``.

        Returns:
            ``Interrupt`` if the loop paused for human review;
            ``list[StepResult]`` if the loop completed without pausing.

        Raises:
            AgentLoopRejected: when ``resume_from`` carries ``decision="reject"``.
        """
        # v0.8.0 — handle resume_from decision before entering the loop.
        if resume_from is not None:
            if resume_from.decision is None:
                raise ValueError(
                    "resume_from must carry a decision ('approve' or 'reject'); "
                    "received Interrupt with decision=None"
                )
            if resume_from.decision == "reject":
                raise AgentLoopRejected(
                    proposal=resume_from.proposed_action,
                    context=resume_from.context,
                )
            # decision == "approve" — re-enter at the same step so the
            # after-gate (if any) still fires for the same step. The
            # before-gate we just approved is suppressed via _skip_before.
            self._skip_before = (resume_from.step, resume_from.phase)
            start_step = max(1, resume_from.step)
        elif self.config.resume_from is not None:
            start_step = max(1, self.config.resume_from + 1)
            self._skip_before = None
            logger.info("Resuming loop at step %d", start_step)
        else:
            start_step = 1
            self._skip_before = None

        self.history = []

        if initial_context:
            self.history.append(
                StepResult(
                    step=0,
                    status=StepStatus.OBSERVING,
                    observation=initial_context,
                )
            )

        # legacy compatibility: keep this no-op assignment for any
        # downstream consumer that read start_step here before the
        # v0.8.0 restructure (see ``test_t1001_characterization.py``).
        _ = start_step

        try:
            for step_num in range(start_step, self.config.max_steps + 1):
                # v0.9.0 — Compliance-as-Code: evaluate policies before
                # the step runs. ``gate()`` raises on ``block`` and
                # returns the full list of decisions otherwise. We
                # record the raw context (audit fidelity) so
                # post-hoc scrubbing is the storage layer's job.
                if self.config.policy_engine is not None:
                    step_decisions = self.config.policy_engine.gate(
                        {"step": step_num, "retries": step_num - 1}
                    )
                    if step_decisions and self.config.state_manager is not None:
                        try:
                            self._record_policy_decisions(step_num, step_decisions)
                        except Exception as e:  # noqa: BLE001
                            logger.warning(
                                "Failed to record policy decisions at step %d: %s",
                                step_num,
                                e,
                            )

                result = await self._run_step(step_num)
                self.history.append(result)

                # v0.7.8 — checkpoint after every step when configured
                self._checkpoint(result)

                if result.status == StepStatus.FAILED and self.config.stop_on_error:
                    logger.error("Loop stopped at step %d: %s", step_num, result.error)
                    break

                # Check custom stop condition
                if self.config.should_stop:
                    try:
                        if await self.config.should_stop(self.history):
                            logger.info("Stop condition met at step %d", step_num)
                            break
                    except Exception as e:
                        logger.warning("Stop condition check failed: %s", e)

                # Default stop: all callbacks are None (no-op loop)
                if not any(
                    [
                        self.config.planner,
                        self.config.actor,
                        self.config.observer,
                        self.config.reflector,
                    ]
                ):
                    logger.info("No callbacks configured, stopping loop")
                    break
        except _InterruptedRun as ir:
            # v0.8.0 — a phase triggered an Interrupt. Persist it via
            # the configured state manager (best-effort) so a crash+resume
            # can replay, then return it to the caller for review.
            self._persist_interrupt(ir.interrupt)
            return ir.interrupt

        return self.history

    def _record_policy_decisions(self, step_num: int, decisions: list[Any]) -> None:
        """v0.9.0 — Append raw policy decisions to LoopState.metadata
        so a crash+resume can replay the audit trail.

        The decisions are stored verbatim (no redaction) so the
        audit log has the raw facts; storage-side scrubbing is the
        caller's responsibility when reading the LoopState back out.
        """
        if not self.config.state_manager:
            return

        from loopy.state import RunOutcome, RunRecord

        sm = self.config.state_manager
        state = sm.load()
        existing = list(state.metadata.get("policies", []))
        existing.append(
            {
                "step": step_num,
                "decisions": [d.to_dict() for d in decisions],
            }
        )
        state.metadata["policies"] = existing

        # Also surface one RunRecord per decision so compliance
        # dashboards that read RunRecords (without parsing metadata)
        # see the audit trail.
        for d in decisions:
            state.add_record(
                RunRecord(
                    task=self.config.task or f"policy_step_{step_num}",
                    outcome=RunOutcome.SUCCESS,
                    tokens_used=0,
                    duration_ms=0,
                    timestamp=datetime.now().isoformat(),
                    metadata={
                        "kind": "policy_decision",
                        "step": step_num,
                        "policy_name": d.policy_name,
                        "verdict": d.verdict,
                    },
                )
            )

        if len(state.history) > 100:
            state.history = state.history[-100:]

        sm.save(state)

    def _checkpoint(self, result: StepResult) -> None:
        """v0.7.8 — Persist a step result to the configured StateManager.

        Records a RunRecord per step and updates LoopState.attempts so a
        subsequent run with ``resume_from`` can pick up where this one left off.
        Failures are logged but do not interrupt the loop — checkpointing is
        best-effort observability, not a transactional write-ahead log.
        """
        if not self.config.state_manager:
            return

        try:
            from loopy.state import RunOutcome, RunRecord

            state_manager = self.config.state_manager
            state = state_manager.load()
            state.current_task = self.config.task or None
            state.attempts = result.step

            outcome = (
                RunOutcome.SUCCESS if result.status == StepStatus.COMPLETE else RunOutcome.FAILURE
            )
            state.add_record(
                RunRecord(
                    task=self.config.task or f"step_{result.step}",
                    outcome=outcome,
                    tokens_used=0,
                    duration_ms=0,
                    timestamp=datetime.now().isoformat(),
                    metadata={
                        "step": result.step,
                        "plan": result.plan[:200],
                        "action": result.action[:200],
                        "observation": result.observation[:200],
                    },
                )
            )

            # Cap stored RunRecords to avoid unbounded growth (matches the
            # DecisionTracker FIFO bound from v0.7.6).
            if len(state.history) > 100:
                state.history = state.history[-100:]

            state_manager.save(state)
        except Exception as e:
            logger.warning("Checkpoint failed at step %d: %s", result.step, e)

    def _persist_interrupt(self, interrupt: Interrupt) -> None:
        """v0.8.0 — record an interrupt on the configured state manager.

        Best-effort: failures are logged but do not change the return
        value of :meth:`run`. The interrupt is appended to
        ``LoopState.metadata["interrupts"]`` and a paired ``RunRecord``
        is added to ``LoopState.history`` so a subsequent resume can see
        what was paused.
        """
        if not self.config.state_manager:
            return

        try:
            from loopy.state import RunOutcome, RunRecord

            state_manager = self.config.state_manager
            state = state_manager.load()
            state.current_task = self.config.task or None
            state.attempts = interrupt.step

            state.add_record(
                RunRecord(
                    task=self.config.task or f"interrupt_step_{interrupt.step}",
                    outcome=RunOutcome.INTERRUPTED,
                    tokens_used=0,
                    duration_ms=0,
                    timestamp=datetime.now().isoformat(),
                    metadata={
                        "kind": "interrupt",
                        "phase": interrupt.phase,
                        "step": interrupt.step,
                        "proposed_action": interrupt.proposed_action,
                    },
                )
            )

            interrupts = list(state.metadata.get("interrupts", []))
            interrupts.append(
                {
                    "phase": interrupt.phase,
                    "step": interrupt.step,
                    "proposed_action": interrupt.proposed_action,
                    "context": interrupt.context,
                }
            )
            state.metadata["interrupts"] = interrupts

            if len(state.history) > 100:
                state.history = state.history[-100:]

            state_manager.save(state)
        except Exception as e:
            logger.warning("Persist interrupt failed: %s", e)

    async def _run_step(self, step_num: int) -> StepResult:
        """Execute a single iteration of the loop."""
        result = StepResult(step=step_num, status=StepStatus.PLANNING)

        # v0.8.0 — Interrupt gates. Each phase can be paused BEFORE the
        # phase runs (``interrupt_before``) or AFTER (``interrupt_after``)
        # by raising ``_InterruptedRun`` which the public ``run()`` catches
        # and converts to an :class:`Interrupt` return value.
        ib = self.config.interrupt_before or []
        ia = self.config.interrupt_after or []
        # v0.8.0 — clear single-shot skip once we enter the target step.
        if self._skip_before is not None and self._skip_before[0] != step_num:
            self._skip_before = None

        async def _pause_before(phase: str, proposed: str, ctx: dict[str, Any]) -> None:
            """Raise ``_InterruptedRun`` if this phase is configured to pause BEFORE running."""
            if phase in ib and self._skip_before != (step_num, phase):
                raise _InterruptedRun(
                    Interrupt(
                        proposed_action=proposed,
                        context={**ctx, "when": "before"},
                        phase=phase,
                        step=step_num,
                    )
                )

        async def _pause_after(phase: str, proposed: str, ctx: dict[str, Any]) -> None:
            """Raise ``_InterruptedRun`` if this phase is configured to pause AFTER running."""
            if phase in ia:
                raise _InterruptedRun(
                    Interrupt(
                        proposed_action=proposed,
                        context={**ctx, "when": "after"},
                        phase=phase,
                        step=step_num,
                    )
                )

        try:
            # PLAN
            if self.config.planner:
                await _pause_before(
                    "plan",
                    proposed=f"run plan step {step_num}",
                    ctx={"step": step_num, "phase": "plan"},
                )
                result.plan = await self.config.planner(self.history)
                await _pause_after(
                    "plan",
                    proposed=f"plan step {step_num} produced: {result.plan[:80]}",
                    ctx={"step": step_num, "phase": "plan", "plan": result.plan},
                )
                logger.debug("Step %d plan: %s...", step_num, result.plan[:100])

            # ACT
            result.status = StepStatus.ACTING
            if self.config.actor:
                await _pause_before(
                    "actor",
                    proposed=f"run actor step {step_num} with plan: {result.plan[:80]}",
                    ctx={"step": step_num, "phase": "actor", "plan": result.plan},
                )
                result.action = await self.config.actor(result.plan)
                await _pause_after(
                    "actor",
                    proposed=f"actor step {step_num} produced: {result.action[:80]}",
                    ctx={
                        "step": step_num,
                        "phase": "actor",
                        "plan": result.plan,
                        "action": result.action,
                    },
                )
                logger.debug("Step %d action: %s...", step_num, result.action[:100])

            # OBSERVE
            result.status = StepStatus.OBSERVING
            if self.config.observer:
                await _pause_before(
                    "observer",
                    proposed=f"observe action result: {result.action[:80]}",
                    ctx={"step": step_num, "phase": "observer", "action": result.action},
                )
                result.observation = await self.config.observer(result.action)
                await _pause_after(
                    "observer",
                    proposed=f"observer step {step_num} produced: {result.observation[:80]}",
                    ctx={
                        "step": step_num,
                        "phase": "observer",
                        "action": result.action,
                        "observation": result.observation,
                    },
                )
                logger.debug("Step %d observation: %s...", step_num, result.observation[:100])

            # REFLECT
            result.status = StepStatus.REFLECTING
            if self.config.reflector:
                await _pause_before(
                    "reflector",
                    proposed=f"reflect on history ({len(self.history)} entries)",
                    ctx={"step": step_num, "phase": "reflector"},
                )
                result.reflection = await self.config.reflector(self.history)
                await _pause_after(
                    "reflector",
                    proposed=f"reflector step {step_num} produced: {result.reflection[:80]}",
                    ctx={
                        "step": step_num,
                        "phase": "reflector",
                        "reflection": result.reflection,
                    },
                )
                logger.debug("Step %d reflection: %s...", step_num, result.reflection[:100])

            result.status = StepStatus.COMPLETE

        except _InterruptedRun as ir:
            # Carry the interrupt up to the public run() entry point.
            raise ir
        except Exception as e:
            result.status = StepStatus.FAILED
            result.error = str(e)
            logger.error("Step %d failed: %s", step_num, e)

            if self.config.stop_on_error:
                raise

        return result
```

## AssertionResult

> Not exported by `loopy`.

## `loopy.AuditLogger` (class)

Log all agent actions for compliance.

Example:
    logger = AuditLogger("./audit.log")
    logger.log("summarize", agent_id="agent-1", ...)

```python
class AuditLogger:
    """
    Log all agent actions for compliance.

    Example:
        logger = AuditLogger("./audit.log")
        logger.log("summarize", agent_id="agent-1", ...)
    """

    def __init__(self, path: str = "./audit.jsonl"):
        self.path = Path(path)
        self.path.parent.mkdir(parents=True, exist_ok=True)

    def log(self, entry: AuditEntry) -> None:
        """Append an audit entry to the JSONL log file.

        Args:
            entry: The AuditEntry to persist.
        """
        with open(self.path, "a") as f:
            f.write(json.dumps(entry.to_dict()) + "\n")

    def query(
        self,
        agent_id: str | None = None,
        start_time: str | None = None,
        end_time: str | None = None,
    ) -> list[AuditEntry]:
        """Query audit log entries with optional filters.

        Args:
            agent_id: Filter by agent identifier.
            start_time: ISO-format start timestamp (inclusive).
            end_time: ISO-format end timestamp (inclusive).

        Returns:
            List of matching AuditEntry objects.
        """
        entries: list[AuditEntry] = []

        if not self.path.exists():
            return entries

        with open(self.path) as f:
            for line in f:
                if not line.strip():
                    continue
                data = json.loads(line)

                if agent_id and data.get("agent_id") != agent_id:
                    continue
                if start_time and data.get("timestamp", "") < start_time:
                    continue
                if end_time and data.get("timestamp", "") > end_time:
                    continue

                entries.append(
                    AuditEntry(
                        timestamp=data["timestamp"],
                        action=data["action"],
                        agent_id=data["agent_id"],
                        input_summary=data["input_summary"],
                        output_summary=data["output_summary"],
                        classification=DataClassification(data["classification"]),
                        tokens_used=data.get("tokens_used", 0),
                        model=data.get("model", ""),
                        metadata=data.get("metadata", {}),
                    )
                )

        return entries

    def summary(self, days: int = 30) -> dict[str, Any]:
        """Generate a summary of audit activity.

        Args:
            days: Number of days to look back (currently unused,
                  reserved for future filtering).

        Returns:
            Dict with total_actions, total_tokens, breakdowns
            by agent and classification.
        """
        entries = self.query()

        total_tokens = sum(e.tokens_used for e in entries)
        by_agent: dict[str, int] = {}
        by_classification: dict[str, int] = {}

        for e in entries:
            by_agent[e.agent_id] = by_agent.get(e.agent_id, 0) + 1
            cls_key = e.classification.value
            by_classification[cls_key] = by_classification.get(cls_key, 0) + 1

        return {
            "total_actions": len(entries),
            "total_tokens": total_tokens,
            "by_agent": by_agent,
            "by_classification": by_classification,
        }
```

## `loopy.AuditReport` (class)

Full audit report with score and suggestions.

```python
@dataclass
class AuditReport:
    """Full audit report with score and suggestions."""

    score: int
    level: ReadinessLevel
    checks: list[CheckItem]
    suggestions: list[str]

    def summary(self) -> dict[str, Any]:
        """Return summary dict."""
        return {
            "score": self.score,
            "level": self.level.value,
            "passed": sum(1 for c in self.checks if c.passed),
            "failed": sum(1 for c in self.checks if not c.passed),
            "total": len(self.checks),
            "suggestions": self.suggestions,
        }
```

## `loopy.BudgetExceeded` (class)

Raised when token budget is exceeded.

```python
class BudgetExceeded(Exception):
    """Raised when token budget is exceeded."""

    def __init__(self, limit: int, used: int):
        self.limit = limit
        self.used = used
        super().__init__(f"Budget exceeded: {used}/{limit} tokens")
```

## `loopy.CacheMiddleware` (class)

Cache middleware for identical requests.

Caches responses and short-circuits duplicate requests
within the TTL window.

Args:
    ttl: Time-to-live in seconds for cached entries.

```python
class CacheMiddleware(Middleware):
    """Cache middleware for identical requests.

    Caches responses and short-circuits duplicate requests
    within the TTL window.

    Args:
        ttl: Time-to-live in seconds for cached entries.
    """

    def __init__(self, ttl: int = 60):
        self.ttl = ttl
        self._cache: dict[str, tuple[float, Any]] = {}

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        """Check cache and short-circuit on hit."""
        # Create cache key (SHA-256 instead of MD5 to avoid scanner flags)
        key_data = json.dumps(ctx.data, sort_keys=True, default=str)
        cache_key = hashlib.sha256(key_data.encode()).hexdigest()

        # Check cache
        if cache_key in self._cache:
            timestamp, cached_result = self._cache[cache_key]
            if time.time() - timestamp < self.ttl:
                ctx.metadata["cached"] = True
                ctx.metadata["cached_result"] = cached_result
                ctx.cancel("Cache hit")
            else:
                del self._cache[cache_key]

        ctx.metadata["cache_key"] = cache_key
        return ctx

    async def after(self, ctx: MiddlewareContext, result: Any) -> Any:
        """Store result in cache after successful execution."""
        if not ctx.metadata.get("cached"):
            cache_key = ctx.metadata.get("cache_key")
            if cache_key:
                self._cache[cache_key] = (time.time(), result)
        return result
```

## `loopy.CacheStats` (class)

Cache statistics.

```python
@dataclass
class CacheStats:
    """Cache statistics."""

    hits: int = 0
    misses: int = 0
    total_saved_tokens: int = 0

    @property
    def hit_rate(self) -> float:
        total = self.hits + self.misses
        return self.hits / total if total > 0 else 0.0

    @property
    def estimated_savings(self) -> float:
        """Rough cost estimate assuming $0.03 per 1K tokens."""
        return (self.total_saved_tokens / 1000) * 0.03
```

## `loopy.CheckItem` (class)

A single audit check.

```python
@dataclass
class CheckItem:
    """A single audit check."""

    name: str
    passed: bool
    weight: int
    description: str

    @property
    def score(self) -> int:
        return self.weight if self.passed else 0
```

## `loopy.CircuitBreakerMiddleware` (class)

Circuit breaker to prevent cascade failures.

Tracks failure count and opens the circuit after a threshold,
blocking requests for *recovery_timeout* seconds before
allowing a probe (half-open state).  State mutations are
protected by an asyncio lock for safe concurrent use.

Args:
    failure_threshold: Consecutive failures before opening.
    recovery_timeout: Seconds before transitioning to half-open.

```python
class CircuitBreakerMiddleware(Middleware):
    """Circuit breaker to prevent cascade failures.

    Tracks failure count and opens the circuit after a threshold,
    blocking requests for *recovery_timeout* seconds before
    allowing a probe (half-open state).  State mutations are
    protected by an asyncio lock for safe concurrent use.

    Args:
        failure_threshold: Consecutive failures before opening.
        recovery_timeout: Seconds before transitioning to half-open.
    """

    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: float = 60.0,
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self._failure_count = 0
        self._last_failure_time: float = 0
        self._state = "closed"  # closed = normal, open = blocked, half-open = testing
        self._lock = asyncio.Lock()

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        """Block request if circuit is open (unless recovery timeout elapsed)."""
        async with self._lock:
            if self._state == "open":
                if time.time() - self._last_failure_time > self.recovery_timeout:
                    self._state = "half-open"
                    logger.info("Circuit breaker: half-open state")
                else:
                    ctx.cancel(f"Circuit breaker is open (failures: {self._failure_count})")
        return ctx

    async def after(self, ctx: MiddlewareContext, result: Any) -> Any:
        """Reset failure count on success."""
        async with self._lock:
            if self._state == "half-open":
                self._state = "closed"
                logger.info("Circuit breaker: closed (recovered)")
            self._failure_count = 0
        return result

    async def on_error(self, ctx: MiddlewareContext, error: Exception) -> Exception:
        """Increment failure count; open circuit if threshold reached."""
        async with self._lock:
            self._failure_count += 1
            self._last_failure_time = time.time()

            if self._failure_count >= self.failure_threshold:
                self._state = "open"
                logger.warning("Circuit breaker: open (failures: %d)", self._failure_count)

        return error
```

## `loopy.ComplianceChecker` (class)

Check compliance against frameworks.

Example:
    checker = ComplianceChecker()
    report = checker.check_soc2(config)
    if not report.passed:
        print(f"Violations: {report.violations}")

```python
class ComplianceChecker:
    """
    Check compliance against frameworks.

    Example:
        checker = ComplianceChecker()
        report = checker.check_soc2(config)
        if not report.passed:
            print(f"Violations: {report.violations}")
    """

    def __init__(self, audit_logger: AuditLogger | None = None):
        self.audit_logger = audit_logger

    def check_soc2(self, config: dict[str, Any]) -> ComplianceReport:
        """Check SOC2 compliance."""
        checks = []
        violations = []
        recommendations = []

        # Check: Access controls
        has_auth = config.get("authentication") is not None
        checks.append({"name": "access_controls", "passed": has_auth})
        if not has_auth:
            violations.append("No authentication configured")
            recommendations.append("Add authentication to restrict agent access")

        # Check: Audit logging
        has_audit = config.get("audit_logging") is True
        checks.append({"name": "audit_logging", "passed": has_audit})
        if not has_audit:
            violations.append("Audit logging not enabled")
            recommendations.append("Enable audit logging for all agent actions")

        # Check: Encryption
        has_encryption = config.get("encryption") is not None
        checks.append({"name": "encryption", "passed": has_encryption})
        if not has_encryption:
            recommendations.append("Configure encryption for data at rest and in transit")

        # Check: Rate limiting
        has_rate_limit = config.get("rate_limit") is not None
        checks.append({"name": "rate_limiting", "passed": has_rate_limit})
        if not has_rate_limit:
            recommendations.append("Add rate limiting to prevent abuse")

        return ComplianceReport(
            framework=ComplianceFramework.SOC2,
            passed=all(c["passed"] for c in checks),
            checks=checks,
            violations=violations,
            recommendations=recommendations,
        )

    def check_gdpr(self, config: dict[str, Any]) -> ComplianceReport:
        """Check GDPR compliance."""
        checks = []
        violations = []
        recommendations = []

        # Check: Data minimization
        has_minimization = config.get("data_minimization") is True
        checks.append({"name": "data_minimization", "passed": has_minimization})
        if not has_minimization:
            violations.append("Data minimization not enforced")
            recommendations.append("Only collect necessary data for agent operation")

        # Check: Right to deletion
        has_deletion = config.get("deletion_support") is True
        checks.append({"name": "right_to_deletion", "passed": has_deletion})
        if not has_deletion:
            recommendations.append("Implement data deletion on user request")

        # Check: Consent tracking
        has_consent = config.get("consent_tracking") is True
        checks.append({"name": "consent_tracking", "passed": has_consent})
        if not has_consent:
            recommendations.append("Track user consent for data processing")

        # Check: PII handling
        has_pii_protection = config.get("pii_protection") is not None
        checks.append({"name": "pii_protection", "passed": has_pii_protection})
        if not has_pii_protection:
            violations.append("No PII protection configured")
            recommendations.append("Add PII detection and masking via guardrails")

        return ComplianceReport(
            framework=ComplianceFramework.GDPR,
            passed=all(c["passed"] for c in checks),
            checks=checks,
            violations=violations,
            recommendations=recommendations,
        )

    def check_eu_ai_act(self, config: dict[str, Any]) -> ComplianceReport:
        """Check EU AI Act compliance."""
        checks = []
        violations = []
        recommendations = []

        # Check: Risk classification
        has_risk_class = config.get("risk_classification") is not None
        checks.append({"name": "risk_classification", "passed": has_risk_class})
        if not has_risk_class:
            violations.append("No risk classification for AI system")
            recommendations.append("Classify AI system risk level per EU AI Act")

        # Check: Human oversight
        has_human_oversight = config.get("human_oversight") is True
        checks.append({"name": "human_oversight", "passed": has_human_oversight})
        if not has_human_oversight:
            violations.append("No human oversight mechanism")
            recommendations.append("Add human-in-the-loop for high-risk decisions")

        # Check: Transparency
        has_transparency = config.get("transparency") is True
        checks.append({"name": "transparency", "passed": has_transparency})
        if not has_transparency:
            recommendations.append("Document AI system capabilities and limitations")

        # Check: Explainability
        has_explainability = config.get("explainability") is True
        checks.append({"name": "explainability", "passed": has_explainability})
        if not has_explainability:
            recommendations.append("Add decision audit trail for agent actions")

        return ComplianceReport(
            framework=ComplianceFramework.EU_AI_ACT,
            passed=all(c["passed"] for c in checks),
            checks=checks,
            violations=violations,
            recommendations=recommendations,
        )
```

## `loopy.ConnectionPool` (class)

HTTP connection pool for reusing connections to providers.

Reduces latency by reusing TCP connections and SSL handshakes.
Evicts the least-recently-used connection when at capacity.

Example:
    pool = ConnectionPool(max_size=10)
    async with pool.get_connection("openai") as client:
        response = await client.post(...)

```python
class ConnectionPool:
    """
    HTTP connection pool for reusing connections to providers.

    Reduces latency by reusing TCP connections and SSL handshakes.
    Evicts the least-recently-used connection when at capacity.

    Example:
        pool = ConnectionPool(max_size=10)
        async with pool.get_connection("openai") as client:
            response = await client.post(...)
    """

    def __init__(self, max_size: int = 10):
        self.max_size = max_size
        self._connections: dict[str, httpx.AsyncClient] = {}
        self._last_used: dict[str, float] = {}
        self._lock = asyncio.Lock()

    async def get_connection(self, provider: str) -> httpx.AsyncClient:
        """Get or create a connection for a provider."""
        async with self._lock:
            if provider in self._connections:
                self._last_used[provider] = time.time()
                return self._connections[provider]

            if len(self._connections) >= self.max_size:
                # Evict least recently used connection
                lru_key = min(self._last_used, key=self._last_used.get)
                await self._connections[lru_key].aclose()
                del self._connections[lru_key]
                del self._last_used[lru_key]

            self._connections[provider] = httpx.AsyncClient(
                timeout=60.0,
                limits=httpx.Limits(
                    max_connections=5,
                    max_keepalive_connections=2,
                ),
            )
            self._last_used[provider] = time.time()
            return self._connections[provider]

    async def close(self) -> None:
        """Close all connections in the pool."""
        for client in self._connections.values():
            await client.aclose()
        self._connections.clear()
        self._last_used.clear()

    def stats(self) -> dict[str, Any]:
        """Get pool statistics."""
        return {
            "active_connections": len(self._connections),
            "max_size": self.max_size,
            "providers": list(self._connections.keys()),
        }
```

## `loopy.Context` (class)

Runtime context passed to each node's ``run`` body.

Carries an asyncio.Event for cooperative cancellation and the
current node name + retry attempt counter.

```python
@dataclass
class Context:
    """Runtime context passed to each node's ``run`` body.

    Carries an asyncio.Event for cooperative cancellation and the
    current node name + retry attempt counter.
    """

    events: asyncio.Event = field(default_factory=asyncio.Event)
    current_node: str = ""
    attempt: int = 0
    run_id: str = field(default_factory=lambda: str(uuid.uuid4()))
```

## `loopy.CostReport` (class)

Report of token usage and (v0.9.0) USD cost.

```python
@dataclass
class CostReport:
    """Report of token usage and (v0.9.0) USD cost."""

    used: int
    limit: int
    remaining: int
    usage_percent: float
    # v0.9.0 — Cost-Aware Routing. All four USD fields are 0.0 by
    # default for the v0.7.x token-only callers; callers that
    # opt in to USD tracking see them populated.
    estimated_usd: float = 0.0
    actual_usd: float = 0.0
    savings_usd: float = 0.0

    def summary(self) -> dict[str, Any]:
        return {
            "used": self.used,
            "limit": self.limit,
            "remaining": self.remaining,
            "usage_percent": self.usage_percent,
            "estimated_usd": self.estimated_usd,
            "actual_usd": self.actual_usd,
            "savings_usd": self.savings_usd,
        }
```

## `loopy.CostTracker` (class)

Track and limit token spending.

Example:
    tracker = CostTracker(daily_limit=10000)
    tracker.record(500)
    report = tracker.report()
    print(f"Used: {report.used}/{report.limit}")

```python
class CostTracker:
    """
    Track and limit token spending.

    Example:
        tracker = CostTracker(daily_limit=10000)
        tracker.record(500)
        report = tracker.report()
        print(f"Used: {report.used}/{report.limit}")
    """

    def __init__(
        self,
        daily_limit: int = 10000,
        persist_path: str | None = None,
    ):
        self.daily_limit = daily_limit
        self.persist_path = Path(persist_path) if persist_path else None
        self._usage: dict[str, int] = {}
        # v0.9.0 — USD totals across the run (not persisted; resets
        # on process restart). The token ``_usage`` dict is keyed by
        # day; the USD totals are session-scoped.
        self._estimated_usd: float = 0.0
        self._actual_usd: float = 0.0
        self._savings_usd: float = 0.0

        if self.persist_path and self.persist_path.exists():
            self._load()

    @property
    def used_today(self) -> int:
        """Tokens used today."""
        today = date.today().isoformat()
        return self._usage.get(today, 0)

    @property
    def remaining(self) -> int:
        """Tokens remaining today."""
        return max(0, self.daily_limit - self.used_today)

    @property
    def should_stop(self) -> bool:
        """Whether budget is exceeded."""
        return self.remaining <= 0

    def record(self, tokens: int) -> None:
        """Record token usage."""
        today = date.today().isoformat()
        self._usage[today] = self._usage.get(today, 0) + tokens

        if self.persist_path:
            self._save()

        if self.should_stop:
            logger.warning("Budget exceeded: %s/%s", self.used_today, self.daily_limit)

    # ── v0.9.0 — Cost-Aware Routing ───────────────────────────

    def record_estimated(self, usd: float) -> None:
        """Record the estimated USD cost of a planned call."""
        self._estimated_usd += float(usd)

    def record_actual(
        self,
        usd: float,
        *,
        savings_from_fallback: float = 0.0,
    ) -> None:
        """Record the actual USD cost of a completed call.

        ``savings_from_fallback`` is the dollar amount the routing
        decision saved vs. the originally-requested provider (when
        the gateway fell back to a cheaper option).
        """
        self._actual_usd += float(usd)
        self._savings_usd += float(savings_from_fallback)

    def report(self) -> CostReport:
        """Generate cost report."""
        used = self.used_today
        return CostReport(
            used=used,
            limit=self.daily_limit,
            remaining=max(0, self.daily_limit - used),
            usage_percent=(used / self.daily_limit * 100) if self.daily_limit > 0 else 0,
            estimated_usd=self._estimated_usd,
            actual_usd=self._actual_usd,
            savings_usd=self._savings_usd,
        )

    def reset(self) -> None:
        """Reset daily usage."""
        self._usage.clear()
        self._estimated_usd = 0.0
        self._actual_usd = 0.0
        self._savings_usd = 0.0
        if self.persist_path:
            self._save()

    def _save(self) -> None:
        """Save usage to disk."""
        if not self.persist_path:
            return
        self.persist_path.parent.mkdir(parents=True, exist_ok=True)
        self.persist_path.write_text(json.dumps(self._usage, indent=2))

    def _load(self) -> None:
        """Load usage from disk."""
        if not self.persist_path or not self.persist_path.exists():
            return
        try:
            self._usage = json.loads(self.persist_path.read_text())
        except Exception as e:
            logger.warning("Failed to load cost data: %s", e)
            self._usage = {}
```

## `loopy.DecisionStep` (class)

A single decision in the reasoning chain.

```python
@dataclass
class DecisionStep:
    """A single decision in the reasoning chain."""

    type: DecisionType
    reasoning: str
    input_summary: str
    output_summary: str
    confidence: float = 1.0
    alternatives: list[str] = field(default_factory=list)
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
    metadata: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "type": self.type.value,
            "reasoning": self.reasoning,
            "input_summary": self.input_summary,
            "output_summary": self.output_summary,
            "confidence": self.confidence,
            "alternatives": self.alternatives,
            "timestamp": self.timestamp,
            "metadata": self.metadata,
        }
```

## `loopy.DecisionTrace` (class)

Full trace of agent decision-making.

```python
@dataclass
class DecisionTrace:
    """Full trace of agent decision-making."""

    task: str
    steps: list[DecisionStep] = field(default_factory=list)
    final_output: str = ""
    total_time_ms: float = 0
    success: bool = True

    def add_step(self, step: DecisionStep) -> None:
        self.steps.append(step)

    @property
    def summary(self) -> str:
        """Human-readable summary of decision chain."""
        lines = [f"Task: {self.task}"]
        for i, step in enumerate(self.steps, 1):
            lines.append(f"  {i}. [{step.type.value}] {step.reasoning}")
        lines.append(f"Output: {self.final_output[:100]}...")
        return "\n".join(lines)

    def to_dict(self) -> dict[str, Any]:
        return {
            "task": self.task,
            "steps": [s.to_dict() for s in self.steps],
            "final_output": self.final_output,
            "total_time_ms": self.total_time_ms,
            "success": self.success,
        }

    def to_json(self) -> str:
        return json.dumps(self.to_dict(), indent=2)
```

## `loopy.DecisionTracker` (class)

Track and explain agent decisions.

Example:
    tracker = DecisionTracker(max_traces=100)
    trace = tracker.start("Summarize document")
    tracker.add_step(trace, DecisionType.PLAN, "Will extract key points")
    # ... agent works ...
    tracker.finish(trace, "Summary complete")
    print(trace.summary)

```python
class DecisionTracker:
    """
    Track and explain agent decisions.

    Example:
        tracker = DecisionTracker(max_traces=100)
        trace = tracker.start("Summarize document")
        tracker.add_step(trace, DecisionType.PLAN, "Will extract key points")
        # ... agent works ...
        tracker.finish(trace, "Summary complete")
        print(trace.summary)
    """

    def __init__(self, max_traces: int = 100):
        self.traces: list[DecisionTrace] = []
        self._max_traces = max_traces

    def start(self, task: str) -> DecisionTrace:
        """Start tracking a new task."""
        trace = DecisionTrace(task=task)
        self.traces.append(trace)

        # Evict oldest when at capacity
        if len(self.traces) > self._max_traces:
            evicted = self.traces.pop(0)
            logger.debug("Evicted old trace: %s", evicted.task)

        return trace

    def add_step(
        self,
        trace: DecisionTrace,
        type: DecisionType,
        reasoning: str,
        input_summary: str = "",
        output_summary: str = "",
        confidence: float = 1.0,
        alternatives: list[str] | None = None,
        **metadata: Any,
    ) -> DecisionStep:
        """Add a decision step to the trace."""
        step = DecisionStep(
            type=type,
            reasoning=reasoning,
            input_summary=input_summary,
            output_summary=output_summary,
            confidence=confidence,
            alternatives=alternatives or [],
            metadata=metadata,
        )
        trace.add_step(step)
        return step

    def finish(self, trace: DecisionTrace, output: str, success: bool = True) -> None:
        """Finish tracking a task."""
        trace.final_output = output
        trace.success = success

    def explain(self, trace: DecisionTrace) -> str:
        """Generate human-readable explanation."""
        lines = [
            f"## Decision Trace: {trace.task}",
            "",
            "### Reasoning Chain:",
        ]

        for i, step in enumerate(trace.steps, 1):
            lines.append(f"\n**Step {i}: {step.type.value}**")
            lines.append(f"- Reasoning: {step.reasoning}")
            if step.alternatives:
                lines.append(f"- Alternatives considered: {', '.join(step.alternatives)}")
            lines.append(f"- Confidence: {step.confidence:.0%}")

        lines.extend(
            [
                "",
                "### Final Output:",
                trace.final_output[:500],
                "",
                "### Stats:",
                f"- Steps: {len(trace.steps)}",
                f"- Time: {trace.total_time_ms:.0f}ms",
                f"- Success: {'✅' if trace.success else '❌'}",
            ]
        )

        return "\n".join(lines)

    def export(self, trace: DecisionTrace, path: str) -> None:
        """Export a decision trace to a JSON file.

        Args:
            trace: The DecisionTrace to export.
            path: Destination file path.
        """
        from pathlib import Path

        Path(path).write_text(trace.to_json())
```

## `loopy.DecisionType` (class)

Types of agent decisions.

```python
class DecisionType(str, Enum):
    """Types of agent decisions."""

    PLAN = "plan"
    ACTION = "action"
    TOOL_USE = "tool_use"
    ROUTE = "route"
    ESCALATE = "escalate"
    STOP = "stop"
    RETRY = "retry"
```

## `loopy.DriftDetector` (class)

Detect drift between config and state.

Example:
    detector = DriftDetector()
    report = await detector.check(config, state)
    if report.drifted:
        print(f"Drift detected: {len(report.issues)} issues")

```python
class DriftDetector:
    """
    Detect drift between config and state.

    Example:
        detector = DriftDetector()
        report = await detector.check(config, state)
        if report.drifted:
            print(f"Drift detected: {len(report.issues)} issues")
    """

    async def check(
        self,
        config: dict[str, Any],
        state: dict[str, Any],
    ) -> DriftReport:
        """
        Check for drift between config and state.

        Args:
            config: Loop configuration
            state: Runtime state

        Returns:
            DriftReport with any drift issues found
        """
        issues: list[DriftIssue] = []
        suggestions: list[str] = []

        # Check max_steps drift
        config_max = config.get("max_steps")
        state_max = state.get("max_steps")
        if config_max is not None and state_max is not None and config_max != state_max:
            issues.append(
                DriftIssue(
                    component="max_steps",
                    expected=str(config_max),
                    actual=str(state_max),
                    severity="error",
                )
            )
            suggestions.append(f"Align max_steps: config={config_max}, state={state_max}")

        # Check attempts vs max
        attempts = state.get("attempts", 0)
        max_attempts = config.get("max_attempts") or state.get("max_attempts", 5)
        if attempts >= max_attempts:
            issues.append(
                DriftIssue(
                    component="attempts",
                    expected=f"< {max_attempts}",
                    actual=str(attempts),
                    severity="warning",
                )
            )
            suggestions.append(
                f"Reset attempts or increase max_attempts (currently {attempts}/{max_attempts})"
            )

        # Check required callbacks
        for callback in ["planner", "actor", "observer", "reflector"]:
            if config.get(callback) is not None and callback not in state:
                issues.append(
                    DriftIssue(
                        component=callback,
                        expected="present in state",
                        actual="missing from state",
                        severity="warning",
                    )
                )
                suggestions.append(f"Register callback '{callback}' in state for tracking")

        # Check state has required fields
        required_fields = ["current_task", "attempts", "history"]
        for field_name in required_fields:
            if field_name not in state:
                issues.append(
                    DriftIssue(
                        component=field_name,
                        expected="present",
                        actual="missing",
                        severity="error",
                    )
                )
                suggestions.append(f"Add '{field_name}' to state for better tracking")

        drifted = any(i.severity == "error" for i in issues)

        return DriftReport(
            drifted=drifted,
            issues=issues,
            suggestions=suggestions,
        )
```

## `loopy.DriftIssue` (class)

A single drift issue.

```python
@dataclass
class DriftIssue:
    """A single drift issue."""

    component: str
    expected: str
    actual: str
    severity: str = "warning"  # "warning" or "error"
```

## `loopy.DriftReport` (class)

Report of drift between config and state.

```python
@dataclass
class DriftReport:
    """Report of drift between config and state."""

    drifted: bool
    issues: list[DriftIssue]
    suggestions: list[str]

    def summary(self) -> dict[str, Any]:
        return {
            "drifted": self.drifted,
            "error_count": sum(1 for i in self.issues if i.severity == "error"),
            "warning_count": sum(1 for i in self.issues if i.severity == "warning"),
            "issues": [
                {"component": i.component, "expected": i.expected, "actual": i.actual}
                for i in self.issues
            ],
            "suggestions": self.suggestions,
        }
```

## `loopy.Edge` (class)

A directed edge between two nodes.

If ``condition`` is provided, the edge is only traversed when
``condition(state)`` returns ``True``. Otherwise the edge fires
unconditionally.

```python
@dataclass
class Edge:
    """A directed edge between two nodes.

    If ``condition`` is provided, the edge is only traversed when
    ``condition(state)`` returns ``True``. Otherwise the edge fires
    unconditionally.
    """

    from_node: str
    to_node: str
    condition: EdgeCondition = None
```

## `loopy.EvalCase` (class)

A single evaluation test case.

```python
@dataclass
class EvalCase:
    """A single evaluation test case."""

    name: str
    input_text: str
    expected_output: str | None = None
    criteria: list[str] = field(default_factory=list)
    tags: list[str] = field(default_factory=list)
    threshold: float = 0.7
```

## `loopy.EvalGate` (class)

Evaluation gate for the evaluator-optimizer pattern.

Uses LLM-as-judge to evaluate outputs against criteria.
Part of the 2026 agentic workflow evaluator-optimizer pattern.

Example:
    gate = EvalGate(
        gate_type=EvalGateType.JUDGE,
        config=JudgeConfig(
            criteria=["correct", "concise", "helpful"],
            threshold=0.8,
        ),
        judge_fn=my_llm_judge,
    )

    result = await gate.evaluate(
        input_text="What is Python?",
        output="Python is a programming language...",
    )

    if result.passed:
        print("Output passed evaluation!")

```python
class EvalGate:
    """
    Evaluation gate for the evaluator-optimizer pattern.

    Uses LLM-as-judge to evaluate outputs against criteria.
    Part of the 2026 agentic workflow evaluator-optimizer pattern.

    Example:
        gate = EvalGate(
            gate_type=EvalGateType.JUDGE,
            config=JudgeConfig(
                criteria=["correct", "concise", "helpful"],
                threshold=0.8,
            ),
            judge_fn=my_llm_judge,
        )

        result = await gate.evaluate(
            input_text="What is Python?",
            output="Python is a programming language...",
        )

        if result.passed:
            print("Output passed evaluation!")
    """

    def __init__(
        self,
        gate_type: EvalGateType,
        config: JudgeConfig | None = None,
        judge_fn: Callable[[str], Awaitable[str]] | None = None,
    ):
        self.gate_type = gate_type
        self.config = config or JudgeConfig()
        self.judge_fn = judge_fn

    async def evaluate(
        self,
        input_text: str,
        output: str,
        criteria: list[str] | None = None,
    ) -> EvalGateResult:
        """
        Evaluate an output against criteria.

        Args:
            input_text: The original input/prompt
            output: The output to evaluate
            criteria: Optional override for criteria

        Returns:
            EvalGateResult with pass/fail and score
        """
        if self.gate_type == EvalGateType.JUDGE:
            return await self._judge_evaluate(input_text, output, criteria)

        # Manual gates always pass (human reviews externally)
        return EvalGateResult(
            gate_type=self.gate_type,
            passed=True,
            score=1.0,
            feedback="Manual gate - pending human review",
        )

    async def _judge_evaluate(
        self,
        input_text: str,
        output: str,
        criteria: list[str] | None = None,
    ) -> EvalGateResult:
        """Use LLM-as-judge to evaluate output."""
        if not self.judge_fn:
            # Fallback to simple evaluation
            return self._simple_judge_evaluate(input_text, output, criteria)

        criteria_list = criteria or self.config.criteria
        criteria_str = ", ".join(criteria_list) if criteria_list else "general quality"

        prompt = self.config.prompt_template.format(
            input=input_text,
            output=output,
            criteria=criteria_str,
        )

        try:
            judge_response = await self.judge_fn(prompt)
            data = json.loads(judge_response)

            score = float(data.get("score", 0.0))
            passed = score >= self.config.threshold

            return EvalGateResult(
                gate_type=EvalGateType.JUDGE,
                passed=passed,
                score=score,
                feedback=data.get("feedback", ""),
                metadata={"criteria": criteria_list},
            )
        except (json.JSONDecodeError, ValueError, KeyError) as e:
            logger.warning("Judge evaluation failed, using fallback: %s", e)
            return self._simple_judge_evaluate(input_text, output, criteria)

    def _simple_judge_evaluate(
        self,
        input_text: str,
        output: str,
        criteria: list[str] | None = None,
    ) -> EvalGateResult:
        """Simple evaluation when no judge function is available."""
        # Basic heuristics
        score = 0.0
        feedback = []

        # Check output is not empty
        if output.strip():
            score += 0.3
            feedback.append("Output is non-empty")

        # Check output length (prefer concise)
        word_count = len(output.split())
        if 10 <= word_count <= 200:
            score += 0.3
            feedback.append(f"Good length ({word_count} words)")
        elif word_count > 200:
            score += 0.1
            feedback.append(f"Too long ({word_count} words)")

        # Check for basic relevance (input words in output)
        input_words = set(input_text.lower().split())
        output_words = set(output.lower().split())
        overlap = len(input_words & output_words) / max(len(input_words), 1)
        score += 0.4 * overlap
        feedback.append(f"Relevance overlap: {overlap:.1%}")

        passed = score >= self.config.threshold

        return EvalGateResult(
            gate_type=EvalGateType.JUDGE,
            passed=passed,
            score=min(score, 1.0),
            feedback="; ".join(feedback),
            metadata={"method": "simple_heuristic"},
        )
```

## `loopy.EvalGateResult` (class)

Result of an evaluation gate check.

```python
@dataclass
class EvalGateResult:
    """Result of an evaluation gate check."""

    gate_type: EvalGateType
    passed: bool
    score: float = 0.0
    feedback: str = ""
    metadata: dict[str, Any] = field(default_factory=dict)
```

## `loopy.EvalGateType` (class)

Types of evaluation gates.

```python
class EvalGateType(str, Enum):
    """Types of evaluation gates."""

    JUDGE = "judge"  # LLM-as-judge (2026 evaluator-optimizer pattern)
    MANUAL = "manual"  # Human approval stub
```

## `loopy.EvalReport` (class)

Full evaluation report.

```python
@dataclass
class EvalReport:
    """Full evaluation report."""

    suite_name: str
    results: list[EvalResult] = field(default_factory=list)

    @property
    def total(self) -> int:
        return len(self.results)

    @property
    def passed(self) -> int:
        return sum(1 for r in self.results if r.verdict == Verdict.PASS)

    @property
    def failed(self) -> int:
        return sum(1 for r in self.results if r.verdict == Verdict.FAIL)

    @property
    def partial(self) -> int:
        return sum(1 for r in self.results if r.verdict == Verdict.PARTIAL)

    @property
    def pass_rate(self) -> float:
        return self.passed / self.total if self.total > 0 else 0.0

    @property
    def average_score(self) -> float:
        if not self.results:
            return 0.0
        return sum(r.score for r in self.results) / len(self.results)

    def summary(self) -> dict[str, Any]:
        """Return summary dict."""
        return {
            "suite": self.suite_name,
            "total": self.total,
            "passed": self.passed,
            "failed": self.failed,
            "partial": self.partial,
            "pass_rate": f"{self.pass_rate:.1%}",
            "average_score": f"{self.average_score:.2f}",
        }

    # v0.7.8 — JSON serialization / file I/O so eval reports become
    # CI-friendly artifacts (compare across runs, archive, attach to PRs).
    def to_dict(self) -> dict[str, Any]:
        """Serialize the report (and every nested case/result) to a dict."""
        return {
            "suite_name": self.suite_name,
            "results": [r.to_dict() for r in self.results],
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> EvalReport:
        """Reconstruct an :class:`EvalReport` from :meth:`to_dict` output."""
        return cls(
            suite_name=data.get("suite_name", ""),
            results=[EvalResult.from_dict(r) for r in data.get("results", [])],
        )

    def to_json(self, indent: int = 2) -> str:
        """Serialize the report to a JSON string."""
        return json.dumps(self.to_dict(), indent=indent)

    @classmethod
    def from_json(cls, payload: str) -> EvalReport:
        """Reconstruct an :class:`EvalReport` from a JSON string."""
        return cls.from_dict(json.loads(payload))

    def save(self, path: str) -> None:
        """Write the report to ``path`` as JSON.

        Creates parent directories if they do not exist.
        """
        from pathlib import Path  # local import keeps top of file unchanged

        Path(path).parent.mkdir(parents=True, exist_ok=True)
        Path(path).write_text(self.to_json(), encoding="utf-8")

    @classmethod
    def load(cls, path: str) -> EvalReport:
        """Load a report previously written by :meth:`save`.

        Returns an empty report if the file does not exist or is unreadable;
        a warning is logged in either failure mode.
        """
        from pathlib import Path

        p = Path(path)
        if not p.exists():
            logger.warning("Eval report not found at %s; returning empty report", path)
            return cls(suite_name="")

        try:
            return cls.from_dict(json.loads(p.read_text(encoding="utf-8")))
        except Exception as e:
            logger.warning("Failed to load eval report %s: %s", path, e)
            return cls(suite_name="")
```

## `loopy.EvalResult` (class)

Result of evaluating a single case.

```python
@dataclass
class EvalResult:
    """Result of evaluating a single case."""

    case: EvalCase
    actual_output: str
    verdict: Verdict
    score: float  # 0.0 to 1.0
    reasoning: str = ""
    criteria_scores: dict[str, float] = field(default_factory=dict)
    metadata: dict[str, Any] = field(default_factory=dict)

    # v0.7.8 — JSON round-trip helpers so EvalReport can be serialized whole.
    def to_dict(self) -> dict[str, Any]:
        return {
            "case": {
                "name": self.case.name,
                "input_text": self.case.input_text,
                "expected_output": self.case.expected_output,
                "criteria": list(self.case.criteria),
                "tags": list(self.case.tags),
                "threshold": self.case.threshold,
            },
            "actual_output": self.actual_output,
            "verdict": self.verdict.value,
            "score": self.score,
            "reasoning": self.reasoning,
            "criteria_scores": dict(self.criteria_scores),
            "metadata": dict(self.metadata),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> EvalResult:
        case_data = data.get("case", {})
        case = EvalCase(
            name=case_data.get("name", ""),
            input_text=case_data.get("input_text", ""),
            expected_output=case_data.get("expected_output"),
            criteria=list(case_data.get("criteria", [])),
            tags=list(case_data.get("tags", [])),
            threshold=case_data.get("threshold", 0.7),
        )
        return cls(
            case=case,
            actual_output=data.get("actual_output", ""),
            verdict=Verdict(data.get("verdict", "fail")),
            score=float(data.get("score", 0.0)),
            reasoning=data.get("reasoning", ""),
            criteria_scores=dict(data.get("criteria_scores", {})),
            metadata=dict(data.get("metadata", {})),
        )
```

## `loopy.EvalSuite` (class)

Collection of evaluation cases.

```python
@dataclass
class EvalSuite:
    """Collection of evaluation cases."""

    name: str
    cases: list[EvalCase] = field(default_factory=list)
    description: str = ""
```

## `loopy.Evaluator` (class)

Judge-based evaluation framework.

Uses an LLM as a judge to evaluate model outputs against criteria.

Example:
    evaluator = Evaluator(judge_fn=my_llm_judge)

    suite = EvalSuite(
        name="math_basic",
        cases=[
            EvalCase(
                name="addition",
                input_text="What is 2+2?",
                expected_output="4",
                criteria=["correct", "concise"],
            ),
        ],
    )

    report = evaluator.run(suite, model_fn=my_model)
    print(report.summary())

```python
class Evaluator:
    """
    Judge-based evaluation framework.

    Uses an LLM as a judge to evaluate model outputs against criteria.

    Example:
        evaluator = Evaluator(judge_fn=my_llm_judge)

        suite = EvalSuite(
            name="math_basic",
            cases=[
                EvalCase(
                    name="addition",
                    input_text="What is 2+2?",
                    expected_output="4",
                    criteria=["correct", "concise"],
                ),
            ],
        )

        report = evaluator.run(suite, model_fn=my_model)
        print(report.summary())
    """

    JUDGE_PROMPT = """You are an evaluation judge. Your task is to score a model's output.

Input: {input}
Expected Output: {expected}
Actual Output: {actual}
Criteria: {criteria}

Score each criterion from 0.0 to 1.0, then provide an overall score.
Respond in JSON:
{{
    "criteria_scores": {{"criterion": score}},
    "overall_score": 0.0-1.0,
    "reasoning": "explanation",
    "verdict": "pass" | "fail" | "partial"
}}"""

    def __init__(
        self,
        judge_fn: Callable[[str], Awaitable[str]] | None = None,
        model_fn: Callable[[str], Awaitable[str]] | None = None,
    ):
        self.judge_fn = judge_fn
        self.model_fn = model_fn

    async def run(
        self,
        suite: EvalSuite,
        model_fn: Callable[[str], Awaitable[str]] | None = None,
    ) -> EvalReport:
        """
        Run evaluation suite.

        Args:
            suite: The evaluation suite to run
            model_fn: Function to get model output (or use instance default)

        Returns:
            EvalReport with all results
        """
        fn = model_fn or self.model_fn
        if not fn:
            raise ValueError("No model function provided")

        report = EvalReport(suite_name=suite.name)

        for case in suite.cases:
            result = await self._eval_case(case, fn)
            report.results.append(result)

        return report

    async def _eval_case(
        self,
        case: EvalCase,
        model_fn: Callable[[str], Awaitable[str]],
    ) -> EvalResult:
        """Evaluate a single case."""
        # Get model output
        actual_output = await model_fn(case.input_text)

        # If no judge function, use simple string matching
        if not self.judge_fn:
            return self._simple_eval(case, actual_output)

        # Use LLM judge
        criteria_str = ", ".join(case.criteria) if case.criteria else "general quality"

        prompt = self.JUDGE_PROMPT.format(
            input=case.input_text,
            expected=case.expected_output or "N/A",
            actual=actual_output,
            criteria=criteria_str,
        )

        judge_response = await self.judge_fn(prompt)

        try:
            # Parse judge response
            data = json.loads(judge_response)
            score = float(data.get("overall_score", 0.0))
            verdict = Verdict(data.get("verdict", "fail"))

            return EvalResult(
                case=case,
                actual_output=actual_output,
                verdict=verdict,
                score=score,
                reasoning=data.get("reasoning", ""),
                criteria_scores=data.get("criteria_scores", {}),
            )
        except (json.JSONDecodeError, ValueError):
            # Fallback to simple eval
            return self._simple_eval(case, actual_output)

    def _simple_eval(self, case: EvalCase, actual_output: str) -> EvalResult:
        """Simple string-based evaluation when no judge is available."""
        if case.expected_output:
            # Exact match
            if actual_output.strip() == case.expected_output.strip():
                score = 1.0
                verdict = Verdict.PASS
            # Partial match (contains expected)
            elif case.expected_output.lower() in actual_output.lower():
                score = 0.7
                verdict = Verdict.PARTIAL
            else:
                score = 0.0
                verdict = Verdict.FAIL
        else:
            # No expected output, just check it's not empty
            score = 1.0 if actual_output.strip() else 0.0
            verdict = Verdict.PASS if actual_output.strip() else Verdict.FAIL

        return EvalResult(
            case=case,
            actual_output=actual_output,
            verdict=verdict,
            score=score,
            reasoning="Simple string matching (no judge function)",
        )
```

## `loopy.FallbackMiddleware` (class)

Provider failover middleware.

Returns a fallback result (from a callable or static data)
when the primary handler raises an exception.

Args:
    fallback_fn: Async callable ``(ctx, error) -> result``.
    fallback_data: Static dict to return as fallback result.

```python
class FallbackMiddleware(Middleware):
    """Provider failover middleware.

    Returns a fallback result (from a callable or static data)
    when the primary handler raises an exception.

    Args:
        fallback_fn: Async callable ``(ctx, error) -> result``.
        fallback_data: Static dict to return as fallback result.
    """

    def __init__(
        self,
        fallback_fn: Callable[[MiddlewareContext, Any], Awaitable[Any]] | None = None,
        fallback_data: dict[str, Any] | None = None,
    ):
        self.fallback_fn = fallback_fn
        self.fallback_data = fallback_data

    async def on_error(self, ctx: MiddlewareContext, error: Exception) -> Exception:
        """Attempt fallback when the primary handler fails."""
        if self.fallback_fn:
            try:
                result = await self.fallback_fn(ctx, error)
                ctx.metadata["fallback_result"] = result
                ctx.metadata["fallback_used"] = True
                logger.info("Fallback used for %s", ctx.operation)
            except Exception as fallback_error:
                logger.error("Fallback also failed: %s", fallback_error)
                return error
        elif self.fallback_data:
            ctx.metadata["fallback_result"] = self.fallback_data
            ctx.metadata["fallback_used"] = True

        return error
```

## `loopy.FilterAction` (class)

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
class FilterAction(str, Enum):
    BLOCK = "block"
    REDACT = "redact"
    WARN = "warn"
    PASS = "pass"
```

## `loopy.Gateway` (class)

AI Gateway for routing LLM requests across providers.

Supports both standalone and async context manager usage.

Example (async context manager):
    async with Gateway() as gateway:
        gateway.add_provider("openai", ProviderConfig(...))
        response = await gateway.chat("What is 2+2?")

Example (standalone):
    gateway = Gateway()
    gateway.add_provider("openai", ProviderConfig(...))
    response = await gateway.chat("What is 2+2?", provider="openai")
    await gateway.close()

```python
class Gateway:
    """
    AI Gateway for routing LLM requests across providers.

    Supports both standalone and async context manager usage.

    Example (async context manager):
        async with Gateway() as gateway:
            gateway.add_provider("openai", ProviderConfig(...))
            response = await gateway.chat("What is 2+2?")

    Example (standalone):
        gateway = Gateway()
        gateway.add_provider("openai", ProviderConfig(...))
        response = await gateway.chat("What is 2+2?", provider="openai")
        await gateway.close()
    """

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

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """Async context manager exit."""
        await self.close()
        return False

    def __init__(self, *, policy_engine: Any = None):
        self.providers: dict[str, ProviderConfig] = {}
        self._pool: ConnectionPool = ConnectionPool()
        self._logs: list[dict[str, Any]] = []
        # v0.9.0 — optional Compliance-as-Code policy engine. When
        # set, every chat() call evaluates the policies and raises
        # ``PolicyViolation`` on a ``block`` decision *before* any
        # provider I/O. ``warn`` / ``info`` decisions are recorded
        # but do not abort the call.
        self.policy_engine = policy_engine

    def add_provider(self, name: str, config: ProviderConfig) -> None:
        """Register a provider."""
        self.providers[name] = config
        logger.info("Added provider: %s (%s)", name, config.provider.value)

    def _resolve_provider(self, provider: str | None = None) -> tuple[str, ProviderConfig]:
        """Resolve a provider name to a (name, config) pair.

        Args:
            provider: Preferred provider name, or *None* for the first
                      available provider.

        Returns:
            A tuple of (provider_name, ProviderConfig).

        Raises:
            ValueError: If no providers are configured.
        """
        if provider and provider in self.providers:
            return provider, self.providers[provider]
        if self.providers:
            name, config = next(iter(self.providers.items()))
            return name, config
        raise ValueError("No providers configured. Call add_provider() first.")

    def _resolve_provider_with_cap(
        self,
        requested: str | None,
        max_tokens: int,
        max_cost_usd: float | None,
    ) -> tuple[str, ProviderConfig]:
        """v0.9.0 — Cost-Aware Routing.

        Resolve the provider (using :meth:`_resolve_provider` for
        the no-cap case) and then enforce ``max_cost_usd``. If the
        resolved provider's estimated cost fits inside the cap,
        return it as-is. Otherwise, find the cheapest configured
        provider that fits, and log the fallback. If no provider
        fits, raise :class:`BudgetExceeded`.

        When ``max_cost_usd is None`` the cap is a no-op and this
        method is a thin wrapper around :meth:`_resolve_provider`.
        """
        name, config = self._resolve_provider(requested)

        if max_cost_usd is None:
            return name, config

        from loopy.cost import BudgetExceeded

        estimated = config.estimate_cost_usd(max_tokens=max_tokens)
        if estimated <= max_cost_usd:
            return name, config

        # Look for a cheaper provider that fits inside the cap.
        candidates: list[tuple[str, ProviderConfig, float]] = []
        for candidate_name, candidate_cfg in self.providers.items():
            cost = candidate_cfg.estimate_cost_usd(max_tokens=max_tokens)
            if cost <= max_cost_usd:
                candidates.append((candidate_name, candidate_cfg, cost))
        if not candidates:
            raise BudgetExceeded(
                limit=int(max_cost_usd * 1000),
                used=int(estimated * 1000),
            )

        # Pick the cheapest candidate.
        candidates.sort(key=lambda c: c[2])
        chosen_name, chosen_cfg, chosen_cost = candidates[0]
        if chosen_name != name:
            self._logs.append(
                {
                    "event": "cost_fallback",
                    "from_provider": name,
                    "to_provider": chosen_name,
                    "estimated_usd": estimated,
                    "chosen_usd": chosen_cost,
                    "cap_usd": max_cost_usd,
                }
            )
        return chosen_name, chosen_cfg

    # Dispatch table for provider-specific API calls
    _PROVIDER_HANDLERS: dict[ModelProvider, str] = {
        ModelProvider.OPENAI: "_call_openai",
        ModelProvider.ANTHROPIC: "_call_anthropic",
        ModelProvider.OLLAMA: "_call_ollama",
    }

    async def chat(
        self,
        message: str,
        provider: str | None = None,
        system: str | None = None,
        temperature: float = 0.7,
        max_tokens: int = 1000,
        *,
        model: TestModel | str | None = None,
        response_format: type[BaseModel] | None = None,
        max_cost_usd: float | None = None,
        **kwargs,
    ) -> GatewayResponse:
        """
        Send a chat completion request through the gateway.

        Routes to the specified provider, or the first available if
        *provider* is *None*.

        Args:
            message: The user message.
            provider: Provider name to route to.
            system: Optional system prompt.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.
            model: v0.7.9 - When set to a : ``TestModel`` (or the
                sentinel string ``"test"``), the request is satisfied
                locally without any HTTP/SDK call. Useful for unit
                tests and CI.
            response_format: v0.7.9 - When set to a Pydantic
                ``BaseModel`` subclass, the gateway validates the
                reply against that schema and returns the instance in
                ``GatewayResponse.structured``.
            **kwargs: Additional arguments (ignored).

        Returns:
            A GatewayResponse with the model reply. When
            ``response_format`` is provided, ``structured`` holds the
            validated Pydantic instance (or ``None`` if validation
            failed).

        Raises:
            ValueError: If no providers are configured and no test
                model is supplied.
            RuntimeError: If the provider's rate limit is exceeded.
        """
        # v0.7.9 - Test model routing: short-circuit to local handler.
        test_model = self._resolve_test_model(model)
        if test_model is not None:
            # v0.9.0 — Compliance-as-Code: policies still apply on
            # the test-model path so unit tests can exercise the gate.
            if self.policy_engine is not None:
                context = dict(kwargs.get("policy_context") or {})
                context.setdefault("provider", "test")
                context.setdefault("max_tokens", max_tokens)
                self.policy_engine.gate(context)
            # v0.9.0 — Cost cap: the test-model path also enforces the
            # cap so unit tests can exercise the guard end-to-end.
            # When no providers are configured, the cap is a no-op
            # (cost is unknown).
            if max_cost_usd is not None and self.providers:
                _, _ = self._resolve_provider_with_cap(
                    provider,
                    max_tokens,
                    max_cost_usd,
                )
            return await self._call_test(
                test_model,
                message,
                system,
                temperature,
                max_tokens,
                response_format,
            )

        provider, config = self._resolve_provider_with_cap(provider, max_tokens, max_cost_usd)

        # v0.9.0 — Compliance-as-Code: evaluate policies before any
        # provider I/O. ``gate()`` raises ``PolicyViolation`` on a
        # ``block`` decision; the caller may also pass
        # ``policy_context={...}`` to feed runtime data into the
        # evaluation (cost estimates, PII flags, etc.).
        if self.policy_engine is not None:
            context = dict(kwargs.get("policy_context") or {})
            context.setdefault("provider", provider)
            context.setdefault("max_tokens", max_tokens)
            self.policy_engine.gate(context)

        # Check rate limits
        config.check_rate_limit()

        # Route to provider
        start_time = time.time()

        try:
            handler_name = self._PROVIDER_HANDLERS.get(config.provider)
            if handler_name is None:
                raise ValueError(f"Unsupported provider: {config.provider}")
            handler = getattr(self, handler_name)
            response = await handler(config, message, system, temperature, max_tokens)
        except Exception as e:
            logger.error("Gateway error (%s): %s", provider, e)
            raise

        latency_ms = (time.time() - start_time) * 1000

        # Log request
        log_entry = {
            "provider": provider,
            "model": config.model,
            "latency_ms": latency_ms,
            "tokens": response.tokens_used,
            "timestamp": time.time(),
        }
        self._logs.append(log_entry)
        config.record_request()

        response.latency_ms = latency_ms

        # v0.7.9 - Structured output: validate the reply against the
        # requested Pydantic schema. Failure logs a warning and sets
        # ``structured`` to None so callers can detect + retry.
        if response_format is not None:
            try:
                response.structured = response_format.model_validate_json(response.content)
            except Exception as e:
                logger.warning("Structured output validation failed: %s", e)
                response.structured = None
        return response

    async def _call_openai(
        self,
        config: ProviderConfig,
        message: str,
        system: str | None,
        temperature: float,
        max_tokens: int,
    ) -> GatewayResponse:
        """Route a chat request to the OpenAI API.

        Args:
            config: Provider configuration.
            message: The user message.
            system: Optional system prompt.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.

        Returns:
            A GatewayResponse with the model reply.
        """
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": message})

        client = await self._pool.get_connection("openai")
        response = await client.post(
            f"{config.base_url or 'https://api.openai.com/v1'}/chat/completions",
            headers={"Authorization": f"Bearer {config.api_key}"},
            json={
                "model": config.model,
                "messages": messages,
                "temperature": temperature,
                "max_tokens": max_tokens,
            },
        )
        response.raise_for_status()
        data = response.json()

        return GatewayResponse(
            content=data["choices"][0]["message"]["content"],
            model=config.model,
            provider=ModelProvider.OPENAI,
            tokens_used=data.get("usage", {}).get("total_tokens", 0),
        )

    async def _call_anthropic(
        self,
        config: ProviderConfig,
        message: str,
        system: str | None,
        temperature: float,
        max_tokens: int,
    ) -> GatewayResponse:
        """Route a chat request to the Anthropic API.

        Args:
            config: Provider configuration.
            message: The user message.
            system: Optional system prompt.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.

        Returns:
            A GatewayResponse with the model reply.
        """
        body: dict[str, Any] = {
            "model": config.model,
            "messages": [{"role": "user", "content": message}],
            "temperature": temperature,
            "max_tokens": max_tokens,
        }
        if system:
            body["system"] = system

        client = await self._pool.get_connection("anthropic")
        response = await client.post(
            f"{config.base_url or 'https://api.anthropic.com/v1'}/messages",
            headers={
                "x-api-key": config.api_key or "",
                "anthropic-version": "2023-06-01",
            },
            json=body,
        )
        response.raise_for_status()
        data = response.json()

        return GatewayResponse(
            content=data["content"][0]["text"],
            model=config.model,
            provider=ModelProvider.ANTHROPIC,
            tokens_used=data.get("usage", {}).get("input_tokens", 0)
            + data.get("usage", {}).get("output_tokens", 0),
        )

    async def _call_ollama(
        self,
        config: ProviderConfig,
        message: str,
        system: str | None,
        temperature: float,
        max_tokens: int,
    ) -> GatewayResponse:
        """Route a chat request to a local Ollama instance.

        Args:
            config: Provider configuration.
            message: The user message.
            system: Optional system prompt.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.

        Returns:
            A GatewayResponse with the model reply.
        """
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": message})

        client = await self._pool.get_connection("ollama")
        response = await client.post(
            f"{config.base_url or 'http://localhost:11434'}/api/chat",
            json={
                "model": config.model,
                "messages": messages,
                "stream": False,
            },
        )
        response.raise_for_status()
        data = response.json()

        return GatewayResponse(
            content=data["message"]["content"],
            model=config.model,
            provider=ModelProvider.OLLAMA,
            tokens_used=data.get("eval_count", 0),
        )

    async def chat_batch(
        self,
        messages: list[str],
        provider: str | None = None,
        system: str | None = None,
        temperature: float = 0.7,
        max_tokens: int = 1000,
        max_concurrent: int = 5,
    ) -> list[GatewayResponse]:
        """
        Send multiple chat requests concurrently.

        Args:
            messages: List of messages to send.
            provider: Provider name (or first available).
            system: Optional system prompt for all requests.
            temperature: Temperature for all requests.
            max_tokens: Max tokens for all requests.
            max_concurrent: Max concurrent requests.

        Returns:
            List of GatewayResponse objects.
        """
        semaphore = asyncio.Semaphore(max_concurrent)

        async def _single_chat(msg: str) -> GatewayResponse:
            async with semaphore:
                return await self.chat(
                    message=msg,
                    provider=provider,
                    system=system,
                    temperature=temperature,
                    max_tokens=max_tokens,
                )

        tasks = [_single_chat(msg) for msg in messages]
        return await asyncio.gather(*tasks)

    async def chat_streaming(
        self,
        message: str,
        provider: str | None = None,
        system: str | None = None,
        temperature: float = 0.7,
        max_tokens: int = 1000,
    ) -> AsyncGenerator[str, None]:
        """
        Send a streaming chat request.

        Yields content chunks as they arrive from the provider.
        Falls back to a single non-streaming call for providers
        other than OpenAI.

        Args:
            message: The user message.
            provider: Provider name (or first available).
            system: Optional system prompt.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.

        Yields:
            Content strings as they are received.
        """
        provider, config = self._resolve_provider(provider)

        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": message})

        if config.provider == ModelProvider.OPENAI:
            client = await self._pool.get_connection("openai")
            async with client.stream(
                "POST",
                f"{config.base_url or 'https://api.openai.com/v1'}/chat/completions",
                headers={"Authorization": f"Bearer {config.api_key}"},
                json={
                    "model": config.model,
                    "messages": messages,
                    "temperature": temperature,
                    "max_tokens": max_tokens,
                    "stream": True,
                },
            ) as response:
                response.raise_for_status()
                async for line in response.aiter_lines():
                    if line.startswith("data: ") and line != "data: [DONE]":
                        data = json.loads(line[6:])
                        delta = data.get("choices", [{}])[0].get("delta", {})
                        if "content" in delta:
                            yield delta["content"]
        else:
            # Fallback to non-streaming for other providers
            result = await self.chat(message, provider, system, temperature, max_tokens)
            yield result.content

    def _resolve_test_model(
        self,
        model: TestModel | str | None,
    ) -> TestModel | None:
        """v0.7.9 - Normalize the chat(model=) argument.

        Accepts a TestModel, the sentinel string 'test', or None.
        Returns the resolved TestModel, or None if the caller wants
        the normal HTTP path.
        """
        return _resolve_test_model_arg(model)

    async def _call_test(
        self,
        test_model: TestModel,
        message: str,
        system: str | None,
        temperature: float,
        max_tokens: int,
        response_format: type[BaseModel] | None,
    ) -> GatewayResponse:
        """v0.7.9 - Dispatch a chat call to a local TestModel.

        Bypasses network and rate limits; logs the call under a
        synthetic provider entry so get_logs() / cost tracking still
        observe the test traffic.
        """
        start_time = time.time()
        response = await test_model.handle(
            message,
            system,
            temperature,
            max_tokens,
            response_format,
        )
        response.latency_ms = (time.time() - start_time) * 1000
        self._logs.append(
            {
                "provider": "test",
                "model": test_model.model_name,
                "latency_ms": response.latency_ms,
                "tokens": response.tokens_used,
                "timestamp": time.time(),
            }
        )
        return response

    def get_logs(self) -> list[dict[str, Any]]:
        """Return request logs."""
        return self._logs.copy()

    async def close(self) -> None:
        """Close the connection pool."""
        await self._pool.close()
```

## `loopy.GatewayResponse` (class)

Unified response from the gateway.

```python
@dataclass
class GatewayResponse:
    """Unified response from the gateway."""

    content: str
    model: str
    provider: ModelProvider
    tokens_used: int = 0
    latency_ms: float = 0
    cached: bool = False
    metadata: dict[str, Any] = field(default_factory=dict)
    # v0.7.9 — populated when ``chat(response_format=...)`` was used;
    # validated Pydantic instance, or ``None`` if validation failed.
    structured: Any | None = None
```

## `loopy.GuardrailPipeline` (class)

Full guardrail pipeline with input and output filters.

Example:
    pipeline = GuardrailPipeline()

    # Check user input
    input_result = pipeline.filter_input("Tell me about 123-45-6789")

    # ... process with LLM ...

    # Check model output
    output_result = pipeline.filter_output("Here's the info...")

```python
class GuardrailPipeline:
    """
    Full guardrail pipeline with input and output filters.

    Example:
        pipeline = GuardrailPipeline()

        # Check user input
        input_result = pipeline.filter_input("Tell me about 123-45-6789")

        # ... process with LLM ...

        # Check model output
        output_result = pipeline.filter_output("Here's the info...")
    """

    def __init__(self, config: GuardrailConfig | None = None):
        self.config = config or GuardrailConfig()
        self.input_filter = InputFilter(self.config)
        self.output_filter = OutputFilter(self.config)
        self._history: list[dict[str, Any]] = []

    def filter_input(self, text: str) -> FilterResult:
        """Filter user input."""
        result = self.input_filter.check(text)
        self._history.append(
            {
                "direction": "input",
                "result": result,
            }
        )
        return result

    def filter_output(self, text: str) -> FilterResult:
        """Filter model output."""
        result = self.output_filter.check(text)
        self._history.append(
            {
                "direction": "output",
                "result": result,
            }
        )
        return result

    def get_history(self) -> list[dict[str, Any]]:
        """Return filter history."""
        return self._history.copy()
```

## Hook

> Not exported by `loopy`.

## HookContext

> Not exported by `loopy`.

## HookRegistry

> Not exported by `loopy`.

## HookResult

> Not exported by `loopy`.

## HookType

> Not exported by `loopy`.

## ImageFormat

> Not exported by `loopy`.

## `loopy.InputFilter` (class)

Filters user input for PII, jailbreak attempts, and harmful content.

Example:
    filter = InputFilter()
    result = filter.check("My SSN is 123-45-6789")
    # result.action == FilterAction.REDACT
    # result.filtered == "My SSN is [SSN_REDACTED]"

```python
class InputFilter:
    """
    Filters user input for PII, jailbreak attempts, and harmful content.

    Example:
        filter = InputFilter()
        result = filter.check("My SSN is 123-45-6789")
        # result.action == FilterAction.REDACT
        # result.filtered == "My SSN is [SSN_REDACTED]"
    """

    # PII Patterns
    PATTERNS = {
        "ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
        "email": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"),
        "phone": re.compile(r"\b(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}\b"),
        "credit_card": re.compile(r"\b(?:\d[ -]*?){13,19}\b"),
        "ip_address": re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"),
    }

    # Jailbreak patterns (simplified - production would use ML)
    JAILBREAK_PATTERNS = [
        re.compile(r"ignore (?:all |any )?(?:previous |prior |your )?instructions", re.I),
        re.compile(r"you are now (?:a |an )?(?: DAN |jailbroken |unrestricted)", re.I),
        re.compile(r"(?:pretend|act) (?:you (?:are|have) |as if )no (?:rules|restrictions)", re.I),
        re.compile(r"bypass (?:all |any )?(?:safety|content|filter)", re.I),
        re.compile(r"do anything now", re.I),
        re.compile(r"developer mode", re.I),
        re.compile(r"jailbreak", re.I),
        re.compile(r"ignore safety", re.I),
    ]

    def __init__(self, config: GuardrailConfig | None = None):
        self.config = config or GuardrailConfig()

    def check(self, text: str) -> FilterResult:
        """Check input text against all configured filters."""
        reasons = []
        filtered = text
        should_redact = False

        # Check PII
        pii_checks = {
            "ssn": self.config.detect_ssn,
            "email": self.config.detect_email,
            "phone": self.config.detect_phone,
            "credit_card": self.config.detect_credit_card,
            "ip_address": self.config.detect_ip_address,
        }

        for pii_type, enabled in pii_checks.items():
            if enabled and pii_type in self.PATTERNS and self.PATTERNS[pii_type].search(filtered):
                reasons.append(f"detected_{pii_type}")
                should_redact = True
                replacement = f"[{pii_type.upper()}_REDACTED]"
                filtered = self.PATTERNS[pii_type].sub(replacement, filtered)

        # Check jailbreak
        if self.config.detect_jailbreak:
            for pattern in self.JAILBREAK_PATTERNS:
                if pattern.search(text):
                    reasons.append("jailbreak_attempt")
                    return FilterResult(
                        action=FilterAction.BLOCK,
                        original=text,
                        filtered="",
                        reasons=reasons,
                    )

        # Check custom blocked patterns
        for pattern_str in self.config.blocked_patterns:
            if re.search(pattern_str, text, re.I):
                reasons.append(f"blocked_pattern:{pattern_str}")
                return FilterResult(
                    action=FilterAction.BLOCK,
                    original=text,
                    filtered="",
                    reasons=reasons,
                )

        # Check blocked keywords
        text_lower = text.lower()
        for keyword in self.config.blocked_keywords:
            if keyword.lower() in text_lower:
                reasons.append(f"blocked_keyword:{keyword}")
                return FilterResult(
                    action=FilterAction.BLOCK,
                    original=text,
                    filtered="",
                    reasons=reasons,
                )

        if should_redact:
            return FilterResult(
                action=FilterAction.REDACT,
                original=text,
                filtered=filtered,
                reasons=reasons,
            )

        return FilterResult(
            action=FilterAction.PASS,
            original=text,
            filtered=text,
            reasons=[],
        )
```

## `loopy.JudgeConfig` (class)

Configuration for LLM-as-judge evaluation.

```python
@dataclass
class JudgeConfig:
    """Configuration for LLM-as-judge evaluation."""

    evaluator_model: str = "gpt-4"
    criteria: list[str] = field(default_factory=list)
    threshold: float = 0.7  # pass threshold
    prompt_template: str = """Rate this output on the given criteria.

Input: {input}
Output: {output}
Criteria: {criteria}

Respond with JSON:
{{
    "score": 0.0-1.0,
    "pass": true/false,
    "feedback": "explanation"
}}"""
```

## `loopy.LLMCache` (class)

Semantic cache for LLM responses.

Caches responses by hashing the prompt + model combination.
Supports TTL, size limits, and persistence.

Example:
    cache = LLMCache(ttl=3600, max_size=1000)

    # Check cache before calling LLM
    cached = cache.get("What is Python?", model="gpt-4")
    if cached:
        response = cached
    else:
        response = await call_llm("What is Python?")
        cache.set("What is Python?", response, model="gpt-4")

    stats = cache.stats()
    print(f"Cache hit rate: {stats.hit_rate:.1%}")

```python
class LLMCache:
    """
    Semantic cache for LLM responses.

    Caches responses by hashing the prompt + model combination.
    Supports TTL, size limits, and persistence.

    Example:
        cache = LLMCache(ttl=3600, max_size=1000)

        # Check cache before calling LLM
        cached = cache.get("What is Python?", model="gpt-4")
        if cached:
            response = cached
        else:
            response = await call_llm("What is Python?")
            cache.set("What is Python?", response, model="gpt-4")

        stats = cache.stats()
        print(f"Cache hit rate: {stats.hit_rate:.1%}")
    """

    def __init__(
        self,
        ttl: int = 3600,
        max_size: int = 1000,
        persist_path: str | Path | None = None,
    ):
        """
        Args:
            ttl: Time-to-live in seconds
            max_size: Maximum number of entries
            persist_path: Optional path to persist cache to disk
        """
        self.ttl = ttl
        self.max_size = max_size
        self.persist_path = Path(persist_path) if persist_path else None

        self._cache: dict[str, CacheEntry] = {}
        self._stats = CacheStats()

        # Load persisted cache
        if self.persist_path and self.persist_path.exists():
            self._load()

    def _make_key(self, prompt: str, model: str, **kwargs: Any) -> str:
        """Generate cache key from prompt and model."""
        key_data = {
            "prompt": prompt,
            "model": model,
            **kwargs,
        }
        key_str = json.dumps(key_data, sort_keys=True)
        return hashlib.sha256(key_str.encode()).hexdigest()[:16]

    def get(self, prompt: str, model: str, **kwargs: Any) -> str | None:
        """
        Get cached response if available.

        Returns:
            Cached response string or None
        """
        key = self._make_key(prompt, model, **kwargs)

        entry = self._cache.get(key)
        if not entry:
            self._stats.misses += 1
            return None

        # Check TTL
        if time.time() - entry.created_at > self.ttl:
            del self._cache[key]
            self._stats.misses += 1
            return None

        # Update access stats
        entry.last_accessed = time.time()
        entry.access_count += 1
        self._stats.hits += 1
        self._stats.total_saved_tokens += entry.tokens_saved

        logger.debug("Cache hit: %s... (accessed %dx)", key[:8], entry.access_count)
        return entry.response

    def set(
        self,
        prompt: str,
        response: str,
        model: str,
        tokens: int = 0,
        **kwargs: Any,
    ) -> None:
        """
        Cache a response.

        Args:
            prompt: The input prompt
            response: The model's response
            model: Model identifier
            tokens: Number of tokens in the response (for savings tracking)
        """
        key = self._make_key(prompt, model, **kwargs)

        # Evict if at capacity
        if len(self._cache) >= self.max_size and key not in self._cache:
            self._evict()

        self._cache[key] = CacheEntry(
            key=key,
            response=response,
            model=model,
            tokens_saved=tokens,
        )

        logger.debug("Cached response: %s... (%d tokens)", key[:8], tokens)

        # Persist if configured
        if self.persist_path:
            self._save()

    def invalidate(self, prompt: str, model: str, **kwargs: Any) -> bool:
        """Remove a specific entry from cache."""
        key = self._make_key(prompt, model, **kwargs)
        if key in self._cache:
            del self._cache[key]
            return True
        return False

    async def aget(self, prompt: str, model: str, **kwargs: Any) -> str | None:
        """v0.7.8 — Async wrapper around :meth:`get`.

        Identical semantics; provided so async callers can `await` without
        having to drop into a thread executor themselves.
        """
        return self.get(prompt, model, **kwargs)

    async def aset(
        self,
        prompt: str,
        response: str,
        model: str,
        tokens: int = 0,
        **kwargs: Any,
    ) -> None:
        """v0.7.8 — Async wrapper around :meth:`set` with non-blocking I/O.

        Mirrors the v0.7.7 ``MemoryStore`` async-save pattern: the in-memory
        write happens synchronously (cheap), and disk persistence — when
        ``persist_path`` is configured — runs in a worker thread via
        ``asyncio.to_thread`` so a slow filesystem cannot stall the loop.
        """
        key = self._make_key(prompt, model, **kwargs)

        if len(self._cache) >= self.max_size and key not in self._cache:
            self._evict()

        self._cache[key] = CacheEntry(
            key=key,
            response=response,
            model=model,
            tokens_saved=tokens,
        )

        if self.persist_path:
            await self._asave()

    def clear(self) -> None:
        """Clear all cached entries."""
        self._cache.clear()
        self._stats = CacheStats()
        logger.info("Cache cleared")

    def stats(self) -> CacheStats:
        """Return cache statistics."""
        return self._stats

    def _evict(self) -> None:
        """Evict the least recently used cache entry.

        Uses *last_accessed* timestamps to find the LRU entry.
        Called automatically when the cache is at capacity.
        """
        if not self._cache:
            return

        # Find LRU entry
        lru_key = min(self._cache, key=lambda k: self._cache[k].last_accessed)
        del self._cache[lru_key]
        logger.debug("Evicted LRU entry: %s...", lru_key[:8])

    def _save(self) -> None:
        """Persist the in-memory cache to disk as JSON.

        Creates parent directories if they don't exist.
        Silently skips if *persist_path* was not configured.
        """
        if not self.persist_path:
            return

        self.persist_path.parent.mkdir(parents=True, exist_ok=True)

        data = {}
        for key, entry in self._cache.items():
            data[key] = {
                "response": entry.response,
                "model": entry.model,
                "tokens_saved": entry.tokens_saved,
                "created_at": entry.created_at,
            }

        self.persist_path.write_text(json.dumps(data, indent=2))

    async def _asave(self) -> None:
        """v0.7.8 - Async persistence; runs the blocking write in a worker.

        Snapshots the cache into a plain dict on the event-loop thread
        (cheap), then writes the JSON file via ``asyncio.to_thread`` so a
        slow disk never blocks other coroutines.
        """
        if not self.persist_path:
            return

        self.persist_path.parent.mkdir(parents=True, exist_ok=True)
        payload = {
            key: {
                "response": entry.response,
                "model": entry.model,
                "tokens_saved": entry.tokens_saved,
                "created_at": entry.created_at,
            }
            for key, entry in self._cache.items()
        }

        def _write() -> None:
            self.persist_path.write_text(json.dumps(payload, indent=2))

        await asyncio.to_thread(_write)

    def _load(self) -> None:
        """Restore the in-memory cache from the persisted JSON file.

        Silently skips if no persisted file exists.
        On parse failure, starts with an empty cache and logs a warning.
        """
        if not self.persist_path or not self.persist_path.exists():
            return

        try:
            data = json.loads(self.persist_path.read_text())
            for key, entry_data in data.items():
                self._cache[key] = CacheEntry(
                    key=key,
                    response=entry_data["response"],
                    model=entry_data["model"],
                    tokens_saved=entry_data.get("tokens_saved", 0),
                    created_at=entry_data.get("created_at", time.time()),
                )
            logger.info("Loaded %d entries from cache", len(self._cache))
        except Exception as e:
            logger.warning("Failed to load cache: %s", e)
```

## `loopy.LocalMCP` (class)

Local MCP server for testing without a running server.

Registers tools locally and routes calls to handlers.

Example:
    mcp = LocalMCP()

    @mcp.tool("get_weather", "Get weather for a city")
    async def get_weather(city: str) -> str:
        return f"Sunny in {city}"

    result = await mcp.call_tool("get_weather", {"city": "Portland"})

```python
class LocalMCP:
    """
    Local MCP server for testing without a running server.

    Registers tools locally and routes calls to handlers.

    Example:
        mcp = LocalMCP()

        @mcp.tool("get_weather", "Get weather for a city")
        async def get_weather(city: str) -> str:
            return f"Sunny in {city}"

        result = await mcp.call_tool("get_weather", {"city": "Portland"})
    """

    def __init__(self):
        self._tools: dict[str, Tool] = {}
        self._handlers: dict[str, Callable[..., Awaitable[Any]]] = {}

    def tool(
        self,
        name: str,
        description: str = "",
        input_schema: dict[str, Any] | None = None,
    ) -> Callable:
        """Decorator to register a tool handler."""

        def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable:
            self._tools[name] = Tool(
                name=name,
                description=description,
                input_schema=input_schema or {},
            )
            self._handlers[name] = fn
            return fn

        return decorator

    async def list_tools(self) -> list[Tool]:
        """List registered tools."""
        return list(self._tools.values())

    async def call_tool(
        self,
        name: str,
        arguments: dict[str, Any] | None = None,
    ) -> MCPToolResult:
        """Call a registered tool."""
        if name not in self._handlers:
            return MCPToolResult(
                content=f"Tool not found: {name}",
                is_error=True,
            )

        try:
            result = await self._handlers[name](**(arguments or {}))
            return MCPToolResult(content=str(result))
        except Exception as e:
            return MCPToolResult(
                content=str(e),
                is_error=True,
            )
```

## `loopy.LoggingMiddleware` (class)

Logs all operations.

```python
class LoggingMiddleware(Middleware):
    """Logs all operations."""

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        logger.info("[%s] Starting with %d data fields", ctx.operation, len(ctx.data))
        return ctx

    async def after(self, ctx: MiddlewareContext, result: Any) -> Any:
        logger.info("[%s] Completed", ctx.operation)
        return result

    async def on_error(self, ctx: MiddlewareContext, error: Exception) -> Exception:
        logger.error("[%s] Failed: %s", ctx.operation, error)
        return error
```

## `loopy.LoopConfig` (class)

Configuration for the agentic loop.

```python
@dataclass
class LoopConfig:
    """Configuration for the agentic loop."""

    max_steps: int = 10
    max_retries: int = 3
    stop_on_error: bool = False

    # Callbacks
    planner: Callable[[list[StepResult]], Awaitable[str]] | None = None
    actor: Callable[[str], Awaitable[str]] | None = None
    observer: Callable[[str], Awaitable[str]] | None = None
    reflector: Callable[[list[StepResult]], Awaitable[str]] | None = None

    # Optional: custom stop condition
    should_stop: Callable[[list[StepResult]], Awaitable[bool]] | None = None

    # v0.7.8 — Resume from checkpoint
    # Set to an integer step number to skip ahead, or leave None to start fresh.
    # When `state_manager` is provided, history is checkpointed after each step
    # and a `RunRecord` is appended so crashed runs can be resumed.
    resume_from: int | None = None
    state_manager: StateManager | None = None
    task: str = ""  # Label for RunRecord metadata

    # v0.9.0 — Compliance-as-Code policy engine. When set, the loop
    # evaluates the policies before each step and raises
    # ``PolicyViolation`` on a ``block`` decision. ``warn`` / ``info``
    # decisions are recorded but do not abort the loop. When
    # ``state_manager`` is configured, the per-step decisions are
    # persisted as ``metadata["policies"]`` on the saved LoopState.
    policy_engine: Any = None

    # v0.8.0 — Human-in-the-loop interrupts
    # Each list is a set of phase names that should pause BEFORE / AFTER
    # running, returning an :class:`Interrupt` to the caller for review.
    # Phase names: ``"plan"``, ``"actor"``, ``"observer"``, ``"reflector"``.
    interrupt_before: list[str] | None = None
    interrupt_after: list[str] | None = None

    def __post_init__(self) -> None:
        # Negative control: cannot configure interrupts on a zero-step loop.
        if (self.interrupt_before or self.interrupt_after) and self.max_steps <= 0:
            raise ValueError("interrupt_before / interrupt_after require max_steps >= 1")
        for phase in self.interrupt_before or []:
            if phase not in {"plan", "actor", "observer", "reflector"}:
                raise ValueError(
                    f"interrupt_before: unknown phase {phase!r}; "
                    "must be one of plan/actor/observer/reflector"
                )
        for phase in self.interrupt_after or []:
            if phase not in {"plan", "actor", "observer", "reflector"}:
                raise ValueError(
                    f"interrupt_after: unknown phase {phase!r}; "
                    "must be one of plan/actor/observer/reflector"
                )
```

## `loopy.LoopPattern` (class)

A reusable loop pattern template.

```python
@dataclass
class LoopPattern:
    """A reusable loop pattern template."""

    name: str
    description: str
    cadence: PatternCadence
    risk: RiskLevel
    readiness_level: str  # L1, L2, or L3

    def to_dict(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "description": self.description,
            "cadence": self.cadence.value,
            "risk": self.risk.value,
            "readiness_level": self.readiness_level,
        }
```

## `loopy.LoopState` (class)

Durable state for an agent loop.

```python
@dataclass
class LoopState:
    """Durable state for an agent loop."""

    current_task: str | None = None
    attempts: int = 0
    max_attempts: int = 5
    history: list[RunRecord] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)

    @property
    def total_tokens(self) -> int:
        return sum(r.tokens_used for r in self.history)

    @property
    def last_run(self) -> RunRecord | None:
        return self.history[-1] if self.history else None

    def add_record(self, record: RunRecord) -> None:
        self.history.append(record)

    def to_dict(self) -> dict[str, Any]:
        return {
            "current_task": self.current_task,
            "attempts": self.attempts,
            "max_attempts": self.max_attempts,
            "history": [r.to_dict() for r in self.history],
            "metadata": self.metadata,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> LoopState:
        return cls(
            current_task=data.get("current_task"),
            attempts=data.get("attempts", 0),
            max_attempts=data.get("max_attempts", 5),
            history=[RunRecord.from_dict(r) for r in data.get("history", [])],
            metadata=data.get("metadata", {}),
        )
```

## `loopy.MCPClient` (class)

Model Context Protocol client.

Connects to MCP servers and exposes their tools.

Example:
    client = MCPClient("http://localhost:3000")

    # List available tools
    tools = await client.list_tools()
    for tool in tools:
        print(f"{tool.name}: {tool.description}")

    # Call a tool
    result = await client.call_tool("get_weather", {"city": "Portland"})

```python
class MCPClient:
    """
    Model Context Protocol client.

    Connects to MCP servers and exposes their tools.

    Example:
        client = MCPClient("http://localhost:3000")

        # List available tools
        tools = await client.list_tools()
        for tool in tools:
            print(f"{tool.name}: {tool.description}")

        # Call a tool
        result = await client.call_tool("get_weather", {"city": "Portland"})
    """

    def __init__(
        self,
        server_url: str,
        api_key: str | None = None,
        *,
        allow_private: bool = True,
    ):
        """
        Args:
            server_url: URL of the MCP server.
            api_key: Optional API key for authentication.
            allow_private: Permit loopback/private/link-local hosts. Keep
                True when *server_url* is operator-controlled (local MCP
                servers are the norm). Set False when the URL can be
                influenced by model output or other untrusted content — the
                SSRF guard then rejects internal destinations.

        Raises:
            ValueError: If the URL scheme is not http(s) or it has no host.
        """
        validate_outbound_url(server_url, allow_private=allow_private)
        self.server_url = server_url.rstrip("/")
        self._client = httpx.AsyncClient(timeout=30.0)
        self._headers: dict[str, str] = {"Content-Type": "application/json"}
        if api_key:
            self._headers["Authorization"] = f"Bearer {api_key}"

        self._tools: list[Tool] = []

    async def list_tools(self) -> list[Tool]:
        """
        List available tools from the MCP server.

        Returns:
            List of Tool definitions
        """
        response = await self._client.post(
            f"{self.server_url}/list_tools",
            headers=self._headers,
            json={},
        )
        response.raise_for_status()
        data = response.json()

        self._tools = [
            Tool(
                name=t["name"],
                description=t.get("description", ""),
                input_schema=t.get("input_schema", {}),
                annotations=t.get("annotations", {}),
            )
            for t in data.get("tools", [])
        ]

        logger.info("Listed %d tools from %s", len(self._tools), self.server_url)
        return self._tools

    async def call_tool(
        self,
        name: str,
        arguments: dict[str, Any] | None = None,
    ) -> MCPToolResult:
        """
        Call a tool on the MCP server.

        Validates that the tool name exists in the cached tool list
        before sending the request.

        Args:
            name: Tool name
            arguments: Tool arguments

        Returns:
            MCPToolResult with the response

        Raises:
            ValueError: If tool name is not in the cached tool list.
        """
        # Validate tool name against cached list.
        # Skip validation if tools haven't been loaded yet (call
        # list_tools() first to enable client-side validation).
        if self._tools and not any(t.name == name for t in self._tools):
            return MCPToolResult(
                content=f"Tool not found: {name}",
                is_error=True,
            )

        payload = {
            "name": name,
            "arguments": arguments or {},
        }

        response = await self._client.post(
            f"{self.server_url}/call_tool",
            headers=self._headers,
            json=payload,
        )
        response.raise_for_status()
        data = response.json()

        return MCPToolResult(
            content=data.get("content", ""),
            is_error=data.get("is_error", False),
            metadata=data.get("metadata", {}),
        )

    async def health_check(self) -> bool:
        """Check if the MCP server is healthy."""
        try:
            response = await self._client.get(
                f"{self.server_url}/health",
                headers=self._headers,
            )
            return response.status_code == 200
        except Exception:
            return False

    async def close(self) -> None:
        """Close the client."""
        await self._client.aclose()

    async def __aenter__(self) -> MCPClient:
        return self

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

## `loopy.MCPTool` (class)

An MCP tool definition.

```python
@dataclass
class Tool:
    """An MCP tool definition."""

    name: str
    description: str
    input_schema: dict[str, Any] = field(default_factory=dict)
    annotations: dict[str, Any] = field(default_factory=dict)
```

## `loopy.MCPToolResult` (class)

Result of a tool call via MCP server.

```python
@dataclass
class MCPToolResult:
    """Result of a tool call via MCP server."""

    content: str | list[dict[str, Any]]
    is_error: bool = False
    metadata: dict[str, Any] = field(default_factory=dict)
```

## `loopy.MediaContent` (class)

A piece of media content.

```python
@dataclass
class MediaContent:
    """A piece of media content."""

    type: MediaType
    data: str  # base64 or URL
    mime_type: str = ""
    metadata: dict[str, Any] = field(default_factory=dict)

    @classmethod
    def from_file(cls, path: str) -> MediaContent:
        """Load media from file."""
        file_path = Path(path)
        if not file_path.exists():
            raise FileNotFoundError(f"Media file not found: {path}")

        suffix = file_path.suffix.lower()
        mime_map = {
            ".png": "image/png",
            ".jpg": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".webp": "image/webp",
            ".gif": "image/gif",
            ".mp3": "audio/mpeg",
            ".wav": "audio/wav",
            ".mp4": "video/mp4",
            ".pdf": "application/pdf",
        }

        mime_type = mime_map.get(suffix, "application/octet-stream")
        media_type = (
            MediaType.IMAGE
            if mime_type.startswith("image/")
            else MediaType.AUDIO
            if mime_type.startswith("audio/")
            else MediaType.VIDEO
            if mime_type.startswith("video/")
            else MediaType.DOCUMENT
        )

        data = base64.b64encode(file_path.read_bytes()).decode()
        return cls(
            type=media_type,
            data=data,
            mime_type=mime_type,
            metadata={"filename": file_path.name, "size": file_path.stat().st_size},
        )

    @classmethod
    def from_url(
        cls,
        url: str,
        media_type: MediaType = MediaType.IMAGE,
        *,
        allow_private: bool = True,
    ) -> MediaContent:
        """Create media from URL (no download).

        The URL is passed through to the provider, which fetches it
        server-side — so keep *allow_private* True only for operator-supplied
        URLs. Set False to reject internal/loopback destinations when the URL
        can come from model output or untrusted content.

        Raises:
            ValueError: If the URL scheme is not http(s) or it has no host.
        """
        validate_outbound_url(url, allow_private=allow_private)
        return cls(
            type=media_type,
            data=url,
            metadata={"url": url},
        )

    def to_openai(self) -> dict[str, Any]:
        """Convert to OpenAI vision API format.

        Returns a dict suitable for use in the ``content`` array
        of an OpenAI chat completion request.
        """
        if self.data.startswith(("http://", "https://")):
            return {"type": "image_url", "image_url": {"url": self.data}}
        return {
            "type": "image_url",
            "image_url": {"url": f"data:{self.mime_type};base64,{self.data}"},
        }

    def to_anthropic(self) -> dict[str, Any]:
        """Convert to Anthropic Messages API format.

        Returns a dict suitable for use in the ``content`` array
        of an Anthropic message request.
        """
        if self.data.startswith(("http://", "https://")):
            return {"type": "image", "source": {"type": "url", "url": self.data}}
        return {
            "type": "image",
            "source": {"type": "base64", "media_type": self.mime_type, "data": self.data},
        }
```

## `loopy.MediaType` (class)

Supported media types.

```python
class MediaType(str, Enum):
    """Supported media types."""

    IMAGE = "image"
    AUDIO = "audio"
    VIDEO = "video"
    DOCUMENT = "document"
```

## `loopy.MetricsCollector` (class)

Simple metrics collector for LLM observability.

Supports counters, histograms, and gauges with tag-based
grouping for summary aggregation.

Example:
    metrics = MetricsCollector()

    metrics.increment("llm.requests", tags={"model": "gpt-4"})
    metrics.histogram("llm.tokens", 150, tags={"model": "gpt-4"})
    metrics.gauge("cache.size", 42)

    summary = metrics.summary()

```python
class MetricsCollector:
    """
    Simple metrics collector for LLM observability.

    Supports counters, histograms, and gauges with tag-based
    grouping for summary aggregation.

    Example:
        metrics = MetricsCollector()

        metrics.increment("llm.requests", tags={"model": "gpt-4"})
        metrics.histogram("llm.tokens", 150, tags={"model": "gpt-4"})
        metrics.gauge("cache.size", 42)

        summary = metrics.summary()
    """

    def __init__(self):
        self._metrics: list[MetricPoint] = []

    def increment(self, name: str, value: float = 1, **tags: str) -> None:
        """Record a counter increment.

        Args:
            name: Metric name.
            value: Amount to increment by (default 1).
            **tags: Key-value tag pairs for grouping.
        """
        self._metrics.append(MetricPoint(name=name, value=value, tags=tags))

    def histogram(self, name: str, value: float, **tags: str) -> None:
        """Record a histogram observation.

        Args:
            name: Metric name.
            value: Observed value.
            **tags: Key-value tag pairs for grouping.
        """
        self._metrics.append(MetricPoint(name=name, value=value, tags=tags))

    def gauge(self, name: str, value: float, **tags: str) -> None:
        """Set a gauge to a value.

        Args:
            name: Metric name.
            value: Current gauge value.
            **tags: Key-value tag pairs for grouping.
        """
        self._metrics.append(MetricPoint(name=name, value=value, tags=tags))

    def summary(self) -> dict[str, Any]:
        """Get summary of collected metrics."""
        by_name: dict[str, list[float]] = {}
        for m in self._metrics:
            if m.name not in by_name:
                by_name[m.name] = []
            by_name[m.name].append(m.value)

        return {
            name: {
                "count": len(values),
                "sum": sum(values),
                "avg": sum(values) / len(values) if values else 0,
                "min": min(values) if values else 0,
                "max": max(values) if values else 0,
            }
            for name, values in by_name.items()
        }

    def export(self) -> list[dict[str, Any]]:
        """Export all metrics."""
        return [
            {
                "name": m.name,
                "value": m.value,
                "timestamp": m.timestamp,
                "tags": m.tags,
            }
            for m in self._metrics
        ]

    def clear(self) -> None:
        """Clear all metrics."""
        self._metrics.clear()
```

## `loopy.ModelProvider` (class)

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
class ModelProvider(str, Enum):
    OPENAI = "openai"
    ANTHROPIC = "anthropic"
    OLLAMA = "ollama"
    CUSTOM = "custom"
```

## `loopy.MultiModalBuilder` (class)

Build multi-modal messages easily.

Example:
    msg = (MultiModalBuilder()
        .text("What's in this image?")
        .image("photo.jpg")
        .image("https://example.com/chart.png")
        .build())

```python
class MultiModalBuilder:
    """
    Build multi-modal messages easily.

    Example:
        msg = (MultiModalBuilder()
            .text("What's in this image?")
            .image("photo.jpg")
            .image("https://example.com/chart.png")
            .build())
    """

    def __init__(self):
        self._text = ""
        self._media: list[MediaContent] = []

    def text(self, content: str) -> MultiModalBuilder:
        """Add text content."""
        self._text = content
        return self

    def image(self, source: str) -> MultiModalBuilder:
        """Add image from file or URL."""
        if source.startswith(("http://", "https://")):
            self._media.append(MediaContent.from_url(source, MediaType.IMAGE))
        else:
            self._media.append(MediaContent.from_file(source))
        return self

    def audio(self, source: str) -> MultiModalBuilder:
        """Add audio from file or URL."""
        if source.startswith(("http://", "https://")):
            self._media.append(MediaContent.from_url(source, MediaType.AUDIO))
        else:
            self._media.append(MediaContent.from_file(source))
        return self

    def file(self, path: str) -> MultiModalBuilder:
        """Add any file as media."""
        self._media.append(MediaContent.from_file(path))
        return self

    def build(self) -> MultiModalMessage:
        """Build the multi-modal message."""
        return MultiModalMessage(text=self._text, media=self._media)
```

## `loopy.MultiModalMessage` (class)

A message with text and media content.

```python
@dataclass
class MultiModalMessage:
    """A message with text and media content."""

    text: str
    media: list[MediaContent] = field(default_factory=list)

    @property
    def has_media(self) -> bool:
        return len(self.media) > 0

    @property
    def images(self) -> list[MediaContent]:
        return [m for m in self.media if m.type == MediaType.IMAGE]

    @property
    def audio(self) -> list[MediaContent]:
        return [m for m in self.media if m.type == MediaType.AUDIO]

    def to_openai(self) -> list[dict[str, Any]]:
        """Convert to OpenAI multi-modal format."""
        content: list[dict[str, Any]] = []

        # Add images first
        for media in self.media:
            if media.type == MediaType.IMAGE:
                content.append(media.to_openai())

        # Add text
        if self.text:
            content.append({"type": "text", "text": self.text})

        return content

    def to_anthropic(self) -> list[dict[str, Any]]:
        """Convert to Anthropic multi-modal format."""
        content: list[dict[str, Any]] = []

        for media in self.media:
            content.append(media.to_anthropic())

        if self.text:
            content.append({"type": "text", "text": self.text})

        return content
```

## `loopy.Node` (class)

A node in a state graph.

```python
@dataclass
class Node:
    """A node in a state graph."""

    name: str
    run: NodeFn
```

## `loopy.Orchestrator` (class)

Multi-agent orchestrator.

Manages a pool of subagents and routes tasks to the appropriate one.
Supports routing and task decomposition for 2026 orchestrator-workers pattern.

Example:
    orchestrator = Orchestrator()

    # Register agents
    orchestrator.add_agent(SubAgent(
        name="researcher",
        description="Searches the web",
        handler=research_fn,
    ))

    orchestrator.add_agent(SubAgent(
        name="coder",
        description="Writes and tests code",
        tools=["execute_code"],
        handler=coder_fn,
    ))

    # Run a task with routing
    result = await orchestrator.run("Build a REST API for user management")
    print(result)

    # Or decompose first
    subtasks = await orchestrator.decompose("Build REST API with tests")
    for task in subtasks:
        result = await orchestrator.run(task.description, agent_name=task.required_agent)

```python
class Orchestrator:
    """
    Multi-agent orchestrator.

    Manages a pool of subagents and routes tasks to the appropriate one.
    Supports routing and task decomposition for 2026 orchestrator-workers pattern.

    Example:
        orchestrator = Orchestrator()

        # Register agents
        orchestrator.add_agent(SubAgent(
            name="researcher",
            description="Searches the web",
            handler=research_fn,
        ))

        orchestrator.add_agent(SubAgent(
            name="coder",
            description="Writes and tests code",
            tools=["execute_code"],
            handler=coder_fn,
        ))

        # Run a task with routing
        result = await orchestrator.run("Build a REST API for user management")
        print(result)

        # Or decompose first
        subtasks = await orchestrator.decompose("Build REST API with tests")
        for task in subtasks:
            result = await orchestrator.run(task.description, agent_name=task.required_agent)
    """

    def __init__(self, max_concurrent: int = 5, router: Router | None = None):
        self.agents: dict[str, SubAgent] = {}
        self._semaphore = asyncio.Semaphore(max_concurrent)
        self._history: list[AgentResult] = []
        self.router = router or Router()
        self.decomposer = TaskDecomposer()

    def add_agent(self, agent: SubAgent) -> None:
        """Register a subagent."""
        self.agents[agent.name] = agent
        logger.info("Added agent: %s", agent.name)

    def get_agent(self, name: str) -> SubAgent | None:
        """Get an agent by name."""
        return self.agents.get(name)

    def list_agents(self) -> list[SubAgent]:
        """List all registered agents."""
        return list(self.agents.values())

    async def route(self, task: str) -> str:
        """
        Route a task to the appropriate agent using the router.

        Args:
            task: The task description

        Returns:
            Agent name to route to
        """
        return await self.router.classify(task)

    async def decompose(self, task: str) -> list[SubTask]:
        """
        Decompose a complex task into subtasks.

        Args:
            task: The high-level task

        Returns:
            List of SubTask objects with dependencies
        """
        return await self.decomposer.decompose(task)

    async def run_decomposed(
        self,
        task: str,
        context: dict[str, Any] | None = None,
    ) -> list[AgentResult]:
        """
        Decompose and run a task, executing subtasks in dependency order.

        Args:
            task: The high-level task to decompose and execute
            context: Optional context to pass to agents

        Returns:
            List of results from each subtask
        """
        subtasks = await self.decompose(task)
        results: list[AgentResult] = []
        completed: set[str] = set()

        # Execute in dependency order
        max_iterations = len(subtasks) * 2  # Safety limit
        iteration = 0

        while len(completed) < len(subtasks) and iteration < max_iterations:
            iteration += 1

            for subtask in subtasks:
                if subtask.id in completed:
                    continue

                # Check if dependencies are met
                deps_met = all(dep in completed for dep in subtask.dependencies)
                if not deps_met:
                    continue

                # Route to appropriate agent
                agent_name = subtask.required_agent or await self.route(subtask.description)

                # Run the subtask
                result = await self.run(
                    subtask.description,
                    agent_name=agent_name,
                    context=context,
                )

                subtask.status = "completed" if result.status == AgentStatus.COMPLETED else "failed"
                subtask.result = result.output
                completed.add(subtask.id)
                results.append(result)

        return results

    async def run_all(
        self,
        task: str,
        context: dict[str, Any] | None = None,
    ) -> list[AgentResult]:
        """
        Run a task on all agents in parallel.

        Returns:
            List of results from each agent
        """
        tasks = [self._run_agent(agent, task, context or {}) for agent in self.agents.values()]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        final_results = []
        for i, result in enumerate(results):
            if isinstance(result, Exception):
                agent_name = list(self.agents.keys())[i]
                final_results.append(
                    AgentResult(
                        agent_name=agent_name,
                        status=AgentStatus.FAILED,
                        error=str(result),
                    )
                )
            else:
                final_results.append(result)

        return final_results

    async def _run_agent(
        self,
        agent: SubAgent,
        task: str,
        context: dict[str, Any],
    ) -> AgentResult:
        """Execute a single agent and return its result.

        Args:
            agent: The subagent to run.
            task: The task description.
            context: Shared context dict.

        Returns:
            An AgentResult with the outcome.
        """
        start_time = time.time()

        agent.status = AgentStatus.RUNNING

        try:
            if agent.handler:
                output = await agent.handler(task, context)
            else:
                output = f"Agent {agent.name} has no handler"

            duration_ms = (time.time() - start_time) * 1000

            result = AgentResult(
                agent_name=agent.name,
                status=AgentStatus.COMPLETED,
                output=output,
                duration_ms=duration_ms,
            )

            agent.status = AgentStatus.COMPLETED
            agent.result = result
            self._history.append(result)

            logger.info("Agent %s completed in %.0fms", agent.name, duration_ms)
            return result

        except Exception as e:
            duration_ms = (time.time() - start_time) * 1000

            result = AgentResult(
                agent_name=agent.name,
                status=AgentStatus.FAILED,
                error=str(e),
                duration_ms=duration_ms,
            )

            agent.status = AgentStatus.FAILED
            agent.result = result
            self._history.append(result)

            logger.error("Agent %s failed: %s", agent.name, e)
            return result

    def get_history(self) -> list[AgentResult]:
        """Get execution history."""
        return self._history.copy()

    def get_summary(self) -> dict[str, Any]:
        """Get a summary of all agent executions.

        Returns:
            Dict with total_agents, total_runs, completed/failed
            counts, and average duration.
        """
        return {
            "total_agents": len(self.agents),
            "total_runs": len(self._history),
            "completed": sum(1 for r in self._history if r.status == AgentStatus.COMPLETED),
            "failed": sum(1 for r in self._history if r.status == AgentStatus.FAILED),
            "avg_duration_ms": (
                sum(r.duration_ms for r in self._history) / len(self._history)
                if self._history
                else 0
            ),
        }

    async def run(
        self,
        task: str,
        agent_name: str | None = None,
        context: dict[str, Any] | None = None,
    ) -> AgentResult:
        """
        Run a task, optionally targeting a specific agent.

        If no agent specified, uses the first available agent.
        """
        # Select agent
        if agent_name:
            agent = self.agents.get(agent_name)
            if not agent:
                return AgentResult(
                    agent_name=agent_name or "unknown",
                    status=AgentStatus.FAILED,
                    error=f"Agent not found: {agent_name}",
                )
        else:
            # Use first available agent
            if not self.agents:
                return AgentResult(
                    agent_name="none",
                    status=AgentStatus.FAILED,
                    error="No agents registered",
                )
            agent = next(iter(self.agents.values()))

        # Run with concurrency control
        async with self._semaphore:
            return await self._run_agent(agent, task, context or {})
```

## `loopy.OutputFilter` (class)

Filters model output for harmful content, data leaks, etc.

Example:
    filter = OutputFilter()
    result = filter.check("The user's email is john@example.com")
    # result.action == FilterAction.REDACT

```python
class OutputFilter:
    """
    Filters model output for harmful content, data leaks, etc.

    Example:
        filter = OutputFilter()
        result = filter.check("The user's email is john@example.com")
        # result.action == FilterAction.REDACT
    """

    def __init__(self, config: GuardrailConfig | None = None):
        self.config = config or GuardrailConfig()
        self._input_filter = InputFilter(config)

    def check(self, text: str) -> FilterResult:
        """Check output text."""
        # Reuse input filter for PII detection in outputs
        return self._input_filter.check(text)
```

## `loopy.PatternCadence` (class)

How often the pattern runs.

```python
class PatternCadence(str, Enum):
    """How often the pattern runs."""

    MINUTES_5 = "5m"
    MINUTES_15 = "15m"
    HOURS_1 = "1h"
    HOURS_6 = "6h"
    DAILY = "1d"
```

## `loopy.PatternRegistry` (class)

Built-in production patterns.

Example:
    registry = PatternRegistry()
    patterns = registry.list_all()
    daily = registry.get("daily-triage")

```python
class PatternRegistry:
    """
    Built-in production patterns.

    Example:
        registry = PatternRegistry()
        patterns = registry.list_all()
        daily = registry.get("daily-triage")
    """

    def __init__(self):
        self._patterns: dict[str, LoopPattern] = {}
        self._register_builtins()

    def _register_builtins(self) -> None:
        """Register built-in patterns."""
        builtins = [
            LoopPattern(
                name="daily-triage",
                description="Triage issues and PRs on a daily cadence",
                cadence=PatternCadence.DAILY,
                risk=RiskLevel.LOW,
                readiness_level="L1",
            ),
            LoopPattern(
                name="pr-babysitter",
                description="Monitor and respond to PR events",
                cadence=PatternCadence.MINUTES_15,
                risk=RiskLevel.MEDIUM,
                readiness_level="L1",
            ),
            LoopPattern(
                name="ci-sweeper",
                description="Sweep CI failures and create fixes",
                cadence=PatternCadence.MINUTES_15,
                risk=RiskLevel.MEDIUM,
                readiness_level="L2",
            ),
            LoopPattern(
                name="dependency-sweeper",
                description="Check and update dependencies",
                cadence=PatternCadence.HOURS_6,
                risk=RiskLevel.MEDIUM,
                readiness_level="L2",
            ),
            LoopPattern(
                name="changelog-drafter",
                description="Draft changelog from commits",
                cadence=PatternCadence.DAILY,
                risk=RiskLevel.LOW,
                readiness_level="L1",
            ),
            LoopPattern(
                name="post-merge-cleanup",
                description="Clean up after merges",
                cadence=PatternCadence.HOURS_6,
                risk=RiskLevel.LOW,
                readiness_level="L1",
            ),
            LoopPattern(
                name="issue-triage",
                description="Triage new issues",
                cadence=PatternCadence.HOURS_1,
                risk=RiskLevel.LOW,
                readiness_level="L1",
            ),
        ]

        for pattern in builtins:
            self._patterns[pattern.name] = pattern

    def get(self, name: str) -> LoopPattern | None:
        """Get pattern by name."""
        return self._patterns.get(name)

    def list_all(self) -> list[LoopPattern]:
        """List all patterns."""
        return list(self._patterns.values())

    def list_by_risk(self, risk: RiskLevel) -> list[LoopPattern]:
        """List patterns by risk level."""
        return [p for p in self._patterns.values() if p.risk == risk]

    def list_by_cadence(self, cadence: PatternCadence) -> list[LoopPattern]:
        """List patterns by cadence."""
        return [p for p in self._patterns.values() if p.cadence == cadence]
```

## PermissionMode

> Not exported by `loopy`.

## Pipeline

> Not exported by `loopy`.

## `loopy.Plugin` (class)

Base plugin class.

All plugins must inherit from this and implement `setup()`.

Example:
    class MyPlugin(Plugin):
        @property
        def info(self) -> PluginInfo:
            return PluginInfo(
                name="my-plugin",
                version="1.0.0",
                description="My awesome plugin",
            )

        async def setup(self, registry: PluginRegistry) -> None:
            # Register tools, middleware, etc.
            registry.register_tool("my_tool", my_tool_handler)

```python
class Plugin(ABC):
    """
    Base plugin class.

    All plugins must inherit from this and implement `setup()`.

    Example:
        class MyPlugin(Plugin):
            @property
            def info(self) -> PluginInfo:
                return PluginInfo(
                    name="my-plugin",
                    version="1.0.0",
                    description="My awesome plugin",
                )

            async def setup(self, registry: PluginRegistry) -> None:
                # Register tools, middleware, etc.
                registry.register_tool("my_tool", my_tool_handler)
    """

    @property
    @abstractmethod
    def info(self) -> PluginInfo:
        """Return plugin metadata."""
        ...

    @abstractmethod
    async def setup(self, registry: PluginRegistry) -> None:
        """Initialize the plugin."""
        ...

    async def teardown(self) -> None:  # noqa: B027
        """Cleanup when plugin is unloaded."""
```

## `loopy.PluginInfo` (class)

Metadata about a plugin.

```python
@dataclass
class PluginInfo:
    """Metadata about a plugin."""

    name: str
    version: str = "0.1.0"
    description: str = ""
    author: str = ""
    url: str = ""

    # Capabilities this plugin provides
    capabilities: list[str] = field(default_factory=list)

    # Dependencies
    requires: list[str] = field(default_factory=list)
```

## `loopy.PluginLoader` (class)

Automatic plugin discovery and loading.

Example:
    loader = PluginLoader()

    # Discover plugins from entry points
    await loader.discover()

    # Or from specific locations
    await loader.discover(
        package="my_package.plugins",
        directory="~/.loopy/plugins",
    )

```python
class PluginLoader:
    """
    Automatic plugin discovery and loading.

    Example:
        loader = PluginLoader()

        # Discover plugins from entry points
        await loader.discover()

        # Or from specific locations
        await loader.discover(
            package="my_package.plugins",
            directory="~/.loopy/plugins",
        )
    """

    def __init__(self, registry: PluginRegistry | None = None):
        self.registry = registry or PluginRegistry()

    async def discover(
        self,
        package: str | None = None,
        directory: str | Path | None = None,
    ) -> int:
        """
        Discover and load plugins.

        Returns:
            Number of plugins loaded
        """
        loaded = 0

        # Load from package
        if package:
            try:
                mod = importlib.import_module(package)
                plugins_attr = getattr(mod, "__plugins__", [])
                for plugin_cls in plugins_attr:
                    if isinstance(plugin_cls, type) and issubclass(plugin_cls, Plugin):
                        await self.registry.load(plugin_cls())
                        loaded += 1
            except ImportError as e:
                logger.warning("Could not import %s: %s", package, e)

        # Load from directory
        if directory:
            loaded += await self.registry.load_directory(directory)

        return loaded
```

## `loopy.PluginRegistry` (class)

Central registry for plugins and their components.

Example:
    registry = PluginRegistry()

    # Load plugins
    await registry.load(MyPlugin())
    await registry.load_package("loopy.plugins.anthropic")

    # Use registered components
    tool = registry.get_tool("my_tool")
    middleware = registry.get_middleware("cache")

```python
class PluginRegistry:
    """
    Central registry for plugins and their components.

    Example:
        registry = PluginRegistry()

        # Load plugins
        await registry.load(MyPlugin())
        await registry.load_package("loopy.plugins.anthropic")

        # Use registered components
        tool = registry.get_tool("my_tool")
        middleware = registry.get_middleware("cache")
    """

    def __init__(self):
        self._plugins: dict[str, Plugin] = {}
        self._tools: dict[str, Callable] = {}
        self._tool_specs: dict[str, dict[str, Any]] = {}
        self._middleware: dict[str, Any] = {}
        self._providers: dict[str, Any] = {}
        self._extensions: dict[str, list[Callable]] = {}
        self._denials: deque = deque(maxlen=DENIAL_LOG_MAX)

    async def load(self, plugin: Plugin) -> None:
        """Load a plugin instance."""
        info = plugin.info

        if info.name in self._plugins:
            logger.warning("Plugin %s already loaded, skipping", info.name)
            return

        # Check dependencies
        for dep in info.requires:
            if dep not in self._plugins:
                raise RuntimeError(f"Plugin {info.name} requires {dep}, which is not loaded")

        # Load the plugin
        await plugin.setup(self)
        self._plugins[info.name] = plugin

        logger.info("Loaded plugin: %s v%s", info.name, info.version)

    async def load_package(self, module_path: str) -> None:
        """
        Load a plugin from a Python module path.

        The module must have a `plugin` attribute that is a Plugin instance.

        Example:
            await registry.load_package("my_package.my_plugin")
        """
        try:
            module = importlib.import_module(module_path)
            plugin_instance = getattr(module, "plugin", None)

            if plugin_instance is None:
                raise ValueError(f"No 'plugin' attribute in {module_path}")

            if not isinstance(plugin_instance, Plugin):
                raise TypeError(f"'plugin' in {module_path} is not a Plugin instance")

            await self.load(plugin_instance)

        except ImportError as e:
            logger.error("Failed to import %s: %s", module_path, e)
            raise

    async def load_directory(self, directory: str | Path) -> int:
        """
        Load all plugins from a directory.

        Looks for Python files with a `plugin` attribute.

        Returns:
            Number of plugins loaded
        """
        directory = Path(directory)
        loaded = 0

        if not directory.exists():
            logger.warning("Plugin directory not found: %s", directory)
            return 0

        for py_file in directory.glob("*.py"):
            if py_file.name.startswith("_"):
                continue

            module_name = py_file.stem
            try:
                spec = importlib.util.spec_from_file_location(
                    f"loopy_plugins.{module_name}",
                    py_file,
                )
                if spec and spec.loader:
                    module = importlib.util.module_from_spec(spec)
                    spec.loader.exec_module(module)

                    plugin_instance = getattr(module, "plugin", None)
                    if plugin_instance and isinstance(plugin_instance, Plugin):
                        await self.load(plugin_instance)
                        loaded += 1
            except Exception as e:
                logger.error("Failed to load plugin from %s: %s", py_file, e)

        return loaded

    def register_tool(
        self,
        name: str,
        handler: Callable,
        *,
        agent_visible: bool = True,
        requires_approval: bool = False,
        scope: str = "side_effecting",
        allowed_values: dict[str, set[str]] | None = None,
    ) -> None:
        """Register a tool handler.

        Args:
            name: The tool name.
            handler: The callable to invoke.
            agent_visible: If False, the tool is hidden from :meth:`list_tools`
                and is intended for operator callers only (the model cannot
                discover it). Defaults True.
            requires_approval: If True, :meth:`execute_tool` demands a human
                approver before running (deny-by-default otherwise).
            scope: ``"read_only"`` or ``"side_effecting"``.
            allowed_values: Per-parameter allow-lists (enum constraints)
                enforced by :meth:`execute_tool`.
        """
        self._tools[name] = handler
        self._tool_specs[name] = {
            "agent_visible": agent_visible,
            "requires_approval": requires_approval,
            "scope": scope,
            "allowed_values": allowed_values or {},
        }
        logger.debug("Registered tool: %s (visible=%s, scope=%s)", name, agent_visible, scope)

    def get_tool(self, name: str) -> Callable | None:
        """Get a registered tool handler."""
        return self._tools.get(name)

    def get_tool_spec(self, name: str) -> dict[str, Any] | None:
        """Get a registered tool's capability spec (security metadata)."""
        return self._tool_specs.get(name)

    def list_tools(self) -> list[str]:
        """List agent-visible tool names (hidden/operator tools excluded)."""
        return [name for name, spec in self._tool_specs.items() if spec["agent_visible"]]

    def list_all_tools(self) -> list[str]:
        """List every registered tool name, visible or not."""
        return list(self._tools.keys())

    def denials(self) -> list[dict[str, Any]]:
        """Audit trail of denied/blocked tool executions.

        Bounded to ``DENIAL_LOG_MAX`` entries (oldest dropped first);
        secret-looking argument values are redacted.
        """
        return list(self._denials)

    async def execute_tool(
        self,
        name: str,
        arguments: dict[str, Any] | None = None,
        *,
        approver: Callable[[str, dict[str, Any]], Awaitable[bool]] | None = None,
    ) -> Any:
        """Execute a registered tool with capability-gate enforcement.

        Enforces tool existence, per-parameter allow-lists, and the
        human-in-the-loop approval gate (a ``requires_approval`` tool is
        denied unless an *approver* approves). Denials are recorded.

        Args:
            name: The tool to execute.
            arguments: Keyword arguments for the handler.
            approver: Optional async callback ``(name, arguments) -> bool``.

        Returns:
            The handler's return value.

        Raises:
            PermissionError: If the call requires approval and none is given.
            ValueError: If an argument falls outside its allow-list.
        """
        handler = self._tools.get(name)
        if handler is None:
            self._denials.append({"tool": name, "reason": "not_found"})
            raise ValueError(f"Tool not found: {name}")

        spec = self._tool_specs.get(name, {})
        arguments = arguments or {}

        if spec.get("requires_approval"):
            if approver is None:
                self._denials.append(
                    {
                        "tool": name,
                        "reason": "approval_required_no_approver",
                        "arguments": redact_arguments(arguments),
                    }
                )
                raise PermissionError(
                    f"Tool '{name}' requires approval and no approver is configured"
                )
            approved = await approver(name, arguments)
            if not approved:
                self._denials.append(
                    {
                        "tool": name,
                        "reason": "approval_denied",
                        "arguments": redact_arguments(arguments),
                    }
                )
                raise PermissionError(f"Tool '{name}' was not approved")

        allowed = spec.get("allowed_values") or {}
        for param, values in allowed.items():
            value = arguments.get(param)
            if value is not None and value not in values:
                self._denials.append({"tool": name, "reason": f"parameter '{param}' out of range"})
                raise ValueError(f"Parameter '{param}' outside allowed values")

        return await handler(**arguments)

    def register_middleware(self, name: str, middleware: Any) -> None:
        """Register middleware."""
        self._middleware[name] = middleware
        logger.debug("Registered middleware: %s", name)

    def get_middleware(self, name: str) -> Any:
        """Get registered middleware."""
        return self._middleware.get(name)

    def register_provider(self, name: str, provider: Any) -> None:
        """Register an LLM provider."""
        self._providers[name] = provider
        logger.debug("Registered provider: %s", name)

    def get_provider(self, name: str) -> Any:
        """Get a registered provider."""
        return self._providers.get(name)

    def register_extension(self, hook_name: str, callback: Callable) -> None:
        """Register an extension hook."""
        if hook_name not in self._extensions:
            self._extensions[hook_name] = []
        self._extensions[hook_name].append(callback)
        logger.debug("Registered extension for hook: %s", hook_name)

    async def trigger_extension(self, hook_name: str, *args: Any, **kwargs: Any) -> list[Any]:
        """Trigger all callbacks for a hook."""
        results = []
        for callback in self._extensions.get(hook_name, []):
            try:
                if callable(callback):
                    result = await callback(*args, **kwargs)
                else:
                    result = callback(*args, **kwargs)
                results.append(result)
            except Exception as e:
                logger.error("Extension hook %s failed: %s", hook_name, e)
        return results

    def get_plugin(self, name: str) -> Plugin | None:
        """Get a loaded plugin."""
        return self._plugins.get(name)

    def list_plugins(self) -> list[PluginInfo]:
        """List all loaded plugins."""
        return [p.info for p in self._plugins.values()]

    async def unload(self, name: str) -> bool:
        """Unload a plugin."""
        if name not in self._plugins:
            return False

        plugin = self._plugins[name]
        await plugin.teardown()
        del self._plugins[name]

        logger.info("Unloaded plugin: %s", name)
        return True

    async def unload_all(self) -> None:
        """Unload all plugins."""
        for name in list(self._plugins.keys()):
            await self.unload(name)
```

## `loopy.ProviderConfig` (class)

Configuration for a single LLM provider.

Includes rate-limit tracking via :meth:`check_rate_limit` and
:meth:`record_request`.

Args:
    provider: The LLM provider enum.
    api_key: Optional API key for authentication.
    base_url: Base URL for the provider API.
    model: Model identifier (e.g. "gpt-4").
    rpm: Max requests per minute.
    tpm: Max tokens per minute.

```python
@dataclass
class ProviderConfig:
    """Configuration for a single LLM provider.

    Includes rate-limit tracking via :meth:`check_rate_limit` and
    :meth:`record_request`.

    Args:
        provider: The LLM provider enum.
        api_key: Optional API key for authentication.
        base_url: Base URL for the provider API.
        model: Model identifier (e.g. "gpt-4").
        rpm: Max requests per minute.
        tpm: Max tokens per minute.
    """

    provider: ModelProvider
    api_key: str | None = None
    base_url: str = ""
    model: str = "gpt-4"

    # Rate limiting
    rpm: int = 60  # requests per minute
    tpm: int = 100_000  # tokens per minute

    # v0.9.0 — Cost-Aware Routing
    # USD per 1k tokens. ``0.0`` for local providers (Ollama, etc.).
    cost_per_1k_tokens: float = 0.0

    # Internal tracking
    _request_count: int = field(default=0, repr=False)
    _window_start: float = field(default_factory=time.time, repr=False)

    def estimate_cost_usd(
        self,
        max_tokens: int,
        expected_tokens: int | None = None,
    ) -> float:
        """Estimate the USD cost of a request.

        Args:
            max_tokens: Upper bound on tokens the provider will
                charge for (the cap configured for this call).
            expected_tokens: Optional override — the caller's
                better estimate of actual token count. Defaults
                to ``max_tokens`` (conservative).

        Returns:
            The estimated cost in USD.
        """
        billable = expected_tokens if expected_tokens is not None else max_tokens
        return (billable / 1000.0) * self.cost_per_1k_tokens

    def check_rate_limit(self) -> None:
        """Check whether the rate limit has been reached.

        Raises:
            RuntimeError: If the number of requests in the current
                rolling 60-second window exceeds *rpm*.
        """
        now = time.time()
        if now - self._window_start > 60:
            self._request_count = 0
            self._window_start = now

        if self._request_count >= self.rpm:
            raise RuntimeError(f"Rate limit exceeded for {self.provider.value}")

    def record_request(self) -> None:
        """Increment the request counter for rate tracking."""
        self._request_count += 1
```

## `loopy.RateLimitMiddleware` (class)

Simple rate limiter.

```python
class RateLimitMiddleware(Middleware):
    """Simple rate limiter."""

    def __init__(self, max_per_second: int = 10):
        self.max_per_second = max_per_second
        self._timestamps: list[float] = []

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        now = time.time()

        # Remove old timestamps
        self._timestamps = [t for t in self._timestamps if now - t < 1.0]

        if len(self._timestamps) >= self.max_per_second:
            ctx.cancel(f"Rate limit exceeded: {self.max_per_second}/sec")
        else:
            self._timestamps.append(now)

        return ctx
```

## `loopy.ReadinessLevel` (class)

Loop readiness levels.

```python
class ReadinessLevel(str, Enum):
    """Loop readiness levels."""

    L0 = "L0"  # 0-29: Draft
    L1 = "L1"  # 30-59: Report only
    L2 = "L2"  # 60-79: Assisted fixes
    L3 = "L3"  # 80-100: Unattended

    @classmethod
    def from_score(cls, score: int) -> ReadinessLevel:
        """Derive readiness level from score."""
        if score < 30:
            return cls.L0
        if score < 60:
            return cls.L1
        if score < 80:
            return cls.L2
        return cls.L3
```

## `loopy.RealtimeEvent` (class)

v0.7.10 - Normalized realtime event surfaced to the agent loop.

```python
@dataclass
class RealtimeEvent:
    """v0.7.10 - Normalized realtime event surfaced to the agent loop."""

    type: RealtimeEventType
    data: dict[str, Any] = field(default_factory=dict)
    timestamp: float = field(default_factory=lambda: __import__("time").time())

    @property
    def transcript(self) -> str:
        """Concatenated transcript text (from ``transcript.delta`` events)."""
        return str(self.data.get("transcript", ""))

    @property
    def audio_bytes(self) -> bytes:
        """Raw audio payload (from ``audio.delta`` events)."""
        return self.data.get("audio", b"")
```

## `loopy.RealtimeEventType` (class)

v0.7.10 - Subset of OpenAI Realtime event types we support natively.

```python
class RealtimeEventType(str, Enum):
    """v0.7.10 - Subset of OpenAI Realtime event types we support natively."""

    SESSION_CREATED = "session.created"
    TRANSCRIPT_DELTA = "transcript.delta"
    TRANSCRIPT_DONE = "transcript.done"
    AUDIO_DELTA = "audio.delta"
    TOOL_CALL = "tool.call"
    ERROR = "error"
    CLOSED = "closed"
```

## `loopy.RealtimeSession` (class)

v0.7.10 - Async iterator over realtime events from a transport.

Lightweight adapter that consumes raw WebSocket frames from any
:class:`RealtimeTransport` and yields normalized
:class:`RealtimeEvent` instances. Useful for voice-first agent
loops, transcription-only bots, and OpenAI Realtime clients.

Example `` `` ``
    from loopy.multimodal import RealtimeSession, RealtimeTransport

    class MyOpenAITransport:
        async def send(self, payload): ...
        async def recv(self): ...
        async def close(self): ...

    async with RealtimeSession(MyOpenAITransport()) as session:
        await session.send({"type": "session.update", "session": {...}})
        async for event in session:
            if event.type == RealtimeEventType.TRANSCRIPT_DELTA:
                print(event.transcript, end="", flush=True)
```

The ``websockets`` library itself is NOT a dependency - users wire
in their preferred WebSocket client via the ``transport=`` argument.
This keeps the loopy-agent core dependency surface at just
``httpx`` + ``pydantic`` while leaving the door open for voice /
realtime use cases.

```python
class RealtimeSession:
    """v0.7.10 - Async iterator over realtime events from a transport.

    Lightweight adapter that consumes raw WebSocket frames from any
    :class:`RealtimeTransport` and yields normalized
    :class:`RealtimeEvent` instances. Useful for voice-first agent
    loops, transcription-only bots, and OpenAI Realtime clients.

    Example `` `` ``
        from loopy.multimodal import RealtimeSession, RealtimeTransport

        class MyOpenAITransport:
            async def send(self, payload): ...
            async def recv(self): ...
            async def close(self): ...

        async with RealtimeSession(MyOpenAITransport()) as session:
            await session.send({"type": "session.update", "session": {...}})
            async for event in session:
                if event.type == RealtimeEventType.TRANSCRIPT_DELTA:
                    print(event.transcript, end="", flush=True)
    ```

    The ``websockets`` library itself is NOT a dependency - users wire
    in their preferred WebSocket client via the ``transport=`` argument.
    This keeps the loopy-agent core dependency surface at just
    ``httpx`` + ``pydantic`` while leaving the door open for voice /
    realtime use cases.
    """

    __slots__ = ("_transport", "_closed", "_events", "_pump_task")

    def __init__(self, transport: RealtimeTransport) -> None:
        self._transport = transport
        self._closed = False
        self._events: asyncio.Queue[RealtimeEvent] = asyncio.Queue()
        self._pump_task: asyncio.Task[None] | None = None

    async def __aenter__(self) -> RealtimeSession:
        # Start the background pump that drains the transport into the queue.
        self._pump_task = asyncio.create_task(self._pump())
        return self

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

    async def send(self, payload: dict[str, Any]) -> None:
        """Send a payload upstream through the transport."""
        if self._closed:
            raise RuntimeError("RealtimeSession is closed")
        await self._transport.send(payload)

    async def close(self) -> None:
        """Close the transport and mark the session done."""
        if self._closed:
            return
        self._closed = True
        # Cancel pump first so it cannot put more events after we close.
        if self._pump_task is not None and not self._pump_task.done():
            self._pump_task.cancel()
            with contextlib.suppress(asyncio.CancelledError, Exception):  # noqa: BLE001
                await self._pump_task
        try:
            await self._transport.close()
        finally:
            # Always emit a CLOSED event so consumers see the terminal.
            with contextlib.suppress(RuntimeError):
                # Queue may already be closed if pump was cancelled.
                await self._events.put(RealtimeEvent(type=RealtimeEventType.CLOSED, data={}))

    def __aiter__(self) -> RealtimeSession:
        return self

    async def __anext__(self) -> RealtimeEvent:
        if self._closed and self._events.empty():
            raise StopAsyncIteration
        try:
            return await asyncio.wait_for(self._events.get(), timeout=0.05)
        except asyncio.TimeoutError:
            # Background pump will refill the queue. Loop again.
            return await self.__anext__()

    async def _pump(self) -> None:
        """Internal: drain the transport until closed, normalising events.

        The pump does NOT call :meth:`close` - that would re-enter
        ``close`` which is awaiting this task. It simply stops putting
        new events and exits; ``close`` is responsible for cancelling
        this task and emitting the final ``CLOSED`` event.
        """
        try:
            while not self._closed:
                try:
                    payload = await self._transport.recv()
                except Exception as e:  # noqa: BLE001 - transport errors become ERROR events
                    logger.warning("RealtimeSession transport error: %s", e)
                    break
                if payload is None:
                    break
                try:
                    await self._events.put(_build_event(payload))
                except Exception:  # noqa: BLE001
                    # Queue may be closed during shutdown.
                    break
        except asyncio.CancelledError:
            pass
```

## `loopy.RealtimeTransport` (class)

v0.7.10 - Pluggable WebSocket transport for ``RealtimeSession``.

Any object exposing ``async send(payload)``, ``async recv()``, and
``async close()`` can drive a ``RealtimeSession``. Loopy ships no
concrete WebSocket implementation - users wire in their preferred
client (``websockets``, ``openai-agents`` realtime client, etc.).

```python
class RealtimeTransport(Protocol):
    """v0.7.10 - Pluggable WebSocket transport for ``RealtimeSession``.

    Any object exposing ``async send(payload)``, ``async recv()``, and
    ``async close()`` can drive a ``RealtimeSession``. Loopy ships no
    concrete WebSocket implementation - users wire in their preferred
    client (``websockets``, ``openai-agents`` realtime client, etc.).
    """

    async def send(self, payload: dict[str, Any]) -> None: ...

    async def recv(self) -> dict[str, Any] | None: ...

    async def close(self) -> None: ...
```

## `loopy.RedactionMatch` (class)

v0.7.9 - A single redacted substring.

```python
@dataclass
class RedactionMatch:
    """v0.7.9 - A single redacted substring."""

    name: str
    start: int
    end: int
    replacement: str

    def __repr__(self) -> str:
        return f"RedactionMatch(name={self.name!r}, len={self.end - self.start})"
```

## `loopy.Redactor` (class)

v0.7.9 - PII / secret aware redaction for traces and exports.

Replaces sensitive substrings with stable placeholders so trace
storage and HTTP export never leak credentials, tokens, or PII.

Built-in patterns (all enabled by default):

| name            | matches                                  |
|-----------------|------------------------------------------|
| ``email``       | RFC-ish email addresses                  |
| ``phone``       | US/International phone-shaped numbers    |
| ``ssn``         | US Social Security Numbers               |
| ``credit_card`` | 13-19 digit card-shaped numbers          |
| ``openai_key``  | ``sk-...``, ``sk-proj-...`` tokens       |
| ``aws_key``     | ``AKIA``/``ASIA`` access keys            |
| ``jwt``         | Three-segment dot-delimited JWTs         |
| ``bearer``      | ``Bearer <token>`` headers               |
| ``ipv4``        | IPv4 addresses                           |

Patterns can be removed (``redactor.disable("phone")``) or extended
(``redactor.add_pattern("employee_id", r"EID-d{6}")``).

The redactor is *pure-string* and side-effect free: ``redact()``
never mutates its input, only returns a new string.

```python
@dataclass
class Redactor:
    """v0.7.9 - PII / secret aware redaction for traces and exports.

    Replaces sensitive substrings with stable placeholders so trace
    storage and HTTP export never leak credentials, tokens, or PII.

    Built-in patterns (all enabled by default):

    | name            | matches                                  |
    |-----------------|------------------------------------------|
    | ``email``       | RFC-ish email addresses                  |
    | ``phone``       | US/International phone-shaped numbers    |
    | ``ssn``         | US Social Security Numbers               |
    | ``credit_card`` | 13-19 digit card-shaped numbers          |
    | ``openai_key``  | ``sk-...``, ``sk-proj-...`` tokens       |
    | ``aws_key``     | ``AKIA``/``ASIA`` access keys            |
    | ``jwt``         | Three-segment dot-delimited JWTs         |
    | ``bearer``      | ``Bearer <token>`` headers               |
    | ``ipv4``        | IPv4 addresses                           |

    Patterns can be removed (``redactor.disable("phone")``) or extended
    (``redactor.add_pattern("employee_id", r"EID-d{6}")``).

    The redactor is *pure-string* and side-effect free: ``redact()``
    never mutates its input, only returns a new string.
    """

    name: str = "default"
    enabled: dict[str, re.Pattern[str]] = field(default_factory=dict)
    extra: dict[str, re.Pattern[str]] = field(default_factory=dict)
    placeholder_format: str = "[{name}_REDACTED]"

    def __post_init__(self) -> None:
        if not self.enabled:
            self.enabled = {
                "email": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
                "phone": re.compile(r"\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"),
                "ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
                "credit_card": re.compile(r"\b(?:\d[ -]*?){13,19}\b"),
                "openai_key": re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b"),
                "aws_key": re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
                "jwt": re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
                "bearer": re.compile(r"(?i)Bearer\s+[A-Za-z0-9._\-+/=]+"),
                "ipv4": re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
            }

    def add_pattern(self, name: str, pattern: str) -> None:
        """Register a custom regex pattern under ``name``.

        Raises ``ValueError`` if ``name`` collides with a built-in
        (use ``disable`` first if you really want to override).
        """
        if name in self.enabled:
            raise ValueError(
                f"{name!r} is a built-in pattern; disable it first if you want to override."
            )
        self.extra[name] = re.compile(pattern)

    def disable(self, name: str) -> None:
        """Remove a pattern from the active set."""
        self.enabled.pop(name, None)
        self.extra.pop(name, None)

    @property
    def active_patterns(self) -> dict[str, re.Pattern[str]]:
        """Combined dict of built-in + custom active patterns."""
        return {**self.enabled, **self.extra}

    def redact(self, text: str) -> str:
        """Return a copy of ``text`` with every match replaced."""
        if not isinstance(text, str) or not text:
            return text
        result = text
        for name, pattern in self.active_patterns.items():
            replacement = self.placeholder_format.format(name=name.upper())
            result = pattern.sub(replacement, result)
        return result

    def find_all(self, text: str) -> list[RedactionMatch]:
        """Return every match (name, span) without modifying ``text``."""
        if not isinstance(text, str) or not text:
            return []
        matches: list[RedactionMatch] = []
        for name, pattern in self.active_patterns.items():
            replacement = self.placeholder_format.format(name=name.upper())
            for m in pattern.finditer(text):
                matches.append(
                    RedactionMatch(
                        name=name,
                        start=m.start(),
                        end=m.end(),
                        replacement=replacement,
                    )
                )
        matches.sort(key=lambda m: m.start)
        return matches

    def redact_value(self, value: Any) -> Any:
        """Recursively redact string leaves inside dicts / lists / tuples."""
        if isinstance(value, str):
            return self.redact(value)
        if isinstance(value, dict):
            return {k: self.redact_value(v) for k, v in value.items()}
        if isinstance(value, list):
            return [self.redact_value(v) for v in value]
        if isinstance(value, tuple):
            return tuple(self.redact_value(v) for v in value)
        return value
```

## `loopy.RetryMiddleware` (class)

Auto-retry with exponential backoff.

Tracks retry count per-execution via context metadata so that
reusing the same middleware instance across multiple calls does
not leak state between runs.

Args:
    max_retries: Maximum number of retry attempts.
    base_delay: Base delay in seconds before the first retry.
    max_delay: Maximum delay cap in seconds.
    retryable_exceptions: Tuple of exception types that trigger a retry.

```python
class RetryMiddleware(Middleware):
    """Auto-retry with exponential backoff.

    Tracks retry count per-execution via context metadata so that
    reusing the same middleware instance across multiple calls does
    not leak state between runs.

    Args:
        max_retries: Maximum number of retry attempts.
        base_delay: Base delay in seconds before the first retry.
        max_delay: Maximum delay cap in seconds.
        retryable_exceptions: Tuple of exception types that trigger a retry.
    """

    def __init__(
        self,
        max_retries: int = 3,
        base_delay: float = 1.0,
        max_delay: float = 30.0,
        retryable_exceptions: tuple[type[Exception], ...] = (Exception,),
    ):
        self.max_retries = max_retries
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.retryable_exceptions = retryable_exceptions

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        """Initialize per-execution retry state."""
        ctx.metadata["_retry_count"] = 0
        return ctx

    async def on_error(self, ctx: MiddlewareContext, error: Exception) -> Exception:
        """
        Handle errors with exponential backoff retry.

        Checks the per-execution retry count stored in context
        metadata so state doesn't leak between pipeline calls.
        """
        retry_count = ctx.metadata.get("_retry_count", 0)
        if isinstance(error, self.retryable_exceptions) and retry_count < self.max_retries:
            delay = min(self.base_delay * (2**retry_count), self.max_delay)
            ctx.metadata["_retry_count"] = retry_count + 1
            logger.warning(
                "Retry %d/%d after %.1fs: %s",
                retry_count + 1,
                self.max_retries,
                delay,
                error,
            )
            await asyncio.sleep(delay)
            ctx.metadata["retry_count"] = retry_count + 1
            ctx.metadata["should_retry"] = True
            return error
        raise
```

## `loopy.RiskLevel` (class)

Risk level of the pattern.

```python
class RiskLevel(str, Enum):
    """Risk level of the pattern."""

    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
```

## `loopy.Router` (class)

Task router for orchestrator-workers pattern.

Classifies input and routes to specialist agents.
Part of the 2026 orchestrator-workers workflow pattern.

Example:
    router = Router()
    router.add_rule(RoutingRule(
        pattern="research|search|find",
        agent_name="researcher",
        priority=1,
    ))
    router.add_rule(RoutingRule(
        pattern="code|implement|build",
        agent_name="coder",
        priority=2,
    ))

    agent_name = await router.classify("Research Python async patterns")
    # Returns "researcher"

```python
class Router:
    """
    Task router for orchestrator-workers pattern.

    Classifies input and routes to specialist agents.
    Part of the 2026 orchestrator-workers workflow pattern.

    Example:
        router = Router()
        router.add_rule(RoutingRule(
            pattern="research|search|find",
            agent_name="researcher",
            priority=1,
        ))
        router.add_rule(RoutingRule(
            pattern="code|implement|build",
            agent_name="coder",
            priority=2,
        ))

        agent_name = await router.classify("Research Python async patterns")
        # Returns "researcher"
    """

    def __init__(
        self,
        classify_fn: Callable[[str, list[RoutingRule]], Awaitable[str]] | None = None,
    ):
        self.rules: list[RoutingRule] = []
        self.classify_fn = classify_fn

    def add_rule(self, rule: RoutingRule) -> None:
        """Add a routing rule."""
        self.rules.append(rule)
        self.rules.sort(key=lambda r: -r.priority)

    async def classify(self, task: str) -> str:
        """
        Classify a task and return the appropriate agent name.

        Args:
            task: The task description

        Returns:
            Agent name to route to
        """
        if self.classify_fn:
            return await self.classify_fn(task, self.rules)

        # Default: pattern matching with regex
        task_lower = task.lower()

        for rule in self.rules:
            if rule._compiled.search(task_lower):
                logger.info("Routed task to %s (pattern: %s)", rule.agent_name, rule.pattern)
                return rule.agent_name

        # Fallback to first agent if no match
        if self.rules:
            return self.rules[0].agent_name

        raise ValueError("No routing rules defined and no default agent")
```

## `loopy.RunOutcome` (class)

Outcome of a loop run.

```python
class RunOutcome(str, Enum):
    """Outcome of a loop run."""

    SUCCESS = "success"
    FAILURE = "failure"
    ESCALATED = "escalated"
    # v0.8.0 — loop paused for human-in-the-loop review.
    INTERRUPTED = "interrupted"
```

## `loopy.RunRecord` (class)

Record of a single loop run.

```python
@dataclass
class RunRecord:
    """Record of a single loop run."""

    task: str
    outcome: RunOutcome
    tokens_used: int = 0
    duration_ms: float = 0
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
    metadata: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "task": self.task,
            "outcome": self.outcome.value,
            "tokens_used": self.tokens_used,
            "duration_ms": self.duration_ms,
            "timestamp": self.timestamp,
            "metadata": self.metadata,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> RunRecord:
        return cls(
            task=data["task"],
            outcome=RunOutcome(data["outcome"]),
            tokens_used=data.get("tokens_used", 0),
            duration_ms=data.get("duration_ms", 0),
            timestamp=data.get("timestamp", ""),
            metadata=data.get("metadata", {}),
        )
```

## `loopy.Skill` (class)

Persistent agent knowledge.

```python
@dataclass
class Skill:
    """Persistent agent knowledge."""

    name: str
    description: str
    instructions: str
    triggers: list[str] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)

    def matches(self, task: str) -> bool:
        """Check if task matches any trigger.

        Multi-word triggers require ALL words to appear in the task.
        Single-word triggers require a whole-word match (word boundary)
        to avoid false positives from substrings.
        """
        return self.score(task) > 0.0

    def score(self, task: str) -> float:
        """v0.7.8 — Return a relevance score in [0.0, 1.0+].

        Scoring rules:
        - Each multi-word trigger matched contributes +1.0 if every word is
          present in the task, +0.5 if only some words are present.
        - Each single-word trigger matched (whole-word) contributes +0.5.
        - Result is normalized by the number of triggers so a skill with
          many triggers doesn't dominate simply by volume. Final score is
          clamped at 1.0.
        """
        if not self.triggers:
            return 0.0

        task_lower = task.lower()
        total = 0.0

        for trigger in self.triggers:
            trigger_words = trigger.lower().split()
            if len(trigger_words) > 1:
                hits = sum(1 for w in trigger_words if w in task_lower)
                if hits == len(trigger_words):
                    total += 1.0
                elif hits > 0:
                    total += 0.5 * (hits / len(trigger_words))
            else:
                if re.search(r"\b" + re.escape(trigger_words[0]) + r"\b", task_lower):
                    total += 0.5

        return min(total / max(len(self.triggers), 1), 1.0)

    @classmethod
    def from_markdown(cls, content: str) -> Skill:
        """Parse skill from markdown content."""
        lines = content.strip().split("\n")

        # Extract title
        name = "Unnamed Skill"
        description = ""
        instructions = ""
        triggers: list[str] = []

        section = None
        section_content: list[str] = []

        for line in lines:
            if line.startswith("# "):
                name = line[2:].strip()
            elif line.startswith("## Purpose"):
                if section == "triggers":
                    triggers = _extract_triggers(section_content)
                elif section == "instructions":
                    instructions = "\n".join(section_content).strip()
                section = "purpose"
                section_content = []
            elif line.startswith("## Triggers"):
                if section == "purpose":
                    description = "\n".join(section_content).strip()
                elif section == "instructions":
                    instructions = "\n".join(section_content).strip()
                section = "triggers"
                section_content = []
            elif line.startswith("## Instructions"):
                if section == "purpose":
                    description = "\n".join(section_content).strip()
                elif section == "triggers":
                    triggers = _extract_triggers(section_content)
                section = "instructions"
                section_content = []
            elif line.startswith("## "):
                if section == "purpose":
                    description = "\n".join(section_content).strip()
                elif section == "triggers":
                    triggers = _extract_triggers(section_content)
                elif section == "instructions":
                    instructions = "\n".join(section_content).strip()
                section = None
                section_content = []
            elif section is not None:
                section_content.append(line)

        # Save last section
        if section == "purpose":
            description = "\n".join(section_content).strip()
        elif section == "triggers":
            triggers = _extract_triggers(section_content)
        elif section == "instructions":
            instructions = "\n".join(section_content).strip()

        # Fallback description from first paragraph
        if not description:
            for line in lines:
                if line.strip() and not line.startswith("#") and not line.startswith("##"):
                    description = line.strip()
                    break

        return cls(
            name=name,
            description=description or f"Skill: {name}",
            instructions=instructions,
            triggers=triggers,
        )

    # v0.7.10 - A2A interop: convert to/from an A2A "Skill" primitive
    # so loopy skills can be advertised in any A2A-compatible runtime
    # (Google Agent2Agent v1.0 / Linux Foundation Agentic AI Foundation).
    # https://a2a-protocol.org/latest/specification/
    def to_a2a_card(
        self,
        *,
        tags: list[str] | None = None,
        examples: list[str] | None = None,
        input_modes: list[str] | None = None,
        output_modes: list[str] | None = None,
    ) -> dict[str, Any]:
        """Serialize this Skill into an A2A "Skill" primitive (dict).

        The shape matches the A2A spec section on Skills inside an
        Agent Card. Any field with no sensible loopy analogue (e.g.
        ``inputModes``) is omitted rather than fabricated.

        Args:
            tags: A2A-compatible tags describing the skill domain.
                Defaults to the first three ``triggers``.
            examples: Example user inputs that should match this skill.
                Defaults to empty list.
            input_modes: Accepted input modalities (e.g. ``["text"]``,
                ``["text", "image"]``). Defaults to ``["text"]``.
            output_modes: Produced output modalities. Defaults to ``["text"]``.

        Returns:
            A dict matching the A2A Skill JSON shape.
        """
        if tags is None:
            tags = list(self.triggers[:3])
        if input_modes is None:
            input_modes = ["text"]
        if output_modes is None:
            output_modes = ["text"]
        return {
            "id": self.name.lower().replace(" ", "-").replace("_", "-"),
            "name": self.name,
            "description": self.description or f"Skill: {self.name}",
            "tags": tags,
            "examples": examples or [],
            "inputModes": input_modes,
            "outputModes": output_modes,
        }

    @classmethod
    def from_a2a_card(cls, card: dict[str, Any]) -> Skill:
        """Reconstruct a :class:`Skill` from an A2A Skill primitive dict.

        ``name`` and ``description`` are required by the A2A spec; if
        either is missing the reconstruction raises ``ValueError``.
        ``tags`` are become `` ``triggers``; ``examples`` and modality
        lists are preserved in ``metadata`` for round-trip fidelity.
        """
        if not isinstance(card, dict):
            raise TypeError(f"a2a card must be a dict, got {type(card).__name__}")
        name = card.get("name")
        description = card.get("description")
        if not name:
            raise ValueError("a2a Skill primitive requires a non-empty 'name'")
        if description is None:
            raise ValueError("a2a Skill primitive requires a 'description'")

        triggers = [str(t) for t in card.get("tags", []) if t]
        instructions = "\n".join(card.get("examples", [])) or card.get("description", "")
        metadata: dict[str, Any] = {}
        if "examples" in card:
            metadata["a2a_examples"] = list(card.get("examples", []))
        if "inputModes" in card:
            metadata["a2a_input_modes"] = list(card.get("inputModes", []))
        if "outputModes" in card:
            metadata["a2a_output_modes"] = list(card.get("outputModes", []))
        if "id" in card:
            metadata["a2a_id"] = card["id"]

        return cls(
            name=name,
            description=description,
            instructions=instructions,
            triggers=triggers,
            metadata=metadata,
        )
```

## `loopy.SkillRegistry` (class)

Load and manage skills.

Example:
    registry = SkillRegistry()
    registry.load_directory("./skills")
    matched = registry.match("Fix CI workflow")

```python
class SkillRegistry:
    """
    Load and manage skills.

    Example:
        registry = SkillRegistry()
        registry.load_directory("./skills")
        matched = registry.match("Fix CI workflow")
    """

    def __init__(self):
        self._skills: dict[str, Skill] = {}

    def add(self, skill: Skill) -> None:
        """Add a skill."""
        self._skills[skill.name] = skill

    def get(self, name: str) -> Skill | None:
        """Get skill by name."""
        return self._skills.get(name)

    def list_all(self) -> list[Skill]:
        """List all skills."""
        return list(self._skills.values())

    def match(self, task: str) -> list[Skill]:
        """Match skills to a task."""
        return [s for s in self._skills.values() if s.matches(task)]

    def match_ranked(
        self,
        task: str,
        min_score: float = 0.0,
        limit: int | None = None,
    ) -> list[tuple[Skill, float]]:
        """v0.7.8 — Return matched skills ordered by relevance score (desc).

        Args:
            task: Task description to match against.
            min_score: Drop matches below this score (default 0.0).
            limit: Cap on number of returned matches (default unlimited).

        Returns:
            List of ``(Skill, score)`` tuples, highest score first.
        """
        scored: list[tuple[Skill, float]] = []
        for skill in self._skills.values():
            score = skill.score(task)
            if score >= min_score:
                scored.append((skill, score))

        scored.sort(key=lambda pair: pair[1], reverse=True)
        if limit is not None:
            scored = scored[:limit]
        return scored

    def match_one(self, task: str, min_score: float = 0.0) -> Skill | None:
        """v0.7.8 — Return the single best-matching skill, or None.

        Convenience wrapper around :meth:`match_ranked` for the common
        "pick one" case. Returns the highest-scoring skill above
        ``min_score``, or ``None`` if nothing qualifies.
        """
        ranked = self.match_ranked(task, min_score=min_score, limit=1)
        return ranked[0][0] if ranked else None

    def to_a2a_skills(self) -> list[dict[str, Any]]:
        """v0.7.10 — Export every skill in this registry as A2A primitives.

        Returns a list of A2A Skill dicts, suitable for embedding in the
        ``skills`` field of an Agent Card served at
        ``/.well-known/agent-card.json`` (A2A v1.0 spec).
        """
        return [s.to_a2a_card() for s in self._skills.values()]

    def load_file(self, path: str) -> Skill:
        """Load a single skill file."""
        content = Path(path).read_text(encoding="utf-8")
        skill = Skill.from_markdown(content)
        self.add(skill)
        return skill

    def load_directory(self, directory: str) -> int:
        """Load all .md files from a directory."""
        dir_path = Path(directory)
        loaded = 0

        if not dir_path.exists():
            logger.warning("Skill directory not found: %s", directory)
            return 0

        for md_file in dir_path.glob("*.md"):
            try:
                self.load_file(str(md_file))
                loaded += 1
            except Exception as e:
                logger.error("Failed to load skill from %s: %s", md_file, e)

        return loaded
```

## `loopy.Span` (class)

A single trace span representing an operation.

Example:
    span = Tracer.start_span("llm_call", model="gpt-4")
    # ... do work ...
    span.set_attribute("tokens", 150)
    span.set_status(SpanStatus.OK)
    span.end()

```python
@dataclass
class Span:
    """
    A single trace span representing an operation.

    Example:
        span = Tracer.start_span("llm_call", model="gpt-4")
        # ... do work ...
        span.set_attribute("tokens", 150)
        span.set_status(SpanStatus.OK)
        span.end()
    """

    name: str
    trace_id: str
    span_id: str
    parent_id: str | None = None

    start_time: float = field(default_factory=time.time)
    end_time: float | None = None

    status: SpanStatus = SpanStatus.UNSET
    attributes: dict[str, Any] = field(default_factory=dict)
    events: list[dict[str, Any]] = field(default_factory=list)
    # v0.8.0 — False for sentinel spans returned by a disabled or
    # shutdown tracer. Drives cheap no-ops in the @observe() decorator
    # and keeps export methods from surfacing internal noise.
    recorded: bool = True

    @property
    def duration_ms(self) -> float | None:
        if self.end_time is None:
            return None
        return (self.end_time - self.start_time) * 1000

    def set_attribute(self, key: str, value: Any) -> None:
        """Set a span attribute."""
        self.attributes[key] = value

    def add_event(self, name: str, attributes: dict[str, Any] | None = None) -> None:
        """Add an event to the span."""
        self.events.append(
            {
                "name": name,
                "timestamp": time.time(),
                "attributes": attributes or {},
            }
        )

    def set_status(self, status: SpanStatus, message: str = "") -> None:
        """Set span status."""
        self.status = status
        if message:
            self.attributes["status_message"] = message

    def end(self) -> None:
        """End the span."""
        self.end_time = time.time()

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary."""
        return {
            "name": self.name,
            "trace_id": self.trace_id,
            "span_id": self.span_id,
            "parent_id": self.parent_id,
            "start_time": self.start_time,
            "end_time": self.end_time,
            "duration_ms": self.duration_ms,
            "status": self.status.value,
            "attributes": self.attributes,
            "events": self.events,
        }
```

## `loopy.SpanStatus` (class)

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
class SpanStatus(str, Enum):
    OK = "ok"
    ERROR = "error"
    UNSET = "unset"
```

## `loopy.State` (class)

The value passed between steps.

``data`` is the user-visible payload; ``metadata`` is for
workflow-level bookkeeping (current step index, attempts, etc.).
Each step receives a fresh ``State`` so steps cannot mutate
the parent.

```python
@dataclass
class State:
    """The value passed between steps.

    ``data`` is the user-visible payload; ``metadata`` is for
    workflow-level bookkeeping (current step index, attempts, etc.).
    Each step receives a fresh ``State`` so steps cannot mutate
    the parent.
    """

    data: StateData
    metadata: dict[str, Any] = field(default_factory=dict)
```

## `loopy.StateGraph` (class)

A typed, validated state graph.

``nodes`` maps node name to ``Node``. ``edges`` is an ordered
list of ``Edge``; the first edge matching the current node +
condition is taken. ``entry`` is the starting node. ``terminal``
is the set of node names whose completion ends the workflow.

```python
@dataclass
class StateGraph:
    """A typed, validated state graph.

    ``nodes`` maps node name to ``Node``. ``edges`` is an ordered
    list of ``Edge``; the first edge matching the current node +
    condition is taken. ``entry`` is the starting node. ``terminal``
    is the set of node names whose completion ends the workflow.
    """

    name: str
    nodes: dict[str, Node]
    edges: list[Edge]
    entry: str
    terminal: set[str] = field(default_factory=set)

    def __post_init__(self) -> None:
        # Name sanity: prevents path-traversal attacks on StateManager.
        for name in self.nodes:
            if not name or "/" in name or "\\" in name or name.startswith("."):
                raise ValueError(
                    f"Invalid node name {name!r}: must be non-empty, no slashes, no leading dot"
                )
        if self.entry not in self.nodes:
            raise ValueError(f"Entry node {self.entry!r} not in graph nodes {sorted(self.nodes)}")
        for t in self.terminal:
            if t not in self.nodes:
                raise ValueError(f"Terminal node {t!r} not in graph nodes {sorted(self.nodes)}")
        for edge in self.edges:
            if edge.from_node not in self.nodes:
                raise ValueError(f"Edge.from_node {edge.from_node!r} not in graph")
            if edge.to_node not in self.nodes:
                raise ValueError(f"Edge.to_node {edge.to_node!r} not in graph")
        # Cycle detection: every cycle must include a terminating node.
        self._validate_no_open_cycles()

    def _validate_no_open_cycles(self) -> None:
        """Build-time check: every node must reach a terminal node.

        For each node in the graph, do a BFS over outgoing edges and
        confirm that *some* terminal is reachable. If a node has no
        path to any terminal (because it is in a cycle of non-terminal
        nodes), raise ``ValueError``.

        Cycles that pass through (or are escapable to) a terminal
        are fine. Example: a <-> b -> c (terminal c) is OK because
        from a you can reach c.
        """
        # Build adjacency map for fast lookup.
        outgoing: dict[str, list[str]] = {n: [] for n in self.nodes}
        for edge in self.edges:
            outgoing[edge.from_node].append(edge.to_node)

        for start in self.nodes:
            if self._can_reach_terminal(start, outgoing):
                continue
            raise ValueError(
                f"Graph has open cycle not reaching any terminal node starting from {start!r}"
            )

    def _can_reach_terminal(self, start: str, outgoing: dict[str, list[str]]) -> bool:
        """BFS: can ``start`` reach any node in ``self.terminal``?

        Tracks visited nodes to handle cycles without infinite loops.
        """
        visited: set[str] = set()
        queue: list[str] = [start]
        while queue:
            node = queue.pop(0)
            if node in visited:
                continue
            visited.add(node)
            if node in self.terminal:
                return True
            for nxt in outgoing[node]:
                if nxt not in visited:
                    queue.append(nxt)
        return False
```

## `loopy.StateManager` (class)

Read/write loop state to disk.

Example:
    manager = StateManager("./loop-state.json")
    state = manager.load()
    state.current_task = "Fix CI"
    manager.save(state)

```python
class StateManager:
    """
    Read/write loop state to disk.

    Example:
        manager = StateManager("./loop-state.json")
        state = manager.load()
        state.current_task = "Fix CI"
        manager.save(state)
    """

    def __init__(self, path: str = "./loop-state.json"):
        self.path = Path(path)

    def load(self) -> LoopState:
        """Load state from disk, or return empty state."""
        if not self.path.exists():
            return LoopState()

        try:
            data = json.loads(self.path.read_text())
            return LoopState.from_dict(data)
        except Exception as e:
            logger.warning("Failed to load state: %s", e)
            return LoopState()

    def save(self, state: LoopState) -> None:
        """Save state to disk."""
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.path.write_text(json.dumps(state.to_dict(), indent=2))

    def prune(self, max_age_days: int = 30) -> int:
        """
        Remove records older than max_age_days.

        Returns:
            Number of records pruned
        """
        state = self.load()
        cutoff = datetime.now() - timedelta(days=max_age_days)

        original_count = len(state.history)
        state.history = [r for r in state.history if _parse_timestamp(r.timestamp) >= cutoff]
        pruned = original_count - len(state.history)

        if pruned > 0:
            self.save(state)
            logger.info("Pruned %d old records", pruned)

        return pruned
```

## `loopy.StepResult` (class)

Result of a single loop iteration.

```python
@dataclass
class StepResult:
    """Result of a single loop iteration."""

    step: int
    status: StepStatus
    plan: str = ""
    action: str = ""
    observation: str = ""
    reflection: str = ""
    data: dict[str, Any] = field(default_factory=dict)
    error: str | None = None
```

## `loopy.StepStatus` (class)

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
class StepStatus(str, Enum):
    PLANNING = "planning"
    ACTING = "acting"
    OBSERVING = "observing"
    REFLECTING = "reflecting"
    COMPLETE = "complete"
    FAILED = "failed"
```

## `loopy.StreamBuffer` (class)

Buffer for accumulating stream tokens.

Accumulates tokens and periodically flushes them into
larger chunks when *flush_threshold* is reached.

Args:
    flush_threshold: Number of tokens before auto-flush.

```python
class StreamBuffer:
    """Buffer for accumulating stream tokens.

    Accumulates tokens and periodically flushes them into
    larger chunks when *flush_threshold* is reached.

    Args:
        flush_threshold: Number of tokens before auto-flush.
    """

    def __init__(self, flush_threshold: int = 10):
        self.tokens: list[str] = []
        self.flush_threshold = flush_threshold
        self.total_tokens = 0

    def add(self, token: str) -> str | None:
        """Append a token; returns flushed content if threshold met.

        Args:
            token: A single token string.

        Returns:
            Flushed content if threshold reached, else *None*.
        """
        self.tokens.append(token)
        self.total_tokens += 1

        if len(self.tokens) >= self.flush_threshold:
            return self.flush()
        return None

    def flush(self) -> str:
        """Flush all buffered tokens into a single string.

        Returns:
            The concatenated buffered content.
        """
        content = "".join(self.tokens)
        self.tokens.clear()
        return content

    @property
    def pending(self) -> str:
        """The currently buffered (not yet flushed) content."""
        return "".join(self.tokens)
```

## `loopy.StreamChunk` (class)

A single chunk in a stream.

```python
@dataclass
class StreamChunk:
    """A single chunk in a stream."""

    event: StreamEvent
    data: Any
    index: int = 0
    metadata: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "event": self.event.value,
            "data": self.data,
            "index": self.index,
            "metadata": self.metadata,
        }

    def to_sse(self) -> str:
        """Format as Server-Sent Event."""
        payload = json.dumps(self.to_dict())
        return f"event: {self.event.value}\ndata: {payload}\n\n"
```

## `loopy.StreamEvent` (class)

Types of stream events.

```python
class StreamEvent(str, Enum):
    """Types of stream events."""

    TOKEN = "token"
    TOOL_CALL = "tool_call"
    TOOL_RESULT = "tool_result"
    THINKING = "thinking"
    ERROR = "error"
    DONE = "done"
```

## `loopy.TEST_MODEL_SENTINEL`

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
(source unavailable)```

## `loopy.TaskDecomposer` (class)

Decomposes complex tasks into subtasks.

Part of the 2026 orchestrator-workers workflow pattern.

Example:
    decomposer = TaskDecomposer(classify_fn=my_classifier)

    subtasks = await decomposer.decompose(
        "Build a REST API with tests and documentation"
    )

    for task in subtasks:
        print(f"{task.id}: {task.description}")

```python
class TaskDecomposer:
    """
    Decomposes complex tasks into subtasks.

    Part of the 2026 orchestrator-workers workflow pattern.

    Example:
        decomposer = TaskDecomposer(classify_fn=my_classifier)

        subtasks = await decomposer.decompose(
            "Build a REST API with tests and documentation"
        )

        for task in subtasks:
            print(f"{task.id}: {task.description}")
    """

    def __init__(self, classify_fn: Callable[[str], Awaitable[str]] | None = None):
        self.classify_fn = classify_fn

    async def decompose(self, task: str) -> list[SubTask]:
        """
        Break a task into subtasks with dependencies.

        Uses built-in pattern matching for common task types (API, research,
        generic). Override ``classify_fn`` in ``__init__`` to plug in an LLM
        or custom classifier for richer decomposition.

        Args:
            task: The high-level task to decompose

        Returns:
            List of SubTask objects with dependencies
        """
        # Simple pattern-based decomposition
        # Override classify_fn for LLM-powered decomposition
        subtasks = []
        task_lower = task.lower()

        # Detect common patterns
        if "api" in task_lower or "rest" in task_lower:
            subtasks.append(
                SubTask(
                    id="design",
                    description="Design API endpoints and data models",
                    required_agent="architect",
                )
            )
            subtasks.append(
                SubTask(
                    id="implement",
                    description="Implement API endpoints",
                    dependencies=["design"],
                    required_agent="coder",
                )
            )
            subtasks.append(
                SubTask(
                    id="test",
                    description="Write and run tests",
                    dependencies=["implement"],
                    required_agent="tester",
                )
            )
        elif "research" in task_lower or "analyze" in task_lower:
            subtasks.append(
                SubTask(
                    id="gather",
                    description="Gather information and sources",
                    required_agent="researcher",
                )
            )
            subtasks.append(
                SubTask(
                    id="analyze",
                    description="Analyze findings",
                    dependencies=["gather"],
                    required_agent="analyst",
                )
            )
            subtasks.append(
                SubTask(
                    id="synthesize",
                    description="Synthesize into report",
                    dependencies=["analyze"],
                    required_agent="writer",
                )
            )
        else:
            # Generic decomposition
            subtasks.append(
                SubTask(
                    id="plan",
                    description=f"Plan approach for: {task[:50]}...",
                    required_agent="planner",
                )
            )
            subtasks.append(
                SubTask(
                    id="execute",
                    description="Execute the plan",
                    dependencies=["plan"],
                    required_agent="executor",
                )
            )

        return subtasks
```

## `loopy.TestModel` (class)

v0.7.9 - Zero-network model for unit tests and CI.

Replace HTTP calls with deterministic scripted replies so you can
exercise the full agent loop without API keys, rate limits, or
billing. Compatible with :meth:`Gateway.chat`.

Example::

    gw = Gateway()
    response = await gw.chat(
        "hi",
        model=TestModel(responses=["hi back"]),
    )
    assert response.content == "hi back"
    assert response.metadata["test_model"] is True

Args:
    responses: Ordered list of canned replies. If exhausted, the
        last entry is reused (so the model never raises for lack of
        material). Use ``callable`` for dynamic replies.
    tool_calls: Optional pre-canned tool calls to emit alongside
        the text reply. Each entry is a dict with ``name`` and
        ``args`` keys; the gateway surfaces them in
        ``response.metadata["tool_calls"]``.
    latency_ms: Artificial latency to inject per call (0 = instant).
    model_name: Reported model name in ``GatewayResponse.model``.
        Defaults to ``"test"``.
    raise_on_message: If set, raise this exception when ``message``
        matches the regex/string. Useful for testing error paths.

```python
class TestModel:
    """v0.7.9 - Zero-network model for unit tests and CI.

    Replace HTTP calls with deterministic scripted replies so you can
    exercise the full agent loop without API keys, rate limits, or
    billing. Compatible with :meth:`Gateway.chat`.

    Example::

        gw = Gateway()
        response = await gw.chat(
            "hi",
            model=TestModel(responses=["hi back"]),
        )
        assert response.content == "hi back"
        assert response.metadata["test_model"] is True

    Args:
        responses: Ordered list of canned replies. If exhausted, the
            last entry is reused (so the model never raises for lack of
            material). Use ``callable`` for dynamic replies.
        tool_calls: Optional pre-canned tool calls to emit alongside
            the text reply. Each entry is a dict with ``name`` and
            ``args`` keys; the gateway surfaces them in
            ``response.metadata["tool_calls"]``.
        latency_ms: Artificial latency to inject per call (0 = instant).
        model_name: Reported model name in ``GatewayResponse.model``.
            Defaults to ``"test"``.
        raise_on_message: If set, raise this exception when ``message``
            matches the regex/string. Useful for testing error paths.
    """

    __slots__ = (
        "responses",
        "tool_calls",
        "latency_ms",
        "model_name",
        "raise_on_message",
        "_index",
        "calls",
    )

    def __init__(
        self,
        responses: list[str | Callable[[str, str | None], str]] | None = None,
        *,
        tool_calls: list[dict[str, Any]] | None = None,
        latency_ms: float = 0.0,
        model_name: str = "test",
        raise_on_message: re.Pattern[str] | str | None = None,
    ) -> None:
        self.responses: list[str | Callable[[str, str | None], str]] = (
            list(responses) if responses is not None else list(DEFAULT_TEST_MODEL_RESPONSES)
        )
        self.tool_calls = tool_calls or []
        self.latency_ms = float(latency_ms)
        self.model_name = model_name
        self.raise_on_message = raise_on_message
        self._index = 0
        self.calls: list[dict[str, Any]] = []

    def next_response(self, message: str, system: str | None) -> str:
        """Return the next scripted reply (advances the cursor)."""
        if not self.responses:
            return ""
        if self._index < len(self.responses):
            reply = self.responses[self._index]
            self._index += 1
        else:
            # Reuse the last reply rather than exhausting.
            reply = self.responses[-1]
        if callable(reply):
            return reply(message, system)
        return reply

    async def handle(
        self,
        message: str,
        system: str | None,
        temperature: float,  # noqa: ARG002 - accepted for protocol parity
        max_tokens: int,  # noqa: ARG002
        response_format: type[BaseModel] | None = None,
    ) -> GatewayResponse:
        """Produce a canned :class:`GatewayResponse` (no I/O)."""
        if self.raise_on_message is not None:
            pattern = self.raise_on_message
            if isinstance(pattern, str):
                if pattern in message:
                    raise RuntimeError(f"Test model forced error: {pattern!r} in message")
            elif pattern.search(message):
                raise RuntimeError(f"Test model forced error on {pattern.pattern!r}")

        self.calls.append({"message": message, "system": system})

        if self.latency_ms > 0:
            await asyncio.sleep(self.latency_ms / 1000.0)

        content = self.next_response(message, system)

        metadata: dict[str, Any] = {"test_model": True, "test_call_index": len(self.calls) - 1}
        if self.tool_calls:
            metadata["tool_calls"] = list(self.tool_calls)

        structured: Any | None = None
        if response_format is not None:
            # Allow tests to pre-format their canned reply as JSON.
            try:
                structured = response_format.model_validate_json(content)
            except Exception:
                # Leave structured as None so callers see the failure path.
                structured = None
            metadata["response_format"] = response_format.__name__

        return GatewayResponse(
            content=content,
            model=self.model_name,
            provider=ModelProvider.OPENAI,  # placeholder; not used
            tokens_used=len(content.split()),
            metadata=metadata,
            structured=structured,
        )

    def reset(self) -> None:
        """Rewind the response cursor and clear recorded calls."""
        self._index = 0
        self.calls.clear()
```

## `loopy.TimingMiddleware` (class)

Tracks operation timing.

```python
class TimingMiddleware(Middleware):
    """Tracks operation timing."""

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        ctx.metadata["start_time"] = time.time()
        return ctx

    async def after(self, ctx: MiddlewareContext, result: Any) -> Any:
        start = ctx.metadata.get("start_time")
        if start:
            elapsed_ms = (time.time() - start) * 1000
            ctx.metadata["elapsed_ms"] = elapsed_ms
            logger.debug("[%s] Took %.1fms", ctx.operation, elapsed_ms)
        return result
```

## ToolCall

> Not exported by `loopy`.

## ToolContext

> Not exported by `loopy`.

## ToolDef

> Not exported by `loopy`.

## ToolExecutor

> Not exported by `loopy`.

## ToolParamSchema

> Not exported by `loopy`.

## `loopy.Tracer` (class)

Distributed tracer for LLM operations.

Example:
    tracer = Tracer(service="my_app")

    with tracer.start("llm_call") as span:
        span.set_attribute("model", "gpt-4")
        response = await llm.complete(prompt)
        span.set_attribute("tokens", response.usage.total_tokens)

```python
class Tracer:
    """
    Distributed tracer for LLM operations.

    Example:
        tracer = Tracer(service="my_app")

        with tracer.start("llm_call") as span:
            span.set_attribute("model", "gpt-4")
            response = await llm.complete(prompt)
            span.set_attribute("tokens", response.usage.total_tokens)
    """

    def __init__(self, service: str = "loopy", redactor: Redactor | None = None):
        self.service = service
        self._spans: list[Span] = []
        self._current_trace_id: str | None = None
        # v0.7.9 - optional redactor applied at span completion.
        self.redactor: Redactor | None = redactor
        # v0.8.0 - instrumentation controls. ``disabled`` is a runtime
        # flag (sets it to True to skip span recording without
        # touching call sites); ``shutdown`` is a one-way latch set
        # by :meth:`shutdown` and makes every public entry point a
        # graceful no-op (used by the @observe() decorator so it
        # never raises after a tracer is torn down).
        self.disabled: bool = False
        self._shutdown: bool = False

    def _generate_id(self) -> str:
        """Generate a unique ID using UUID4."""
        return uuid.uuid4().hex[:16]

    def start_span(
        self,
        name: str,
        parent_id: str | None = None,
        **attributes: Any,
    ) -> Span:
        """
        Start a new span.

        Args:
            name: Span name (e.g., "llm_call", "tool_use")
            parent_id: Optional parent span ID
            **attributes: Initial attributes

        Returns:
            New Span instance. When ``self.disabled`` is True or the
            tracer has been :meth:`shutdown`, a sentinel
            :class:`Span` with ``recorded=False`` is returned so the
            @observe() decorator can drive its lifecycle without
            raising.
        """
        trace_id = self._current_trace_id or self._generate_id()
        span_id = self._generate_id()

        if self.disabled or self._shutdown:
            return Span(
                name=name,
                trace_id=trace_id,
                span_id=span_id,
                parent_id=parent_id,
                recorded=False,
            )

        span = Span(
            name=name,
            trace_id=trace_id,
            span_id=span_id,
            parent_id=parent_id,
            attributes={"service": self.service, **attributes},
        )

        # v0.7.9 - scrub attributes/events before storage.
        if self.redactor is not None:
            span.attributes = self.redactor.redact_value(span.attributes)
            span.events = self.redactor.redact_value(span.events)

        self._spans.append(span)
        logger.debug("Started span: %s (%s)", name, span_id)

        return span

    def shutdown(self) -> None:
        """v0.8.0 — one-way latch: subsequent ``start_span`` calls return
        non-recording sentinel spans so instrumentation never raises.
        """
        self._shutdown = True

    def start(self, name: str, **attributes: Any) -> SpanContext:
        """
        Start a span with context manager support.

        Example:
            with tracer.start("llm_call") as span:
                span.set_attribute("model", "gpt-4")
                # ... do work ...
        """
        span = self.start_span(name, **attributes)
        return SpanContext(span)

    def get_spans(self) -> list[Span]:
        """Get all recorded spans."""
        return self._spans.copy()

    def get_trace(self, trace_id: str) -> list[Span]:
        """Get all spans for a trace."""
        return [s for s in self._spans if s.trace_id == trace_id]

    def export_json(self) -> str:
        """Export all spans as JSON."""
        return json.dumps([s.to_dict() for s in self._spans], indent=2)

    def export_otlp(self) -> dict[str, Any]:
        """
        Export spans in OTLP-compatible format.

        Delegates to :meth:`export_opentelemetry` for consistent
        output across all export methods.

        Returns:
            A dict with resource attributes and span data.
        """
        return self.export_opentelemetry()

    def clear(self) -> None:
        """Clear all spans."""
        self._spans.clear()

    def export_opentelemetry(self) -> dict[str, Any]:
        """
        Export spans in OpenTelemetry-compatible format.

        Returns a dict with resource info and spans ready for OTLP export.
        """
        return {
            "resource": {
                "attributes": {
                    "service.name": self.service,
                    "service.version": __version__,
                }
            },
            "spans": [
                {
                    "traceId": s.trace_id,
                    "spanId": s.span_id,
                    "parentSpanId": s.parent_id,
                    "operationName": s.name,
                    "startTime": int(s.start_time * 1e6),  # microseconds
                    "endTime": int((s.end_time or time.time()) * 1e6),
                    "attributes": [
                        {"key": k, "value": {"stringValue": str(v)}}
                        for k, v in s.attributes.items()
                    ],
                    "events": [
                        {
                            "name": e["name"],
                            "time": int(e["timestamp"] * 1e6),
                        }
                        for e in s.events
                    ],
                }
                for s in self._spans
            ],
        }
```

## `loopy.ValidationMiddleware` (class)

Validates data before processing.

```python
class ValidationMiddleware(Middleware):
    """Validates data before processing."""

    def __init__(
        self,
        required_fields: list[str] | None = None,
        validators: dict[str, Callable[[Any], bool]] | None = None,
    ):
        self.required_fields = required_fields or []
        self.validators = validators or {}

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        # Check required fields
        for field_name in self.required_fields:
            if field_name not in ctx.data:
                ctx.cancel(f"Missing required field: {field_name}")
                return ctx

        # Run validators
        for field_name, validator in self.validators.items():
            if field_name in ctx.data and not validator(ctx.data[field_name]):
                ctx.cancel(f"Validation failed for field: {field_name}")
                return ctx

        return ctx
```

## `loopy.Verdict` (class)

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
class Verdict(str, Enum):
    PASS = "pass"
    FAIL = "fail"
    PARTIAL = "partial"
```

## Verifier

> Not exported by `loopy`.

## `loopy.Workflow` (class)

Run a DAG to completion or resume from a journal.

```python
class Workflow:
    """Run a DAG to completion or resume from a journal."""

    @staticmethod
    async def run(
        dag: DAG,
        initial_state: State,
        *,
        journal_path: str | None = None,
    ) -> State:
        """Execute every step in order. If ``journal_path`` is set,
        each completed step is persisted so the run can be resumed
        from any point after a crash.

        Raises:
            Exception: re-raises the first exception from a step
                after running every earlier step's compensation.
        """
        return await Workflow._run(dag, initial_state, journal_path, resume_from=None)

    @staticmethod
    def resume(  # type: ignore[override]
        token: Any,
        dag: DAG | None = None,
        initial_state: State | None = None,
    ) -> Any:
        """Resume a partially-completed workflow from ``token``.

        The first positional argument is validated as a
        :class:`ResumeToken` *before* any other argument check, so
        calling ``Workflow.resume("not-a-token")`` raises
        ``ValueError`` with a useful message rather than a
        ``TypeError`` about missing kwargs.

        Returns a coroutine; awaiting it runs the workflow.
        """
        if not isinstance(token, ResumeToken):
            raise ValueError(f"resume() requires a ResumeToken, got {type(token).__name__}")
        return Workflow._resume(token, dag, initial_state)

    @staticmethod
    async def _resume(
        token: ResumeToken,
        dag: DAG,
        initial_state: State,
    ) -> State:
        if not Path(token.journal_path).exists():
            raise FileNotFoundError(
                f"ResumeToken journal_path does not exist: {token.journal_path}"
            )
        return await Workflow._run(
            dag,
            initial_state,
            token.journal_path,
            resume_from=token.last_completed_step,
        )

    @staticmethod
    async def _run(
        dag: DAG,
        initial_state: State,
        journal_path: str | None,
        *,
        resume_from: str | None,
    ) -> State:
        records: list[_JournalRecord] = _load_journal(journal_path) if journal_path else []
        completed: set[str] = {r.step for r in records}
        state = initial_state
        completed_steps: list[Step] = []

        try:
            for step in dag.steps:
                if step.name in completed and resume_from is not None:
                    # Replay-from-journal: load the persisted state.
                    persisted = next(r for r in records if r.step == step.name)
                    state = State(
                        data=dict(persisted.state),
                        metadata=dict(state.metadata),
                    )
                    continue
                if step.name in completed:
                    # No resume: skip already-completed steps when
                    # the journal and DAG are aligned.
                    continue

                state = await step.run(state)
                completed_steps.append(step)
                completed.add(step.name)
                if journal_path:
                    records.append(
                        _JournalRecord(
                            step=step.name,
                            state=dict(state.data),
                            timestamp=time.time(),
                            attempt=1,
                        )
                    )
                    _save_journal(journal_path, records)
        except Exception:
            # Saga: run compensations in reverse order for every
            # step that successfully completed *this run*.
            for done in reversed(completed_steps):
                if done.compensation is not None:
                    try:
                        done.compensation(state)
                    except Exception as exc:  # noqa: BLE001
                        logger.warning("Compensation for %s failed: %s", done.name, exc)
            raise

        return state

    @staticmethod
    def test_env(
        journal_path: str | None = None,
        *,
        start: float = 0.0,
    ) -> DurableTestEnv:
        """Create an isolated :class:`TestEnv` for deterministic time.

        Args:
            journal_path: When set, the virtual clock is persisted
                to this JSON file so a follow-up
                ``Workflow.run(journal_path=...)`` can pick up the
                same virtual timeline.
            start: Initial virtual timestamp (defaults to 0.0).
        """
        # The clock is wrapped in a single-element list so the
        # inner closure can mutate it without ``global`` or a
        # nonlocals declaration.
        clock_box: list[float] = [float(start)]
        if journal_path and Path(journal_path).exists():
            try:
                raw = json.loads(Path(journal_path).read_text())
                clock_box[0] = float(raw.get("clock", clock_box[0]))
            except (json.JSONDecodeError, ValueError, TypeError):
                # Corrupt or missing clock field; start fresh.
                pass

        def _persist() -> None:
            if not journal_path:
                return
            Path(journal_path).write_text(json.dumps({"clock": clock_box[0], "version": 1}))

        return DurableTestEnv(
            _now=lambda: clock_box[0],
            _advance=lambda seconds: _advance_inplace(clock_box, seconds),
            _persist=_persist,
        )
```


# Module `loopy.loop`

## `loopy.loop.AgentLoop` (class)

The agentic loop engine.

Example:
    async def my_planner(history):
        return "I will search for information about Python."

    async def my_actor(plan):
        return "Searched the web and found 3 results."

    async def my_observer(action):
        return "Found relevant docs about Python asyncio."

    async def my_reflector(history):
        return "Good progress, but need more details on threading."

    loop = AgentLoop(LoopConfig(
        planner=my_planner,
        actor=my_actor,
        observer=my_observer,
        reflector=my_reflector,
    ))

    results = await loop.run()

```python
class AgentLoop:
    """
    The agentic loop engine.

    Example:
        async def my_planner(history):
            return "I will search for information about Python."

        async def my_actor(plan):
            return "Searched the web and found 3 results."

        async def my_observer(action):
            return "Found relevant docs about Python asyncio."

        async def my_reflector(history):
            return "Good progress, but need more details on threading."

        loop = AgentLoop(LoopConfig(
            planner=my_planner,
            actor=my_actor,
            observer=my_observer,
            reflector=my_reflector,
        ))

        results = await loop.run()
    """

    def __init__(self, config: LoopConfig | None = None):
        self.config = config or LoopConfig()
        self.history: list[StepResult] = []
        # v0.8.0 — set by run() when resuming past a before-gate interrupt,
        # so _run_step can skip that single (step, phase) gate on re-entry.
        self._skip_before: tuple[int, str] | None = None

    async def run(
        self,
        initial_context: str = "",
        *,
        resume_from: Interrupt | None = None,
    ) -> Interrupt | list[StepResult]:
        """
        Execute the full agentic loop.

        v0.8.0 — HITL interrupts: if ``LoopConfig.interrupt_before`` or
        ``LoopConfig.interrupt_after`` matches the current phase, this
        method returns an :class:`Interrupt` instance instead of a list
        of ``StepResult``. To continue, pass the Interrupt back via
        ``resume_from=Interrupt(decision="approve")``.

        Returns:
            ``Interrupt`` if the loop paused for human review;
            ``list[StepResult]`` if the loop completed without pausing.

        Raises:
            AgentLoopRejected: when ``resume_from`` carries ``decision="reject"``.
        """
        # v0.8.0 — handle resume_from decision before entering the loop.
        if resume_from is not None:
            if resume_from.decision is None:
                raise ValueError(
                    "resume_from must carry a decision ('approve' or 'reject'); "
                    "received Interrupt with decision=None"
                )
            if resume_from.decision == "reject":
                raise AgentLoopRejected(
                    proposal=resume_from.proposed_action,
                    context=resume_from.context,
                )
            # decision == "approve" — re-enter at the same step so the
            # after-gate (if any) still fires for the same step. The
            # before-gate we just approved is suppressed via _skip_before.
            self._skip_before = (resume_from.step, resume_from.phase)
            start_step = max(1, resume_from.step)
        elif self.config.resume_from is not None:
            start_step = max(1, self.config.resume_from + 1)
            self._skip_before = None
            logger.info("Resuming loop at step %d", start_step)
        else:
            start_step = 1
            self._skip_before = None

        self.history = []

        if initial_context:
            self.history.append(
                StepResult(
                    step=0,
                    status=StepStatus.OBSERVING,
                    observation=initial_context,
                )
            )

        # legacy compatibility: keep this no-op assignment for any
        # downstream consumer that read start_step here before the
        # v0.8.0 restructure (see ``test_t1001_characterization.py``).
        _ = start_step

        try:
            for step_num in range(start_step, self.config.max_steps + 1):
                # v0.9.0 — Compliance-as-Code: evaluate policies before
                # the step runs. ``gate()`` raises on ``block`` and
                # returns the full list of decisions otherwise. We
                # record the raw context (audit fidelity) so
                # post-hoc scrubbing is the storage layer's job.
                if self.config.policy_engine is not None:
                    step_decisions = self.config.policy_engine.gate(
                        {"step": step_num, "retries": step_num - 1}
                    )
                    if step_decisions and self.config.state_manager is not None:
                        try:
                            self._record_policy_decisions(step_num, step_decisions)
                        except Exception as e:  # noqa: BLE001
                            logger.warning(
                                "Failed to record policy decisions at step %d: %s",
                                step_num,
                                e,
                            )

                result = await self._run_step(step_num)
                self.history.append(result)

                # v0.7.8 — checkpoint after every step when configured
                self._checkpoint(result)

                if result.status == StepStatus.FAILED and self.config.stop_on_error:
                    logger.error("Loop stopped at step %d: %s", step_num, result.error)
                    break

                # Check custom stop condition
                if self.config.should_stop:
                    try:
                        if await self.config.should_stop(self.history):
                            logger.info("Stop condition met at step %d", step_num)
                            break
                    except Exception as e:
                        logger.warning("Stop condition check failed: %s", e)

                # Default stop: all callbacks are None (no-op loop)
                if not any(
                    [
                        self.config.planner,
                        self.config.actor,
                        self.config.observer,
                        self.config.reflector,
                    ]
                ):
                    logger.info("No callbacks configured, stopping loop")
                    break
        except _InterruptedRun as ir:
            # v0.8.0 — a phase triggered an Interrupt. Persist it via
            # the configured state manager (best-effort) so a crash+resume
            # can replay, then return it to the caller for review.
            self._persist_interrupt(ir.interrupt)
            return ir.interrupt

        return self.history

    def _record_policy_decisions(self, step_num: int, decisions: list[Any]) -> None:
        """v0.9.0 — Append raw policy decisions to LoopState.metadata
        so a crash+resume can replay the audit trail.

        The decisions are stored verbatim (no redaction) so the
        audit log has the raw facts; storage-side scrubbing is the
        caller's responsibility when reading the LoopState back out.
        """
        if not self.config.state_manager:
            return

        from loopy.state import RunOutcome, RunRecord

        sm = self.config.state_manager
        state = sm.load()
        existing = list(state.metadata.get("policies", []))
        existing.append(
            {
                "step": step_num,
                "decisions": [d.to_dict() for d in decisions],
            }
        )
        state.metadata["policies"] = existing

        # Also surface one RunRecord per decision so compliance
        # dashboards that read RunRecords (without parsing metadata)
        # see the audit trail.
        for d in decisions:
            state.add_record(
                RunRecord(
                    task=self.config.task or f"policy_step_{step_num}",
                    outcome=RunOutcome.SUCCESS,
                    tokens_used=0,
                    duration_ms=0,
                    timestamp=datetime.now().isoformat(),
                    metadata={
                        "kind": "policy_decision",
                        "step": step_num,
                        "policy_name": d.policy_name,
                        "verdict": d.verdict,
                    },
                )
            )

        if len(state.history) > 100:
            state.history = state.history[-100:]

        sm.save(state)

    def _checkpoint(self, result: StepResult) -> None:
        """v0.7.8 — Persist a step result to the configured StateManager.

        Records a RunRecord per step and updates LoopState.attempts so a
        subsequent run with ``resume_from`` can pick up where this one left off.
        Failures are logged but do not interrupt the loop — checkpointing is
        best-effort observability, not a transactional write-ahead log.
        """
        if not self.config.state_manager:
            return

        try:
            from loopy.state import RunOutcome, RunRecord

            state_manager = self.config.state_manager
            state = state_manager.load()
            state.current_task = self.config.task or None
            state.attempts = result.step

            outcome = (
                RunOutcome.SUCCESS if result.status == StepStatus.COMPLETE else RunOutcome.FAILURE
            )
            state.add_record(
                RunRecord(
                    task=self.config.task or f"step_{result.step}",
                    outcome=outcome,
                    tokens_used=0,
                    duration_ms=0,
                    timestamp=datetime.now().isoformat(),
                    metadata={
                        "step": result.step,
                        "plan": result.plan[:200],
                        "action": result.action[:200],
                        "observation": result.observation[:200],
                    },
                )
            )

            # Cap stored RunRecords to avoid unbounded growth (matches the
            # DecisionTracker FIFO bound from v0.7.6).
            if len(state.history) > 100:
                state.history = state.history[-100:]

            state_manager.save(state)
        except Exception as e:
            logger.warning("Checkpoint failed at step %d: %s", result.step, e)

    def _persist_interrupt(self, interrupt: Interrupt) -> None:
        """v0.8.0 — record an interrupt on the configured state manager.

        Best-effort: failures are logged but do not change the return
        value of :meth:`run`. The interrupt is appended to
        ``LoopState.metadata["interrupts"]`` and a paired ``RunRecord``
        is added to ``LoopState.history`` so a subsequent resume can see
        what was paused.
        """
        if not self.config.state_manager:
            return

        try:
            from loopy.state import RunOutcome, RunRecord

            state_manager = self.config.state_manager
            state = state_manager.load()
            state.current_task = self.config.task or None
            state.attempts = interrupt.step

            state.add_record(
                RunRecord(
                    task=self.config.task or f"interrupt_step_{interrupt.step}",
                    outcome=RunOutcome.INTERRUPTED,
                    tokens_used=0,
                    duration_ms=0,
                    timestamp=datetime.now().isoformat(),
                    metadata={
                        "kind": "interrupt",
                        "phase": interrupt.phase,
                        "step": interrupt.step,
                        "proposed_action": interrupt.proposed_action,
                    },
                )
            )

            interrupts = list(state.metadata.get("interrupts", []))
            interrupts.append(
                {
                    "phase": interrupt.phase,
                    "step": interrupt.step,
                    "proposed_action": interrupt.proposed_action,
                    "context": interrupt.context,
                }
            )
            state.metadata["interrupts"] = interrupts

            if len(state.history) > 100:
                state.history = state.history[-100:]

            state_manager.save(state)
        except Exception as e:
            logger.warning("Persist interrupt failed: %s", e)

    async def _run_step(self, step_num: int) -> StepResult:
        """Execute a single iteration of the loop."""
        result = StepResult(step=step_num, status=StepStatus.PLANNING)

        # v0.8.0 — Interrupt gates. Each phase can be paused BEFORE the
        # phase runs (``interrupt_before``) or AFTER (``interrupt_after``)
        # by raising ``_InterruptedRun`` which the public ``run()`` catches
        # and converts to an :class:`Interrupt` return value.
        ib = self.config.interrupt_before or []
        ia = self.config.interrupt_after or []
        # v0.8.0 — clear single-shot skip once we enter the target step.
        if self._skip_before is not None and self._skip_before[0] != step_num:
            self._skip_before = None

        async def _pause_before(phase: str, proposed: str, ctx: dict[str, Any]) -> None:
            """Raise ``_InterruptedRun`` if this phase is configured to pause BEFORE running."""
            if phase in ib and self._skip_before != (step_num, phase):
                raise _InterruptedRun(
                    Interrupt(
                        proposed_action=proposed,
                        context={**ctx, "when": "before"},
                        phase=phase,
                        step=step_num,
                    )
                )

        async def _pause_after(phase: str, proposed: str, ctx: dict[str, Any]) -> None:
            """Raise ``_InterruptedRun`` if this phase is configured to pause AFTER running."""
            if phase in ia:
                raise _InterruptedRun(
                    Interrupt(
                        proposed_action=proposed,
                        context={**ctx, "when": "after"},
                        phase=phase,
                        step=step_num,
                    )
                )

        try:
            # PLAN
            if self.config.planner:
                await _pause_before(
                    "plan",
                    proposed=f"run plan step {step_num}",
                    ctx={"step": step_num, "phase": "plan"},
                )
                result.plan = await self.config.planner(self.history)
                await _pause_after(
                    "plan",
                    proposed=f"plan step {step_num} produced: {result.plan[:80]}",
                    ctx={"step": step_num, "phase": "plan", "plan": result.plan},
                )
                logger.debug("Step %d plan: %s...", step_num, result.plan[:100])

            # ACT
            result.status = StepStatus.ACTING
            if self.config.actor:
                await _pause_before(
                    "actor",
                    proposed=f"run actor step {step_num} with plan: {result.plan[:80]}",
                    ctx={"step": step_num, "phase": "actor", "plan": result.plan},
                )
                result.action = await self.config.actor(result.plan)
                await _pause_after(
                    "actor",
                    proposed=f"actor step {step_num} produced: {result.action[:80]}",
                    ctx={
                        "step": step_num,
                        "phase": "actor",
                        "plan": result.plan,
                        "action": result.action,
                    },
                )
                logger.debug("Step %d action: %s...", step_num, result.action[:100])

            # OBSERVE
            result.status = StepStatus.OBSERVING
            if self.config.observer:
                await _pause_before(
                    "observer",
                    proposed=f"observe action result: {result.action[:80]}",
                    ctx={"step": step_num, "phase": "observer", "action": result.action},
                )
                result.observation = await self.config.observer(result.action)
                await _pause_after(
                    "observer",
                    proposed=f"observer step {step_num} produced: {result.observation[:80]}",
                    ctx={
                        "step": step_num,
                        "phase": "observer",
                        "action": result.action,
                        "observation": result.observation,
                    },
                )
                logger.debug("Step %d observation: %s...", step_num, result.observation[:100])

            # REFLECT
            result.status = StepStatus.REFLECTING
            if self.config.reflector:
                await _pause_before(
                    "reflector",
                    proposed=f"reflect on history ({len(self.history)} entries)",
                    ctx={"step": step_num, "phase": "reflector"},
                )
                result.reflection = await self.config.reflector(self.history)
                await _pause_after(
                    "reflector",
                    proposed=f"reflector step {step_num} produced: {result.reflection[:80]}",
                    ctx={
                        "step": step_num,
                        "phase": "reflector",
                        "reflection": result.reflection,
                    },
                )
                logger.debug("Step %d reflection: %s...", step_num, result.reflection[:100])

            result.status = StepStatus.COMPLETE

        except _InterruptedRun as ir:
            # Carry the interrupt up to the public run() entry point.
            raise ir
        except Exception as e:
            result.status = StepStatus.FAILED
            result.error = str(e)
            logger.error("Step %d failed: %s", step_num, e)

            if self.config.stop_on_error:
                raise

        return result
```

## `loopy.loop.LoopConfig` (class)

Configuration for the agentic loop.

```python
@dataclass
class LoopConfig:
    """Configuration for the agentic loop."""

    max_steps: int = 10
    max_retries: int = 3
    stop_on_error: bool = False

    # Callbacks
    planner: Callable[[list[StepResult]], Awaitable[str]] | None = None
    actor: Callable[[str], Awaitable[str]] | None = None
    observer: Callable[[str], Awaitable[str]] | None = None
    reflector: Callable[[list[StepResult]], Awaitable[str]] | None = None

    # Optional: custom stop condition
    should_stop: Callable[[list[StepResult]], Awaitable[bool]] | None = None

    # v0.7.8 — Resume from checkpoint
    # Set to an integer step number to skip ahead, or leave None to start fresh.
    # When `state_manager` is provided, history is checkpointed after each step
    # and a `RunRecord` is appended so crashed runs can be resumed.
    resume_from: int | None = None
    state_manager: StateManager | None = None
    task: str = ""  # Label for RunRecord metadata

    # v0.9.0 — Compliance-as-Code policy engine. When set, the loop
    # evaluates the policies before each step and raises
    # ``PolicyViolation`` on a ``block`` decision. ``warn`` / ``info``
    # decisions are recorded but do not abort the loop. When
    # ``state_manager`` is configured, the per-step decisions are
    # persisted as ``metadata["policies"]`` on the saved LoopState.
    policy_engine: Any = None

    # v0.8.0 — Human-in-the-loop interrupts
    # Each list is a set of phase names that should pause BEFORE / AFTER
    # running, returning an :class:`Interrupt` to the caller for review.
    # Phase names: ``"plan"``, ``"actor"``, ``"observer"``, ``"reflector"``.
    interrupt_before: list[str] | None = None
    interrupt_after: list[str] | None = None

    def __post_init__(self) -> None:
        # Negative control: cannot configure interrupts on a zero-step loop.
        if (self.interrupt_before or self.interrupt_after) and self.max_steps <= 0:
            raise ValueError("interrupt_before / interrupt_after require max_steps >= 1")
        for phase in self.interrupt_before or []:
            if phase not in {"plan", "actor", "observer", "reflector"}:
                raise ValueError(
                    f"interrupt_before: unknown phase {phase!r}; "
                    "must be one of plan/actor/observer/reflector"
                )
        for phase in self.interrupt_after or []:
            if phase not in {"plan", "actor", "observer", "reflector"}:
                raise ValueError(
                    f"interrupt_after: unknown phase {phase!r}; "
                    "must be one of plan/actor/observer/reflector"
                )
```

## `loopy.loop.StepResult` (class)

Result of a single loop iteration.

```python
@dataclass
class StepResult:
    """Result of a single loop iteration."""

    step: int
    status: StepStatus
    plan: str = ""
    action: str = ""
    observation: str = ""
    reflection: str = ""
    data: dict[str, Any] = field(default_factory=dict)
    error: str | None = None
```

## `loopy.loop.StepStatus` (class)

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
class StepStatus(str, Enum):
    PLANNING = "planning"
    ACTING = "acting"
    OBSERVING = "observing"
    REFLECTING = "reflecting"
    COMPLETE = "complete"
    FAILED = "failed"
```


# Module `loopy.flow`

## `loopy.flow.Node` (class)

A node in a state graph.

```python
@dataclass
class Node:
    """A node in a state graph."""

    name: str
    run: NodeFn
```

## `loopy.flow.Edge` (class)

A directed edge between two nodes.

If ``condition`` is provided, the edge is only traversed when
``condition(state)`` returns ``True``. Otherwise the edge fires
unconditionally.

```python
@dataclass
class Edge:
    """A directed edge between two nodes.

    If ``condition`` is provided, the edge is only traversed when
    ``condition(state)`` returns ``True``. Otherwise the edge fires
    unconditionally.
    """

    from_node: str
    to_node: str
    condition: EdgeCondition = None
```

## `loopy.flow.StateGraph` (class)

A typed, validated state graph.

``nodes`` maps node name to ``Node``. ``edges`` is an ordered
list of ``Edge``; the first edge matching the current node +
condition is taken. ``entry`` is the starting node. ``terminal``
is the set of node names whose completion ends the workflow.

```python
@dataclass
class StateGraph:
    """A typed, validated state graph.

    ``nodes`` maps node name to ``Node``. ``edges`` is an ordered
    list of ``Edge``; the first edge matching the current node +
    condition is taken. ``entry`` is the starting node. ``terminal``
    is the set of node names whose completion ends the workflow.
    """

    name: str
    nodes: dict[str, Node]
    edges: list[Edge]
    entry: str
    terminal: set[str] = field(default_factory=set)

    def __post_init__(self) -> None:
        # Name sanity: prevents path-traversal attacks on StateManager.
        for name in self.nodes:
            if not name or "/" in name or "\\" in name or name.startswith("."):
                raise ValueError(
                    f"Invalid node name {name!r}: must be non-empty, no slashes, no leading dot"
                )
        if self.entry not in self.nodes:
            raise ValueError(f"Entry node {self.entry!r} not in graph nodes {sorted(self.nodes)}")
        for t in self.terminal:
            if t not in self.nodes:
                raise ValueError(f"Terminal node {t!r} not in graph nodes {sorted(self.nodes)}")
        for edge in self.edges:
            if edge.from_node not in self.nodes:
                raise ValueError(f"Edge.from_node {edge.from_node!r} not in graph")
            if edge.to_node not in self.nodes:
                raise ValueError(f"Edge.to_node {edge.to_node!r} not in graph")
        # Cycle detection: every cycle must include a terminating node.
        self._validate_no_open_cycles()

    def _validate_no_open_cycles(self) -> None:
        """Build-time check: every node must reach a terminal node.

        For each node in the graph, do a BFS over outgoing edges and
        confirm that *some* terminal is reachable. If a node has no
        path to any terminal (because it is in a cycle of non-terminal
        nodes), raise ``ValueError``.

        Cycles that pass through (or are escapable to) a terminal
        are fine. Example: a <-> b -> c (terminal c) is OK because
        from a you can reach c.
        """
        # Build adjacency map for fast lookup.
        outgoing: dict[str, list[str]] = {n: [] for n in self.nodes}
        for edge in self.edges:
            outgoing[edge.from_node].append(edge.to_node)

        for start in self.nodes:
            if self._can_reach_terminal(start, outgoing):
                continue
            raise ValueError(
                f"Graph has open cycle not reaching any terminal node starting from {start!r}"
            )

    def _can_reach_terminal(self, start: str, outgoing: dict[str, list[str]]) -> bool:
        """BFS: can ``start`` reach any node in ``self.terminal``?

        Tracks visited nodes to handle cycles without infinite loops.
        """
        visited: set[str] = set()
        queue: list[str] = [start]
        while queue:
            node = queue.pop(0)
            if node in visited:
                continue
            visited.add(node)
            if node in self.terminal:
                return True
            for nxt in outgoing[node]:
                if nxt not in visited:
                    queue.append(nxt)
        return False
```

## `loopy.flow.Context` (class)

Runtime context passed to each node's ``run`` body.

Carries an asyncio.Event for cooperative cancellation and the
current node name + retry attempt counter.

```python
@dataclass
class Context:
    """Runtime context passed to each node's ``run`` body.

    Carries an asyncio.Event for cooperative cancellation and the
    current node name + retry attempt counter.
    """

    events: asyncio.Event = field(default_factory=asyncio.Event)
    current_node: str = ""
    attempt: int = 0
    run_id: str = field(default_factory=lambda: str(uuid.uuid4()))
```

## `loopy.flow.Workflow` (class)

A runnable state graph.

Workflows may optionally persist their state to a StateManager
so they can resume after a crash. Without a StateManager they
run in-memory only.

```python
class Workflow:
    """A runnable state graph.

    Workflows may optionally persist their state to a StateManager
    so they can resume after a crash. Without a StateManager they
    run in-memory only.
    """

    def __init__(
        self,
        graph: StateGraph,
        *,
        state_manager: Any | None = None,
    ) -> None:
        self.graph = graph
        self.state_manager = state_manager
        self._completed_nodes: set[str] = set()
        self._last_state: State | None = None

    @property
    def completed_nodes(self) -> set[str]:
        return set(self._completed_nodes)

    async def run(
        self,
        initial_state: State,
        *,
        resume_from: set[str] | None = None,
    ) -> State:
        """Execute the graph from ``initial_state``.

        When ``resume_from`` is provided (and ``state_manager`` is set),
        already-completed node names are skipped; otherwise the graph
        runs from ``entry``.
        """
        if not isinstance(initial_state, dict):
            raise TypeError(f"initial_state must be a dict, got {type(initial_state).__name__}")
        # Initialize tracking state.
        self._completed_nodes = set(resume_from or set())
        if self.state_manager is not None and resume_from:
            try:
                loaded = self.state_manager.load()
                # Use the loaded state if present, else fall through.
                if loaded and getattr(loaded, "current_task", None):
                    initial_state = {
                        **initial_state,
                        "_loaded": True,
                    }
            except Exception:  # noqa: BLE001 - state file may be empty
                logger.debug("No prior workflow state found; starting fresh")

        state: State = dict(initial_state)
        current = self.graph.entry
        ctx = Context(current_node=current, attempt=1)

        while True:
            if current in self._completed_nodes:
                # If we already ran this node (resume_from), don't re-run;
                # advance to the next node.
                if current in self.graph.terminal:
                    break
                current = self._next_node(state, current)
                continue

            node = self.graph.nodes[current]
            state["_current_node"] = current

            # Run the node, idempotently. A retry re-invokes the same
            # run() with the same input state; the executor is the only
            # place where non-determinism (clock, network) lives.
            try:
                state = await node.run(state, ctx)
            except Exception:
                # Re-raise after persisting a marker so a retry knows
                # to retry this exact node.
                self._persist_state(state, status="failed_at", node=current)
                raise

            self._completed_nodes.add(current)
            self._persist_state(state, status="completed", node=current)

            # Terminal reached after running the terminal node itself.
            if current in self.graph.terminal:
                break

            # Decide next node.
            current = self._next_node(state, current)

        # Final: mark the terminal node complete.
        self._last_state = state
        return state

    def _next_node(self, state: State, current: str) -> str:
        """Pick the next node based on outgoing edges.

        Returns the to_node of the first edge whose ``condition`` is
        satisfied (or unconditional). If no edge matches, the workflow
        terminates by raising ``_CycleError`` (caller catches).
        """
        for edge in self.graph.edges:
            if edge.from_node != current:
                continue
            if edge.condition is None or edge.condition(state):
                return edge.to_node
        # No outgoing edge matched; treat as terminal.
        raise _CycleError(f"No outgoing edge from {current!r}; not in terminal set")

    def _persist_state(
        self,
        state: State,
        *,
        status: str,
        node: str,
    ) -> None:
        """Persist the current workflow state to StateManager if present.

        Scrubs state via the Tracer's redactor (if any) before write
        so PII does not leak to disk.
        """
        if self.state_manager is None:
            return
        try:
            stored = self.state_manager.load()
            stored.current_task = f"flow:{self.graph.name}:{node}"
            # Scrub via a Tracer's redactor if a Tracer is reachable.
            # We avoid hard-coupling to Tracer by importing lazily.
            from loopy.observe import Tracer  # local import to avoid cycles

            tracer = getattr(self.state_manager, "_tracer", None)
            scrubbed = (
                tracer.redactor.redact_value(state)
                if isinstance(tracer, Tracer) and tracer.redactor is not None
                else state
            )
            # Note: scrubbed state is held in a local var for the
            # future record-writing logic; today we save the
            # state_manager itself.
            _ = scrubbed  # explicit "intentionally not yet wired into the journal"
            self.state_manager.save(stored)
        except Exception as e:  # noqa: BLE001
            logger.warning("Flow state persist failed at %s (%s): %s", node, status, e)
```

## `loopy.flow.State` (callable)

dict() -> new empty dictionary
dict(mapping) -> new dictionary initialized from a mapping object's
    (key, value) pairs
dict(iterable) -> new dictionary initialized as if via:
    d = {}
    for k, v in iterable:
        d[k] = v
dict(**kwargs) -> new dictionary initialized with the name=value pairs
    in the keyword argument list.  For example:  dict(one=1, two=2)

```python
(source unavailable)```


# Module `loopy.gateway`

## `loopy.gateway.Gateway` (class)

AI Gateway for routing LLM requests across providers.

Supports both standalone and async context manager usage.

Example (async context manager):
    async with Gateway() as gateway:
        gateway.add_provider("openai", ProviderConfig(...))
        response = await gateway.chat("What is 2+2?")

Example (standalone):
    gateway = Gateway()
    gateway.add_provider("openai", ProviderConfig(...))
    response = await gateway.chat("What is 2+2?", provider="openai")
    await gateway.close()

```python
class Gateway:
    """
    AI Gateway for routing LLM requests across providers.

    Supports both standalone and async context manager usage.

    Example (async context manager):
        async with Gateway() as gateway:
            gateway.add_provider("openai", ProviderConfig(...))
            response = await gateway.chat("What is 2+2?")

    Example (standalone):
        gateway = Gateway()
        gateway.add_provider("openai", ProviderConfig(...))
        response = await gateway.chat("What is 2+2?", provider="openai")
        await gateway.close()
    """

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

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """Async context manager exit."""
        await self.close()
        return False

    def __init__(self, *, policy_engine: Any = None):
        self.providers: dict[str, ProviderConfig] = {}
        self._pool: ConnectionPool = ConnectionPool()
        self._logs: list[dict[str, Any]] = []
        # v0.9.0 — optional Compliance-as-Code policy engine. When
        # set, every chat() call evaluates the policies and raises
        # ``PolicyViolation`` on a ``block`` decision *before* any
        # provider I/O. ``warn`` / ``info`` decisions are recorded
        # but do not abort the call.
        self.policy_engine = policy_engine

    def add_provider(self, name: str, config: ProviderConfig) -> None:
        """Register a provider."""
        self.providers[name] = config
        logger.info("Added provider: %s (%s)", name, config.provider.value)

    def _resolve_provider(self, provider: str | None = None) -> tuple[str, ProviderConfig]:
        """Resolve a provider name to a (name, config) pair.

        Args:
            provider: Preferred provider name, or *None* for the first
                      available provider.

        Returns:
            A tuple of (provider_name, ProviderConfig).

        Raises:
            ValueError: If no providers are configured.
        """
        if provider and provider in self.providers:
            return provider, self.providers[provider]
        if self.providers:
            name, config = next(iter(self.providers.items()))
            return name, config
        raise ValueError("No providers configured. Call add_provider() first.")

    def _resolve_provider_with_cap(
        self,
        requested: str | None,
        max_tokens: int,
        max_cost_usd: float | None,
    ) -> tuple[str, ProviderConfig]:
        """v0.9.0 — Cost-Aware Routing.

        Resolve the provider (using :meth:`_resolve_provider` for
        the no-cap case) and then enforce ``max_cost_usd``. If the
        resolved provider's estimated cost fits inside the cap,
        return it as-is. Otherwise, find the cheapest configured
        provider that fits, and log the fallback. If no provider
        fits, raise :class:`BudgetExceeded`.

        When ``max_cost_usd is None`` the cap is a no-op and this
        method is a thin wrapper around :meth:`_resolve_provider`.
        """
        name, config = self._resolve_provider(requested)

        if max_cost_usd is None:
            return name, config

        from loopy.cost import BudgetExceeded

        estimated = config.estimate_cost_usd(max_tokens=max_tokens)
        if estimated <= max_cost_usd:
            return name, config

        # Look for a cheaper provider that fits inside the cap.
        candidates: list[tuple[str, ProviderConfig, float]] = []
        for candidate_name, candidate_cfg in self.providers.items():
            cost = candidate_cfg.estimate_cost_usd(max_tokens=max_tokens)
            if cost <= max_cost_usd:
                candidates.append((candidate_name, candidate_cfg, cost))
        if not candidates:
            raise BudgetExceeded(
                limit=int(max_cost_usd * 1000),
                used=int(estimated * 1000),
            )

        # Pick the cheapest candidate.
        candidates.sort(key=lambda c: c[2])
        chosen_name, chosen_cfg, chosen_cost = candidates[0]
        if chosen_name != name:
            self._logs.append(
                {
                    "event": "cost_fallback",
                    "from_provider": name,
                    "to_provider": chosen_name,
                    "estimated_usd": estimated,
                    "chosen_usd": chosen_cost,
                    "cap_usd": max_cost_usd,
                }
            )
        return chosen_name, chosen_cfg

    # Dispatch table for provider-specific API calls
    _PROVIDER_HANDLERS: dict[ModelProvider, str] = {
        ModelProvider.OPENAI: "_call_openai",
        ModelProvider.ANTHROPIC: "_call_anthropic",
        ModelProvider.OLLAMA: "_call_ollama",
    }

    async def chat(
        self,
        message: str,
        provider: str | None = None,
        system: str | None = None,
        temperature: float = 0.7,
        max_tokens: int = 1000,
        *,
        model: TestModel | str | None = None,
        response_format: type[BaseModel] | None = None,
        max_cost_usd: float | None = None,
        **kwargs,
    ) -> GatewayResponse:
        """
        Send a chat completion request through the gateway.

        Routes to the specified provider, or the first available if
        *provider* is *None*.

        Args:
            message: The user message.
            provider: Provider name to route to.
            system: Optional system prompt.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.
            model: v0.7.9 - When set to a : ``TestModel`` (or the
                sentinel string ``"test"``), the request is satisfied
                locally without any HTTP/SDK call. Useful for unit
                tests and CI.
            response_format: v0.7.9 - When set to a Pydantic
                ``BaseModel`` subclass, the gateway validates the
                reply against that schema and returns the instance in
                ``GatewayResponse.structured``.
            **kwargs: Additional arguments (ignored).

        Returns:
            A GatewayResponse with the model reply. When
            ``response_format`` is provided, ``structured`` holds the
            validated Pydantic instance (or ``None`` if validation
            failed).

        Raises:
            ValueError: If no providers are configured and no test
                model is supplied.
            RuntimeError: If the provider's rate limit is exceeded.
        """
        # v0.7.9 - Test model routing: short-circuit to local handler.
        test_model = self._resolve_test_model(model)
        if test_model is not None:
            # v0.9.0 — Compliance-as-Code: policies still apply on
            # the test-model path so unit tests can exercise the gate.
            if self.policy_engine is not None:
                context = dict(kwargs.get("policy_context") or {})
                context.setdefault("provider", "test")
                context.setdefault("max_tokens", max_tokens)
                self.policy_engine.gate(context)
            # v0.9.0 — Cost cap: the test-model path also enforces the
            # cap so unit tests can exercise the guard end-to-end.
            # When no providers are configured, the cap is a no-op
            # (cost is unknown).
            if max_cost_usd is not None and self.providers:
                _, _ = self._resolve_provider_with_cap(
                    provider,
                    max_tokens,
                    max_cost_usd,
                )
            return await self._call_test(
                test_model,
                message,
                system,
                temperature,
                max_tokens,
                response_format,
            )

        provider, config = self._resolve_provider_with_cap(provider, max_tokens, max_cost_usd)

        # v0.9.0 — Compliance-as-Code: evaluate policies before any
        # provider I/O. ``gate()`` raises ``PolicyViolation`` on a
        # ``block`` decision; the caller may also pass
        # ``policy_context={...}`` to feed runtime data into the
        # evaluation (cost estimates, PII flags, etc.).
        if self.policy_engine is not None:
            context = dict(kwargs.get("policy_context") or {})
            context.setdefault("provider", provider)
            context.setdefault("max_tokens", max_tokens)
            self.policy_engine.gate(context)

        # Check rate limits
        config.check_rate_limit()

        # Route to provider
        start_time = time.time()

        try:
            handler_name = self._PROVIDER_HANDLERS.get(config.provider)
            if handler_name is None:
                raise ValueError(f"Unsupported provider: {config.provider}")
            handler = getattr(self, handler_name)
            response = await handler(config, message, system, temperature, max_tokens)
        except Exception as e:
            logger.error("Gateway error (%s): %s", provider, e)
            raise

        latency_ms = (time.time() - start_time) * 1000

        # Log request
        log_entry = {
            "provider": provider,
            "model": config.model,
            "latency_ms": latency_ms,
            "tokens": response.tokens_used,
            "timestamp": time.time(),
        }
        self._logs.append(log_entry)
        config.record_request()

        response.latency_ms = latency_ms

        # v0.7.9 - Structured output: validate the reply against the
        # requested Pydantic schema. Failure logs a warning and sets
        # ``structured`` to None so callers can detect + retry.
        if response_format is not None:
            try:
                response.structured = response_format.model_validate_json(response.content)
            except Exception as e:
                logger.warning("Structured output validation failed: %s", e)
                response.structured = None
        return response

    async def _call_openai(
        self,
        config: ProviderConfig,
        message: str,
        system: str | None,
        temperature: float,
        max_tokens: int,
    ) -> GatewayResponse:
        """Route a chat request to the OpenAI API.

        Args:
            config: Provider configuration.
            message: The user message.
            system: Optional system prompt.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.

        Returns:
            A GatewayResponse with the model reply.
        """
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": message})

        client = await self._pool.get_connection("openai")
        response = await client.post(
            f"{config.base_url or 'https://api.openai.com/v1'}/chat/completions",
            headers={"Authorization": f"Bearer {config.api_key}"},
            json={
                "model": config.model,
                "messages": messages,
                "temperature": temperature,
                "max_tokens": max_tokens,
            },
        )
        response.raise_for_status()
        data = response.json()

        return GatewayResponse(
            content=data["choices"][0]["message"]["content"],
            model=config.model,
            provider=ModelProvider.OPENAI,
            tokens_used=data.get("usage", {}).get("total_tokens", 0),
        )

    async def _call_anthropic(
        self,
        config: ProviderConfig,
        message: str,
        system: str | None,
        temperature: float,
        max_tokens: int,
    ) -> GatewayResponse:
        """Route a chat request to the Anthropic API.

        Args:
            config: Provider configuration.
            message: The user message.
            system: Optional system prompt.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.

        Returns:
            A GatewayResponse with the model reply.
        """
        body: dict[str, Any] = {
            "model": config.model,
            "messages": [{"role": "user", "content": message}],
            "temperature": temperature,
            "max_tokens": max_tokens,
        }
        if system:
            body["system"] = system

        client = await self._pool.get_connection("anthropic")
        response = await client.post(
            f"{config.base_url or 'https://api.anthropic.com/v1'}/messages",
            headers={
                "x-api-key": config.api_key or "",
                "anthropic-version": "2023-06-01",
            },
            json=body,
        )
        response.raise_for_status()
        data = response.json()

        return GatewayResponse(
            content=data["content"][0]["text"],
            model=config.model,
            provider=ModelProvider.ANTHROPIC,
            tokens_used=data.get("usage", {}).get("input_tokens", 0)
            + data.get("usage", {}).get("output_tokens", 0),
        )

    async def _call_ollama(
        self,
        config: ProviderConfig,
        message: str,
        system: str | None,
        temperature: float,
        max_tokens: int,
    ) -> GatewayResponse:
        """Route a chat request to a local Ollama instance.

        Args:
            config: Provider configuration.
            message: The user message.
            system: Optional system prompt.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.

        Returns:
            A GatewayResponse with the model reply.
        """
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": message})

        client = await self._pool.get_connection("ollama")
        response = await client.post(
            f"{config.base_url or 'http://localhost:11434'}/api/chat",
            json={
                "model": config.model,
                "messages": messages,
                "stream": False,
            },
        )
        response.raise_for_status()
        data = response.json()

        return GatewayResponse(
            content=data["message"]["content"],
            model=config.model,
            provider=ModelProvider.OLLAMA,
            tokens_used=data.get("eval_count", 0),
        )

    async def chat_batch(
        self,
        messages: list[str],
        provider: str | None = None,
        system: str | None = None,
        temperature: float = 0.7,
        max_tokens: int = 1000,
        max_concurrent: int = 5,
    ) -> list[GatewayResponse]:
        """
        Send multiple chat requests concurrently.

        Args:
            messages: List of messages to send.
            provider: Provider name (or first available).
            system: Optional system prompt for all requests.
            temperature: Temperature for all requests.
            max_tokens: Max tokens for all requests.
            max_concurrent: Max concurrent requests.

        Returns:
            List of GatewayResponse objects.
        """
        semaphore = asyncio.Semaphore(max_concurrent)

        async def _single_chat(msg: str) -> GatewayResponse:
            async with semaphore:
                return await self.chat(
                    message=msg,
                    provider=provider,
                    system=system,
                    temperature=temperature,
                    max_tokens=max_tokens,
                )

        tasks = [_single_chat(msg) for msg in messages]
        return await asyncio.gather(*tasks)

    async def chat_streaming(
        self,
        message: str,
        provider: str | None = None,
        system: str | None = None,
        temperature: float = 0.7,
        max_tokens: int = 1000,
    ) -> AsyncGenerator[str, None]:
        """
        Send a streaming chat request.

        Yields content chunks as they arrive from the provider.
        Falls back to a single non-streaming call for providers
        other than OpenAI.

        Args:
            message: The user message.
            provider: Provider name (or first available).
            system: Optional system prompt.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.

        Yields:
            Content strings as they are received.
        """
        provider, config = self._resolve_provider(provider)

        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": message})

        if config.provider == ModelProvider.OPENAI:
            client = await self._pool.get_connection("openai")
            async with client.stream(
                "POST",
                f"{config.base_url or 'https://api.openai.com/v1'}/chat/completions",
                headers={"Authorization": f"Bearer {config.api_key}"},
                json={
                    "model": config.model,
                    "messages": messages,
                    "temperature": temperature,
                    "max_tokens": max_tokens,
                    "stream": True,
                },
            ) as response:
                response.raise_for_status()
                async for line in response.aiter_lines():
                    if line.startswith("data: ") and line != "data: [DONE]":
                        data = json.loads(line[6:])
                        delta = data.get("choices", [{}])[0].get("delta", {})
                        if "content" in delta:
                            yield delta["content"]
        else:
            # Fallback to non-streaming for other providers
            result = await self.chat(message, provider, system, temperature, max_tokens)
            yield result.content

    def _resolve_test_model(
        self,
        model: TestModel | str | None,
    ) -> TestModel | None:
        """v0.7.9 - Normalize the chat(model=) argument.

        Accepts a TestModel, the sentinel string 'test', or None.
        Returns the resolved TestModel, or None if the caller wants
        the normal HTTP path.
        """
        return _resolve_test_model_arg(model)

    async def _call_test(
        self,
        test_model: TestModel,
        message: str,
        system: str | None,
        temperature: float,
        max_tokens: int,
        response_format: type[BaseModel] | None,
    ) -> GatewayResponse:
        """v0.7.9 - Dispatch a chat call to a local TestModel.

        Bypasses network and rate limits; logs the call under a
        synthetic provider entry so get_logs() / cost tracking still
        observe the test traffic.
        """
        start_time = time.time()
        response = await test_model.handle(
            message,
            system,
            temperature,
            max_tokens,
            response_format,
        )
        response.latency_ms = (time.time() - start_time) * 1000
        self._logs.append(
            {
                "provider": "test",
                "model": test_model.model_name,
                "latency_ms": response.latency_ms,
                "tokens": response.tokens_used,
                "timestamp": time.time(),
            }
        )
        return response

    def get_logs(self) -> list[dict[str, Any]]:
        """Return request logs."""
        return self._logs.copy()

    async def close(self) -> None:
        """Close the connection pool."""
        await self._pool.close()
```

## `loopy.gateway.GatewayResponse` (class)

Unified response from the gateway.

```python
@dataclass
class GatewayResponse:
    """Unified response from the gateway."""

    content: str
    model: str
    provider: ModelProvider
    tokens_used: int = 0
    latency_ms: float = 0
    cached: bool = False
    metadata: dict[str, Any] = field(default_factory=dict)
    # v0.7.9 — populated when ``chat(response_format=...)`` was used;
    # validated Pydantic instance, or ``None`` if validation failed.
    structured: Any | None = None
```

## `loopy.gateway.ModelProvider` (class)

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
class ModelProvider(str, Enum):
    OPENAI = "openai"
    ANTHROPIC = "anthropic"
    OLLAMA = "ollama"
    CUSTOM = "custom"
```

## `loopy.gateway.ProviderConfig` (class)

Configuration for a single LLM provider.

Includes rate-limit tracking via :meth:`check_rate_limit` and
:meth:`record_request`.

Args:
    provider: The LLM provider enum.
    api_key: Optional API key for authentication.
    base_url: Base URL for the provider API.
    model: Model identifier (e.g. "gpt-4").
    rpm: Max requests per minute.
    tpm: Max tokens per minute.

```python
@dataclass
class ProviderConfig:
    """Configuration for a single LLM provider.

    Includes rate-limit tracking via :meth:`check_rate_limit` and
    :meth:`record_request`.

    Args:
        provider: The LLM provider enum.
        api_key: Optional API key for authentication.
        base_url: Base URL for the provider API.
        model: Model identifier (e.g. "gpt-4").
        rpm: Max requests per minute.
        tpm: Max tokens per minute.
    """

    provider: ModelProvider
    api_key: str | None = None
    base_url: str = ""
    model: str = "gpt-4"

    # Rate limiting
    rpm: int = 60  # requests per minute
    tpm: int = 100_000  # tokens per minute

    # v0.9.0 — Cost-Aware Routing
    # USD per 1k tokens. ``0.0`` for local providers (Ollama, etc.).
    cost_per_1k_tokens: float = 0.0

    # Internal tracking
    _request_count: int = field(default=0, repr=False)
    _window_start: float = field(default_factory=time.time, repr=False)

    def estimate_cost_usd(
        self,
        max_tokens: int,
        expected_tokens: int | None = None,
    ) -> float:
        """Estimate the USD cost of a request.

        Args:
            max_tokens: Upper bound on tokens the provider will
                charge for (the cap configured for this call).
            expected_tokens: Optional override — the caller's
                better estimate of actual token count. Defaults
                to ``max_tokens`` (conservative).

        Returns:
            The estimated cost in USD.
        """
        billable = expected_tokens if expected_tokens is not None else max_tokens
        return (billable / 1000.0) * self.cost_per_1k_tokens

    def check_rate_limit(self) -> None:
        """Check whether the rate limit has been reached.

        Raises:
            RuntimeError: If the number of requests in the current
                rolling 60-second window exceeds *rpm*.
        """
        now = time.time()
        if now - self._window_start > 60:
            self._request_count = 0
            self._window_start = now

        if self._request_count >= self.rpm:
            raise RuntimeError(f"Rate limit exceeded for {self.provider.value}")

    def record_request(self) -> None:
        """Increment the request counter for rate tracking."""
        self._request_count += 1
```

## `loopy.gateway.ConnectionPool` (class)

HTTP connection pool for reusing connections to providers.

Reduces latency by reusing TCP connections and SSL handshakes.
Evicts the least-recently-used connection when at capacity.

Example:
    pool = ConnectionPool(max_size=10)
    async with pool.get_connection("openai") as client:
        response = await client.post(...)

```python
class ConnectionPool:
    """
    HTTP connection pool for reusing connections to providers.

    Reduces latency by reusing TCP connections and SSL handshakes.
    Evicts the least-recently-used connection when at capacity.

    Example:
        pool = ConnectionPool(max_size=10)
        async with pool.get_connection("openai") as client:
            response = await client.post(...)
    """

    def __init__(self, max_size: int = 10):
        self.max_size = max_size
        self._connections: dict[str, httpx.AsyncClient] = {}
        self._last_used: dict[str, float] = {}
        self._lock = asyncio.Lock()

    async def get_connection(self, provider: str) -> httpx.AsyncClient:
        """Get or create a connection for a provider."""
        async with self._lock:
            if provider in self._connections:
                self._last_used[provider] = time.time()
                return self._connections[provider]

            if len(self._connections) >= self.max_size:
                # Evict least recently used connection
                lru_key = min(self._last_used, key=self._last_used.get)
                await self._connections[lru_key].aclose()
                del self._connections[lru_key]
                del self._last_used[lru_key]

            self._connections[provider] = httpx.AsyncClient(
                timeout=60.0,
                limits=httpx.Limits(
                    max_connections=5,
                    max_keepalive_connections=2,
                ),
            )
            self._last_used[provider] = time.time()
            return self._connections[provider]

    async def close(self) -> None:
        """Close all connections in the pool."""
        for client in self._connections.values():
            await client.aclose()
        self._connections.clear()
        self._last_used.clear()

    def stats(self) -> dict[str, Any]:
        """Get pool statistics."""
        return {
            "active_connections": len(self._connections),
            "max_size": self.max_size,
            "providers": list(self._connections.keys()),
        }
```

## `loopy.gateway.TestModel` (class)

v0.7.9 - Zero-network model for unit tests and CI.

Replace HTTP calls with deterministic scripted replies so you can
exercise the full agent loop without API keys, rate limits, or
billing. Compatible with :meth:`Gateway.chat`.

Example::

    gw = Gateway()
    response = await gw.chat(
        "hi",
        model=TestModel(responses=["hi back"]),
    )
    assert response.content == "hi back"
    assert response.metadata["test_model"] is True

Args:
    responses: Ordered list of canned replies. If exhausted, the
        last entry is reused (so the model never raises for lack of
        material). Use ``callable`` for dynamic replies.
    tool_calls: Optional pre-canned tool calls to emit alongside
        the text reply. Each entry is a dict with ``name`` and
        ``args`` keys; the gateway surfaces them in
        ``response.metadata["tool_calls"]``.
    latency_ms: Artificial latency to inject per call (0 = instant).
    model_name: Reported model name in ``GatewayResponse.model``.
        Defaults to ``"test"``.
    raise_on_message: If set, raise this exception when ``message``
        matches the regex/string. Useful for testing error paths.

```python
class TestModel:
    """v0.7.9 - Zero-network model for unit tests and CI.

    Replace HTTP calls with deterministic scripted replies so you can
    exercise the full agent loop without API keys, rate limits, or
    billing. Compatible with :meth:`Gateway.chat`.

    Example::

        gw = Gateway()
        response = await gw.chat(
            "hi",
            model=TestModel(responses=["hi back"]),
        )
        assert response.content == "hi back"
        assert response.metadata["test_model"] is True

    Args:
        responses: Ordered list of canned replies. If exhausted, the
            last entry is reused (so the model never raises for lack of
            material). Use ``callable`` for dynamic replies.
        tool_calls: Optional pre-canned tool calls to emit alongside
            the text reply. Each entry is a dict with ``name`` and
            ``args`` keys; the gateway surfaces them in
            ``response.metadata["tool_calls"]``.
        latency_ms: Artificial latency to inject per call (0 = instant).
        model_name: Reported model name in ``GatewayResponse.model``.
            Defaults to ``"test"``.
        raise_on_message: If set, raise this exception when ``message``
            matches the regex/string. Useful for testing error paths.
    """

    __slots__ = (
        "responses",
        "tool_calls",
        "latency_ms",
        "model_name",
        "raise_on_message",
        "_index",
        "calls",
    )

    def __init__(
        self,
        responses: list[str | Callable[[str, str | None], str]] | None = None,
        *,
        tool_calls: list[dict[str, Any]] | None = None,
        latency_ms: float = 0.0,
        model_name: str = "test",
        raise_on_message: re.Pattern[str] | str | None = None,
    ) -> None:
        self.responses: list[str | Callable[[str, str | None], str]] = (
            list(responses) if responses is not None else list(DEFAULT_TEST_MODEL_RESPONSES)
        )
        self.tool_calls = tool_calls or []
        self.latency_ms = float(latency_ms)
        self.model_name = model_name
        self.raise_on_message = raise_on_message
        self._index = 0
        self.calls: list[dict[str, Any]] = []

    def next_response(self, message: str, system: str | None) -> str:
        """Return the next scripted reply (advances the cursor)."""
        if not self.responses:
            return ""
        if self._index < len(self.responses):
            reply = self.responses[self._index]
            self._index += 1
        else:
            # Reuse the last reply rather than exhausting.
            reply = self.responses[-1]
        if callable(reply):
            return reply(message, system)
        return reply

    async def handle(
        self,
        message: str,
        system: str | None,
        temperature: float,  # noqa: ARG002 - accepted for protocol parity
        max_tokens: int,  # noqa: ARG002
        response_format: type[BaseModel] | None = None,
    ) -> GatewayResponse:
        """Produce a canned :class:`GatewayResponse` (no I/O)."""
        if self.raise_on_message is not None:
            pattern = self.raise_on_message
            if isinstance(pattern, str):
                if pattern in message:
                    raise RuntimeError(f"Test model forced error: {pattern!r} in message")
            elif pattern.search(message):
                raise RuntimeError(f"Test model forced error on {pattern.pattern!r}")

        self.calls.append({"message": message, "system": system})

        if self.latency_ms > 0:
            await asyncio.sleep(self.latency_ms / 1000.0)

        content = self.next_response(message, system)

        metadata: dict[str, Any] = {"test_model": True, "test_call_index": len(self.calls) - 1}
        if self.tool_calls:
            metadata["tool_calls"] = list(self.tool_calls)

        structured: Any | None = None
        if response_format is not None:
            # Allow tests to pre-format their canned reply as JSON.
            try:
                structured = response_format.model_validate_json(content)
            except Exception:
                # Leave structured as None so callers see the failure path.
                structured = None
            metadata["response_format"] = response_format.__name__

        return GatewayResponse(
            content=content,
            model=self.model_name,
            provider=ModelProvider.OPENAI,  # placeholder; not used
            tokens_used=len(content.split()),
            metadata=metadata,
            structured=structured,
        )

    def reset(self) -> None:
        """Rewind the response cursor and clear recorded calls."""
        self._index = 0
        self.calls.clear()
```

## `loopy.gateway.TEST_MODEL_SENTINEL`

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
(source unavailable)```


# Module `loopy.guardrails`

## `loopy.guardrails.GuardrailPipeline` (class)

Full guardrail pipeline with input and output filters.

Example:
    pipeline = GuardrailPipeline()

    # Check user input
    input_result = pipeline.filter_input("Tell me about 123-45-6789")

    # ... process with LLM ...

    # Check model output
    output_result = pipeline.filter_output("Here's the info...")

```python
class GuardrailPipeline:
    """
    Full guardrail pipeline with input and output filters.

    Example:
        pipeline = GuardrailPipeline()

        # Check user input
        input_result = pipeline.filter_input("Tell me about 123-45-6789")

        # ... process with LLM ...

        # Check model output
        output_result = pipeline.filter_output("Here's the info...")
    """

    def __init__(self, config: GuardrailConfig | None = None):
        self.config = config or GuardrailConfig()
        self.input_filter = InputFilter(self.config)
        self.output_filter = OutputFilter(self.config)
        self._history: list[dict[str, Any]] = []

    def filter_input(self, text: str) -> FilterResult:
        """Filter user input."""
        result = self.input_filter.check(text)
        self._history.append(
            {
                "direction": "input",
                "result": result,
            }
        )
        return result

    def filter_output(self, text: str) -> FilterResult:
        """Filter model output."""
        result = self.output_filter.check(text)
        self._history.append(
            {
                "direction": "output",
                "result": result,
            }
        )
        return result

    def get_history(self) -> list[dict[str, Any]]:
        """Return filter history."""
        return self._history.copy()
```

## `loopy.guardrails.InputFilter` (class)

Filters user input for PII, jailbreak attempts, and harmful content.

Example:
    filter = InputFilter()
    result = filter.check("My SSN is 123-45-6789")
    # result.action == FilterAction.REDACT
    # result.filtered == "My SSN is [SSN_REDACTED]"

```python
class InputFilter:
    """
    Filters user input for PII, jailbreak attempts, and harmful content.

    Example:
        filter = InputFilter()
        result = filter.check("My SSN is 123-45-6789")
        # result.action == FilterAction.REDACT
        # result.filtered == "My SSN is [SSN_REDACTED]"
    """

    # PII Patterns
    PATTERNS = {
        "ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
        "email": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"),
        "phone": re.compile(r"\b(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}\b"),
        "credit_card": re.compile(r"\b(?:\d[ -]*?){13,19}\b"),
        "ip_address": re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"),
    }

    # Jailbreak patterns (simplified - production would use ML)
    JAILBREAK_PATTERNS = [
        re.compile(r"ignore (?:all |any )?(?:previous |prior |your )?instructions", re.I),
        re.compile(r"you are now (?:a |an )?(?: DAN |jailbroken |unrestricted)", re.I),
        re.compile(r"(?:pretend|act) (?:you (?:are|have) |as if )no (?:rules|restrictions)", re.I),
        re.compile(r"bypass (?:all |any )?(?:safety|content|filter)", re.I),
        re.compile(r"do anything now", re.I),
        re.compile(r"developer mode", re.I),
        re.compile(r"jailbreak", re.I),
        re.compile(r"ignore safety", re.I),
    ]

    def __init__(self, config: GuardrailConfig | None = None):
        self.config = config or GuardrailConfig()

    def check(self, text: str) -> FilterResult:
        """Check input text against all configured filters."""
        reasons = []
        filtered = text
        should_redact = False

        # Check PII
        pii_checks = {
            "ssn": self.config.detect_ssn,
            "email": self.config.detect_email,
            "phone": self.config.detect_phone,
            "credit_card": self.config.detect_credit_card,
            "ip_address": self.config.detect_ip_address,
        }

        for pii_type, enabled in pii_checks.items():
            if enabled and pii_type in self.PATTERNS and self.PATTERNS[pii_type].search(filtered):
                reasons.append(f"detected_{pii_type}")
                should_redact = True
                replacement = f"[{pii_type.upper()}_REDACTED]"
                filtered = self.PATTERNS[pii_type].sub(replacement, filtered)

        # Check jailbreak
        if self.config.detect_jailbreak:
            for pattern in self.JAILBREAK_PATTERNS:
                if pattern.search(text):
                    reasons.append("jailbreak_attempt")
                    return FilterResult(
                        action=FilterAction.BLOCK,
                        original=text,
                        filtered="",
                        reasons=reasons,
                    )

        # Check custom blocked patterns
        for pattern_str in self.config.blocked_patterns:
            if re.search(pattern_str, text, re.I):
                reasons.append(f"blocked_pattern:{pattern_str}")
                return FilterResult(
                    action=FilterAction.BLOCK,
                    original=text,
                    filtered="",
                    reasons=reasons,
                )

        # Check blocked keywords
        text_lower = text.lower()
        for keyword in self.config.blocked_keywords:
            if keyword.lower() in text_lower:
                reasons.append(f"blocked_keyword:{keyword}")
                return FilterResult(
                    action=FilterAction.BLOCK,
                    original=text,
                    filtered="",
                    reasons=reasons,
                )

        if should_redact:
            return FilterResult(
                action=FilterAction.REDACT,
                original=text,
                filtered=filtered,
                reasons=reasons,
            )

        return FilterResult(
            action=FilterAction.PASS,
            original=text,
            filtered=text,
            reasons=[],
        )
```

## `loopy.guardrails.OutputFilter` (class)

Filters model output for harmful content, data leaks, etc.

Example:
    filter = OutputFilter()
    result = filter.check("The user's email is john@example.com")
    # result.action == FilterAction.REDACT

```python
class OutputFilter:
    """
    Filters model output for harmful content, data leaks, etc.

    Example:
        filter = OutputFilter()
        result = filter.check("The user's email is john@example.com")
        # result.action == FilterAction.REDACT
    """

    def __init__(self, config: GuardrailConfig | None = None):
        self.config = config or GuardrailConfig()
        self._input_filter = InputFilter(config)

    def check(self, text: str) -> FilterResult:
        """Check output text."""
        # Reuse input filter for PII detection in outputs
        return self._input_filter.check(text)
```

## `loopy.guardrails.FilterAction` (class)

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
class FilterAction(str, Enum):
    BLOCK = "block"
    REDACT = "redact"
    WARN = "warn"
    PASS = "pass"
```


# Module `loopy.hooks`

## Hook

> Could not import `loopy.hooks.Hook`: No module named 'loopy.hooks'

## HookContext

> Could not import `loopy.hooks.HookContext`: No module named 'loopy.hooks'

## HookRegistry

> Could not import `loopy.hooks.HookRegistry`: No module named 'loopy.hooks'

## HookResult

> Could not import `loopy.hooks.HookResult`: No module named 'loopy.hooks'

## HookType

> Could not import `loopy.hooks.HookType`: No module named 'loopy.hooks'


# Module `loopy.evals`

## `loopy.evals.EvalSuite` (class)

Collection of evaluation cases.

```python
@dataclass
class EvalSuite:
    """Collection of evaluation cases."""

    name: str
    cases: list[EvalCase] = field(default_factory=list)
    description: str = ""
```

## `loopy.evals.EvalCase` (class)

A single evaluation test case.

```python
@dataclass
class EvalCase:
    """A single evaluation test case."""

    name: str
    input_text: str
    expected_output: str | None = None
    criteria: list[str] = field(default_factory=list)
    tags: list[str] = field(default_factory=list)
    threshold: float = 0.7
```

## `loopy.evals.EvalResult` (class)

Result of evaluating a single case.

```python
@dataclass
class EvalResult:
    """Result of evaluating a single case."""

    case: EvalCase
    actual_output: str
    verdict: Verdict
    score: float  # 0.0 to 1.0
    reasoning: str = ""
    criteria_scores: dict[str, float] = field(default_factory=dict)
    metadata: dict[str, Any] = field(default_factory=dict)

    # v0.7.8 — JSON round-trip helpers so EvalReport can be serialized whole.
    def to_dict(self) -> dict[str, Any]:
        return {
            "case": {
                "name": self.case.name,
                "input_text": self.case.input_text,
                "expected_output": self.case.expected_output,
                "criteria": list(self.case.criteria),
                "tags": list(self.case.tags),
                "threshold": self.case.threshold,
            },
            "actual_output": self.actual_output,
            "verdict": self.verdict.value,
            "score": self.score,
            "reasoning": self.reasoning,
            "criteria_scores": dict(self.criteria_scores),
            "metadata": dict(self.metadata),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> EvalResult:
        case_data = data.get("case", {})
        case = EvalCase(
            name=case_data.get("name", ""),
            input_text=case_data.get("input_text", ""),
            expected_output=case_data.get("expected_output"),
            criteria=list(case_data.get("criteria", [])),
            tags=list(case_data.get("tags", [])),
            threshold=case_data.get("threshold", 0.7),
        )
        return cls(
            case=case,
            actual_output=data.get("actual_output", ""),
            verdict=Verdict(data.get("verdict", "fail")),
            score=float(data.get("score", 0.0)),
            reasoning=data.get("reasoning", ""),
            criteria_scores=dict(data.get("criteria_scores", {})),
            metadata=dict(data.get("metadata", {})),
        )
```

## `loopy.evals.EvalReport` (class)

Full evaluation report.

```python
@dataclass
class EvalReport:
    """Full evaluation report."""

    suite_name: str
    results: list[EvalResult] = field(default_factory=list)

    @property
    def total(self) -> int:
        return len(self.results)

    @property
    def passed(self) -> int:
        return sum(1 for r in self.results if r.verdict == Verdict.PASS)

    @property
    def failed(self) -> int:
        return sum(1 for r in self.results if r.verdict == Verdict.FAIL)

    @property
    def partial(self) -> int:
        return sum(1 for r in self.results if r.verdict == Verdict.PARTIAL)

    @property
    def pass_rate(self) -> float:
        return self.passed / self.total if self.total > 0 else 0.0

    @property
    def average_score(self) -> float:
        if not self.results:
            return 0.0
        return sum(r.score for r in self.results) / len(self.results)

    def summary(self) -> dict[str, Any]:
        """Return summary dict."""
        return {
            "suite": self.suite_name,
            "total": self.total,
            "passed": self.passed,
            "failed": self.failed,
            "partial": self.partial,
            "pass_rate": f"{self.pass_rate:.1%}",
            "average_score": f"{self.average_score:.2f}",
        }

    # v0.7.8 — JSON serialization / file I/O so eval reports become
    # CI-friendly artifacts (compare across runs, archive, attach to PRs).
    def to_dict(self) -> dict[str, Any]:
        """Serialize the report (and every nested case/result) to a dict."""
        return {
            "suite_name": self.suite_name,
            "results": [r.to_dict() for r in self.results],
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> EvalReport:
        """Reconstruct an :class:`EvalReport` from :meth:`to_dict` output."""
        return cls(
            suite_name=data.get("suite_name", ""),
            results=[EvalResult.from_dict(r) for r in data.get("results", [])],
        )

    def to_json(self, indent: int = 2) -> str:
        """Serialize the report to a JSON string."""
        return json.dumps(self.to_dict(), indent=indent)

    @classmethod
    def from_json(cls, payload: str) -> EvalReport:
        """Reconstruct an :class:`EvalReport` from a JSON string."""
        return cls.from_dict(json.loads(payload))

    def save(self, path: str) -> None:
        """Write the report to ``path`` as JSON.

        Creates parent directories if they do not exist.
        """
        from pathlib import Path  # local import keeps top of file unchanged

        Path(path).parent.mkdir(parents=True, exist_ok=True)
        Path(path).write_text(self.to_json(), encoding="utf-8")

    @classmethod
    def load(cls, path: str) -> EvalReport:
        """Load a report previously written by :meth:`save`.

        Returns an empty report if the file does not exist or is unreadable;
        a warning is logged in either failure mode.
        """
        from pathlib import Path

        p = Path(path)
        if not p.exists():
            logger.warning("Eval report not found at %s; returning empty report", path)
            return cls(suite_name="")

        try:
            return cls.from_dict(json.loads(p.read_text(encoding="utf-8")))
        except Exception as e:
            logger.warning("Failed to load eval report %s: %s", path, e)
            return cls(suite_name="")
```

## `loopy.evals.Evaluator` (class)

Judge-based evaluation framework.

Uses an LLM as a judge to evaluate model outputs against criteria.

Example:
    evaluator = Evaluator(judge_fn=my_llm_judge)

    suite = EvalSuite(
        name="math_basic",
        cases=[
            EvalCase(
                name="addition",
                input_text="What is 2+2?",
                expected_output="4",
                criteria=["correct", "concise"],
            ),
        ],
    )

    report = evaluator.run(suite, model_fn=my_model)
    print(report.summary())

```python
class Evaluator:
    """
    Judge-based evaluation framework.

    Uses an LLM as a judge to evaluate model outputs against criteria.

    Example:
        evaluator = Evaluator(judge_fn=my_llm_judge)

        suite = EvalSuite(
            name="math_basic",
            cases=[
                EvalCase(
                    name="addition",
                    input_text="What is 2+2?",
                    expected_output="4",
                    criteria=["correct", "concise"],
                ),
            ],
        )

        report = evaluator.run(suite, model_fn=my_model)
        print(report.summary())
    """

    JUDGE_PROMPT = """You are an evaluation judge. Your task is to score a model's output.

Input: {input}
Expected Output: {expected}
Actual Output: {actual}
Criteria: {criteria}

Score each criterion from 0.0 to 1.0, then provide an overall score.
Respond in JSON:
{{
    "criteria_scores": {{"criterion": score}},
    "overall_score": 0.0-1.0,
    "reasoning": "explanation",
    "verdict": "pass" | "fail" | "partial"
}}"""

    def __init__(
        self,
        judge_fn: Callable[[str], Awaitable[str]] | None = None,
        model_fn: Callable[[str], Awaitable[str]] | None = None,
    ):
        self.judge_fn = judge_fn
        self.model_fn = model_fn

    async def run(
        self,
        suite: EvalSuite,
        model_fn: Callable[[str], Awaitable[str]] | None = None,
    ) -> EvalReport:
        """
        Run evaluation suite.

        Args:
            suite: The evaluation suite to run
            model_fn: Function to get model output (or use instance default)

        Returns:
            EvalReport with all results
        """
        fn = model_fn or self.model_fn
        if not fn:
            raise ValueError("No model function provided")

        report = EvalReport(suite_name=suite.name)

        for case in suite.cases:
            result = await self._eval_case(case, fn)
            report.results.append(result)

        return report

    async def _eval_case(
        self,
        case: EvalCase,
        model_fn: Callable[[str], Awaitable[str]],
    ) -> EvalResult:
        """Evaluate a single case."""
        # Get model output
        actual_output = await model_fn(case.input_text)

        # If no judge function, use simple string matching
        if not self.judge_fn:
            return self._simple_eval(case, actual_output)

        # Use LLM judge
        criteria_str = ", ".join(case.criteria) if case.criteria else "general quality"

        prompt = self.JUDGE_PROMPT.format(
            input=case.input_text,
            expected=case.expected_output or "N/A",
            actual=actual_output,
            criteria=criteria_str,
        )

        judge_response = await self.judge_fn(prompt)

        try:
            # Parse judge response
            data = json.loads(judge_response)
            score = float(data.get("overall_score", 0.0))
            verdict = Verdict(data.get("verdict", "fail"))

            return EvalResult(
                case=case,
                actual_output=actual_output,
                verdict=verdict,
                score=score,
                reasoning=data.get("reasoning", ""),
                criteria_scores=data.get("criteria_scores", {}),
            )
        except (json.JSONDecodeError, ValueError):
            # Fallback to simple eval
            return self._simple_eval(case, actual_output)

    def _simple_eval(self, case: EvalCase, actual_output: str) -> EvalResult:
        """Simple string-based evaluation when no judge is available."""
        if case.expected_output:
            # Exact match
            if actual_output.strip() == case.expected_output.strip():
                score = 1.0
                verdict = Verdict.PASS
            # Partial match (contains expected)
            elif case.expected_output.lower() in actual_output.lower():
                score = 0.7
                verdict = Verdict.PARTIAL
            else:
                score = 0.0
                verdict = Verdict.FAIL
        else:
            # No expected output, just check it's not empty
            score = 1.0 if actual_output.strip() else 0.0
            verdict = Verdict.PASS if actual_output.strip() else Verdict.FAIL

        return EvalResult(
            case=case,
            actual_output=actual_output,
            verdict=verdict,
            score=score,
            reasoning="Simple string matching (no judge function)",
        )
```

## `loopy.evals.EvalGate` (class)

Evaluation gate for the evaluator-optimizer pattern.

Uses LLM-as-judge to evaluate outputs against criteria.
Part of the 2026 agentic workflow evaluator-optimizer pattern.

Example:
    gate = EvalGate(
        gate_type=EvalGateType.JUDGE,
        config=JudgeConfig(
            criteria=["correct", "concise", "helpful"],
            threshold=0.8,
        ),
        judge_fn=my_llm_judge,
    )

    result = await gate.evaluate(
        input_text="What is Python?",
        output="Python is a programming language...",
    )

    if result.passed:
        print("Output passed evaluation!")

```python
class EvalGate:
    """
    Evaluation gate for the evaluator-optimizer pattern.

    Uses LLM-as-judge to evaluate outputs against criteria.
    Part of the 2026 agentic workflow evaluator-optimizer pattern.

    Example:
        gate = EvalGate(
            gate_type=EvalGateType.JUDGE,
            config=JudgeConfig(
                criteria=["correct", "concise", "helpful"],
                threshold=0.8,
            ),
            judge_fn=my_llm_judge,
        )

        result = await gate.evaluate(
            input_text="What is Python?",
            output="Python is a programming language...",
        )

        if result.passed:
            print("Output passed evaluation!")
    """

    def __init__(
        self,
        gate_type: EvalGateType,
        config: JudgeConfig | None = None,
        judge_fn: Callable[[str], Awaitable[str]] | None = None,
    ):
        self.gate_type = gate_type
        self.config = config or JudgeConfig()
        self.judge_fn = judge_fn

    async def evaluate(
        self,
        input_text: str,
        output: str,
        criteria: list[str] | None = None,
    ) -> EvalGateResult:
        """
        Evaluate an output against criteria.

        Args:
            input_text: The original input/prompt
            output: The output to evaluate
            criteria: Optional override for criteria

        Returns:
            EvalGateResult with pass/fail and score
        """
        if self.gate_type == EvalGateType.JUDGE:
            return await self._judge_evaluate(input_text, output, criteria)

        # Manual gates always pass (human reviews externally)
        return EvalGateResult(
            gate_type=self.gate_type,
            passed=True,
            score=1.0,
            feedback="Manual gate - pending human review",
        )

    async def _judge_evaluate(
        self,
        input_text: str,
        output: str,
        criteria: list[str] | None = None,
    ) -> EvalGateResult:
        """Use LLM-as-judge to evaluate output."""
        if not self.judge_fn:
            # Fallback to simple evaluation
            return self._simple_judge_evaluate(input_text, output, criteria)

        criteria_list = criteria or self.config.criteria
        criteria_str = ", ".join(criteria_list) if criteria_list else "general quality"

        prompt = self.config.prompt_template.format(
            input=input_text,
            output=output,
            criteria=criteria_str,
        )

        try:
            judge_response = await self.judge_fn(prompt)
            data = json.loads(judge_response)

            score = float(data.get("score", 0.0))
            passed = score >= self.config.threshold

            return EvalGateResult(
                gate_type=EvalGateType.JUDGE,
                passed=passed,
                score=score,
                feedback=data.get("feedback", ""),
                metadata={"criteria": criteria_list},
            )
        except (json.JSONDecodeError, ValueError, KeyError) as e:
            logger.warning("Judge evaluation failed, using fallback: %s", e)
            return self._simple_judge_evaluate(input_text, output, criteria)

    def _simple_judge_evaluate(
        self,
        input_text: str,
        output: str,
        criteria: list[str] | None = None,
    ) -> EvalGateResult:
        """Simple evaluation when no judge function is available."""
        # Basic heuristics
        score = 0.0
        feedback = []

        # Check output is not empty
        if output.strip():
            score += 0.3
            feedback.append("Output is non-empty")

        # Check output length (prefer concise)
        word_count = len(output.split())
        if 10 <= word_count <= 200:
            score += 0.3
            feedback.append(f"Good length ({word_count} words)")
        elif word_count > 200:
            score += 0.1
            feedback.append(f"Too long ({word_count} words)")

        # Check for basic relevance (input words in output)
        input_words = set(input_text.lower().split())
        output_words = set(output.lower().split())
        overlap = len(input_words & output_words) / max(len(input_words), 1)
        score += 0.4 * overlap
        feedback.append(f"Relevance overlap: {overlap:.1%}")

        passed = score >= self.config.threshold

        return EvalGateResult(
            gate_type=EvalGateType.JUDGE,
            passed=passed,
            score=min(score, 1.0),
            feedback="; ".join(feedback),
            metadata={"method": "simple_heuristic"},
        )
```

## `loopy.evals.EvalGateResult` (class)

Result of an evaluation gate check.

```python
@dataclass
class EvalGateResult:
    """Result of an evaluation gate check."""

    gate_type: EvalGateType
    passed: bool
    score: float = 0.0
    feedback: str = ""
    metadata: dict[str, Any] = field(default_factory=dict)
```

## `loopy.evals.EvalGateType` (class)

Types of evaluation gates.

```python
class EvalGateType(str, Enum):
    """Types of evaluation gates."""

    JUDGE = "judge"  # LLM-as-judge (2026 evaluator-optimizer pattern)
    MANUAL = "manual"  # Human approval stub
```

## `loopy.evals.JudgeConfig` (class)

Configuration for LLM-as-judge evaluation.

```python
@dataclass
class JudgeConfig:
    """Configuration for LLM-as-judge evaluation."""

    evaluator_model: str = "gpt-4"
    criteria: list[str] = field(default_factory=list)
    threshold: float = 0.7  # pass threshold
    prompt_template: str = """Rate this output on the given criteria.

Input: {input}
Output: {output}
Criteria: {criteria}

Respond with JSON:
{{
    "score": 0.0-1.0,
    "pass": true/false,
    "feedback": "explanation"
}}"""
```

## `loopy.evals.Verdict` (class)

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
class Verdict(str, Enum):
    PASS = "pass"
    FAIL = "fail"
    PARTIAL = "partial"
```


# Module `loopy.cache`

## `loopy.cache.LLMCache` (class)

Semantic cache for LLM responses.

Caches responses by hashing the prompt + model combination.
Supports TTL, size limits, and persistence.

Example:
    cache = LLMCache(ttl=3600, max_size=1000)

    # Check cache before calling LLM
    cached = cache.get("What is Python?", model="gpt-4")
    if cached:
        response = cached
    else:
        response = await call_llm("What is Python?")
        cache.set("What is Python?", response, model="gpt-4")

    stats = cache.stats()
    print(f"Cache hit rate: {stats.hit_rate:.1%}")

```python
class LLMCache:
    """
    Semantic cache for LLM responses.

    Caches responses by hashing the prompt + model combination.
    Supports TTL, size limits, and persistence.

    Example:
        cache = LLMCache(ttl=3600, max_size=1000)

        # Check cache before calling LLM
        cached = cache.get("What is Python?", model="gpt-4")
        if cached:
            response = cached
        else:
            response = await call_llm("What is Python?")
            cache.set("What is Python?", response, model="gpt-4")

        stats = cache.stats()
        print(f"Cache hit rate: {stats.hit_rate:.1%}")
    """

    def __init__(
        self,
        ttl: int = 3600,
        max_size: int = 1000,
        persist_path: str | Path | None = None,
    ):
        """
        Args:
            ttl: Time-to-live in seconds
            max_size: Maximum number of entries
            persist_path: Optional path to persist cache to disk
        """
        self.ttl = ttl
        self.max_size = max_size
        self.persist_path = Path(persist_path) if persist_path else None

        self._cache: dict[str, CacheEntry] = {}
        self._stats = CacheStats()

        # Load persisted cache
        if self.persist_path and self.persist_path.exists():
            self._load()

    def _make_key(self, prompt: str, model: str, **kwargs: Any) -> str:
        """Generate cache key from prompt and model."""
        key_data = {
            "prompt": prompt,
            "model": model,
            **kwargs,
        }
        key_str = json.dumps(key_data, sort_keys=True)
        return hashlib.sha256(key_str.encode()).hexdigest()[:16]

    def get(self, prompt: str, model: str, **kwargs: Any) -> str | None:
        """
        Get cached response if available.

        Returns:
            Cached response string or None
        """
        key = self._make_key(prompt, model, **kwargs)

        entry = self._cache.get(key)
        if not entry:
            self._stats.misses += 1
            return None

        # Check TTL
        if time.time() - entry.created_at > self.ttl:
            del self._cache[key]
            self._stats.misses += 1
            return None

        # Update access stats
        entry.last_accessed = time.time()
        entry.access_count += 1
        self._stats.hits += 1
        self._stats.total_saved_tokens += entry.tokens_saved

        logger.debug("Cache hit: %s... (accessed %dx)", key[:8], entry.access_count)
        return entry.response

    def set(
        self,
        prompt: str,
        response: str,
        model: str,
        tokens: int = 0,
        **kwargs: Any,
    ) -> None:
        """
        Cache a response.

        Args:
            prompt: The input prompt
            response: The model's response
            model: Model identifier
            tokens: Number of tokens in the response (for savings tracking)
        """
        key = self._make_key(prompt, model, **kwargs)

        # Evict if at capacity
        if len(self._cache) >= self.max_size and key not in self._cache:
            self._evict()

        self._cache[key] = CacheEntry(
            key=key,
            response=response,
            model=model,
            tokens_saved=tokens,
        )

        logger.debug("Cached response: %s... (%d tokens)", key[:8], tokens)

        # Persist if configured
        if self.persist_path:
            self._save()

    def invalidate(self, prompt: str, model: str, **kwargs: Any) -> bool:
        """Remove a specific entry from cache."""
        key = self._make_key(prompt, model, **kwargs)
        if key in self._cache:
            del self._cache[key]
            return True
        return False

    async def aget(self, prompt: str, model: str, **kwargs: Any) -> str | None:
        """v0.7.8 — Async wrapper around :meth:`get`.

        Identical semantics; provided so async callers can `await` without
        having to drop into a thread executor themselves.
        """
        return self.get(prompt, model, **kwargs)

    async def aset(
        self,
        prompt: str,
        response: str,
        model: str,
        tokens: int = 0,
        **kwargs: Any,
    ) -> None:
        """v0.7.8 — Async wrapper around :meth:`set` with non-blocking I/O.

        Mirrors the v0.7.7 ``MemoryStore`` async-save pattern: the in-memory
        write happens synchronously (cheap), and disk persistence — when
        ``persist_path`` is configured — runs in a worker thread via
        ``asyncio.to_thread`` so a slow filesystem cannot stall the loop.
        """
        key = self._make_key(prompt, model, **kwargs)

        if len(self._cache) >= self.max_size and key not in self._cache:
            self._evict()

        self._cache[key] = CacheEntry(
            key=key,
            response=response,
            model=model,
            tokens_saved=tokens,
        )

        if self.persist_path:
            await self._asave()

    def clear(self) -> None:
        """Clear all cached entries."""
        self._cache.clear()
        self._stats = CacheStats()
        logger.info("Cache cleared")

    def stats(self) -> CacheStats:
        """Return cache statistics."""
        return self._stats

    def _evict(self) -> None:
        """Evict the least recently used cache entry.

        Uses *last_accessed* timestamps to find the LRU entry.
        Called automatically when the cache is at capacity.
        """
        if not self._cache:
            return

        # Find LRU entry
        lru_key = min(self._cache, key=lambda k: self._cache[k].last_accessed)
        del self._cache[lru_key]
        logger.debug("Evicted LRU entry: %s...", lru_key[:8])

    def _save(self) -> None:
        """Persist the in-memory cache to disk as JSON.

        Creates parent directories if they don't exist.
        Silently skips if *persist_path* was not configured.
        """
        if not self.persist_path:
            return

        self.persist_path.parent.mkdir(parents=True, exist_ok=True)

        data = {}
        for key, entry in self._cache.items():
            data[key] = {
                "response": entry.response,
                "model": entry.model,
                "tokens_saved": entry.tokens_saved,
                "created_at": entry.created_at,
            }

        self.persist_path.write_text(json.dumps(data, indent=2))

    async def _asave(self) -> None:
        """v0.7.8 - Async persistence; runs the blocking write in a worker.

        Snapshots the cache into a plain dict on the event-loop thread
        (cheap), then writes the JSON file via ``asyncio.to_thread`` so a
        slow disk never blocks other coroutines.
        """
        if not self.persist_path:
            return

        self.persist_path.parent.mkdir(parents=True, exist_ok=True)
        payload = {
            key: {
                "response": entry.response,
                "model": entry.model,
                "tokens_saved": entry.tokens_saved,
                "created_at": entry.created_at,
            }
            for key, entry in self._cache.items()
        }

        def _write() -> None:
            self.persist_path.write_text(json.dumps(payload, indent=2))

        await asyncio.to_thread(_write)

    def _load(self) -> None:
        """Restore the in-memory cache from the persisted JSON file.

        Silently skips if no persisted file exists.
        On parse failure, starts with an empty cache and logs a warning.
        """
        if not self.persist_path or not self.persist_path.exists():
            return

        try:
            data = json.loads(self.persist_path.read_text())
            for key, entry_data in data.items():
                self._cache[key] = CacheEntry(
                    key=key,
                    response=entry_data["response"],
                    model=entry_data["model"],
                    tokens_saved=entry_data.get("tokens_saved", 0),
                    created_at=entry_data.get("created_at", time.time()),
                )
            logger.info("Loaded %d entries from cache", len(self._cache))
        except Exception as e:
            logger.warning("Failed to load cache: %s", e)
```

## `loopy.cache.CacheStats` (class)

Cache statistics.

```python
@dataclass
class CacheStats:
    """Cache statistics."""

    hits: int = 0
    misses: int = 0
    total_saved_tokens: int = 0

    @property
    def hit_rate(self) -> float:
        total = self.hits + self.misses
        return self.hits / total if total > 0 else 0.0

    @property
    def estimated_savings(self) -> float:
        """Rough cost estimate assuming $0.03 per 1K tokens."""
        return (self.total_saved_tokens / 1000) * 0.03
```


# Module `loopy.observe`

## `loopy.observe.Tracer` (class)

Distributed tracer for LLM operations.

Example:
    tracer = Tracer(service="my_app")

    with tracer.start("llm_call") as span:
        span.set_attribute("model", "gpt-4")
        response = await llm.complete(prompt)
        span.set_attribute("tokens", response.usage.total_tokens)

```python
class Tracer:
    """
    Distributed tracer for LLM operations.

    Example:
        tracer = Tracer(service="my_app")

        with tracer.start("llm_call") as span:
            span.set_attribute("model", "gpt-4")
            response = await llm.complete(prompt)
            span.set_attribute("tokens", response.usage.total_tokens)
    """

    def __init__(self, service: str = "loopy", redactor: Redactor | None = None):
        self.service = service
        self._spans: list[Span] = []
        self._current_trace_id: str | None = None
        # v0.7.9 - optional redactor applied at span completion.
        self.redactor: Redactor | None = redactor
        # v0.8.0 - instrumentation controls. ``disabled`` is a runtime
        # flag (sets it to True to skip span recording without
        # touching call sites); ``shutdown`` is a one-way latch set
        # by :meth:`shutdown` and makes every public entry point a
        # graceful no-op (used by the @observe() decorator so it
        # never raises after a tracer is torn down).
        self.disabled: bool = False
        self._shutdown: bool = False

    def _generate_id(self) -> str:
        """Generate a unique ID using UUID4."""
        return uuid.uuid4().hex[:16]

    def start_span(
        self,
        name: str,
        parent_id: str | None = None,
        **attributes: Any,
    ) -> Span:
        """
        Start a new span.

        Args:
            name: Span name (e.g., "llm_call", "tool_use")
            parent_id: Optional parent span ID
            **attributes: Initial attributes

        Returns:
            New Span instance. When ``self.disabled`` is True or the
            tracer has been :meth:`shutdown`, a sentinel
            :class:`Span` with ``recorded=False`` is returned so the
            @observe() decorator can drive its lifecycle without
            raising.
        """
        trace_id = self._current_trace_id or self._generate_id()
        span_id = self._generate_id()

        if self.disabled or self._shutdown:
            return Span(
                name=name,
                trace_id=trace_id,
                span_id=span_id,
                parent_id=parent_id,
                recorded=False,
            )

        span = Span(
            name=name,
            trace_id=trace_id,
            span_id=span_id,
            parent_id=parent_id,
            attributes={"service": self.service, **attributes},
        )

        # v0.7.9 - scrub attributes/events before storage.
        if self.redactor is not None:
            span.attributes = self.redactor.redact_value(span.attributes)
            span.events = self.redactor.redact_value(span.events)

        self._spans.append(span)
        logger.debug("Started span: %s (%s)", name, span_id)

        return span

    def shutdown(self) -> None:
        """v0.8.0 — one-way latch: subsequent ``start_span`` calls return
        non-recording sentinel spans so instrumentation never raises.
        """
        self._shutdown = True

    def start(self, name: str, **attributes: Any) -> SpanContext:
        """
        Start a span with context manager support.

        Example:
            with tracer.start("llm_call") as span:
                span.set_attribute("model", "gpt-4")
                # ... do work ...
        """
        span = self.start_span(name, **attributes)
        return SpanContext(span)

    def get_spans(self) -> list[Span]:
        """Get all recorded spans."""
        return self._spans.copy()

    def get_trace(self, trace_id: str) -> list[Span]:
        """Get all spans for a trace."""
        return [s for s in self._spans if s.trace_id == trace_id]

    def export_json(self) -> str:
        """Export all spans as JSON."""
        return json.dumps([s.to_dict() for s in self._spans], indent=2)

    def export_otlp(self) -> dict[str, Any]:
        """
        Export spans in OTLP-compatible format.

        Delegates to :meth:`export_opentelemetry` for consistent
        output across all export methods.

        Returns:
            A dict with resource attributes and span data.
        """
        return self.export_opentelemetry()

    def clear(self) -> None:
        """Clear all spans."""
        self._spans.clear()

    def export_opentelemetry(self) -> dict[str, Any]:
        """
        Export spans in OpenTelemetry-compatible format.

        Returns a dict with resource info and spans ready for OTLP export.
        """
        return {
            "resource": {
                "attributes": {
                    "service.name": self.service,
                    "service.version": __version__,
                }
            },
            "spans": [
                {
                    "traceId": s.trace_id,
                    "spanId": s.span_id,
                    "parentSpanId": s.parent_id,
                    "operationName": s.name,
                    "startTime": int(s.start_time * 1e6),  # microseconds
                    "endTime": int((s.end_time or time.time()) * 1e6),
                    "attributes": [
                        {"key": k, "value": {"stringValue": str(v)}}
                        for k, v in s.attributes.items()
                    ],
                    "events": [
                        {
                            "name": e["name"],
                            "time": int(e["timestamp"] * 1e6),
                        }
                        for e in s.events
                    ],
                }
                for s in self._spans
            ],
        }
```

## `loopy.observe.Span` (class)

A single trace span representing an operation.

Example:
    span = Tracer.start_span("llm_call", model="gpt-4")
    # ... do work ...
    span.set_attribute("tokens", 150)
    span.set_status(SpanStatus.OK)
    span.end()

```python
@dataclass
class Span:
    """
    A single trace span representing an operation.

    Example:
        span = Tracer.start_span("llm_call", model="gpt-4")
        # ... do work ...
        span.set_attribute("tokens", 150)
        span.set_status(SpanStatus.OK)
        span.end()
    """

    name: str
    trace_id: str
    span_id: str
    parent_id: str | None = None

    start_time: float = field(default_factory=time.time)
    end_time: float | None = None

    status: SpanStatus = SpanStatus.UNSET
    attributes: dict[str, Any] = field(default_factory=dict)
    events: list[dict[str, Any]] = field(default_factory=list)
    # v0.8.0 — False for sentinel spans returned by a disabled or
    # shutdown tracer. Drives cheap no-ops in the @observe() decorator
    # and keeps export methods from surfacing internal noise.
    recorded: bool = True

    @property
    def duration_ms(self) -> float | None:
        if self.end_time is None:
            return None
        return (self.end_time - self.start_time) * 1000

    def set_attribute(self, key: str, value: Any) -> None:
        """Set a span attribute."""
        self.attributes[key] = value

    def add_event(self, name: str, attributes: dict[str, Any] | None = None) -> None:
        """Add an event to the span."""
        self.events.append(
            {
                "name": name,
                "timestamp": time.time(),
                "attributes": attributes or {},
            }
        )

    def set_status(self, status: SpanStatus, message: str = "") -> None:
        """Set span status."""
        self.status = status
        if message:
            self.attributes["status_message"] = message

    def end(self) -> None:
        """End the span."""
        self.end_time = time.time()

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary."""
        return {
            "name": self.name,
            "trace_id": self.trace_id,
            "span_id": self.span_id,
            "parent_id": self.parent_id,
            "start_time": self.start_time,
            "end_time": self.end_time,
            "duration_ms": self.duration_ms,
            "status": self.status.value,
            "attributes": self.attributes,
            "events": self.events,
        }
```

## `loopy.observe.SpanStatus` (class)

str(object='') -> str
str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or
errors is specified, then the object must expose a data buffer
that will be decoded using the given encoding and error handler.
Otherwise, returns the result of object.__str__() (if defined)
or repr(object).
encoding defaults to sys.getdefaultencoding().
errors defaults to 'strict'.

```python
class SpanStatus(str, Enum):
    OK = "ok"
    ERROR = "error"
    UNSET = "unset"
```

## `loopy.observe.MetricsCollector` (class)

Simple metrics collector for LLM observability.

Supports counters, histograms, and gauges with tag-based
grouping for summary aggregation.

Example:
    metrics = MetricsCollector()

    metrics.increment("llm.requests", tags={"model": "gpt-4"})
    metrics.histogram("llm.tokens", 150, tags={"model": "gpt-4"})
    metrics.gauge("cache.size", 42)

    summary = metrics.summary()

```python
class MetricsCollector:
    """
    Simple metrics collector for LLM observability.

    Supports counters, histograms, and gauges with tag-based
    grouping for summary aggregation.

    Example:
        metrics = MetricsCollector()

        metrics.increment("llm.requests", tags={"model": "gpt-4"})
        metrics.histogram("llm.tokens", 150, tags={"model": "gpt-4"})
        metrics.gauge("cache.size", 42)

        summary = metrics.summary()
    """

    def __init__(self):
        self._metrics: list[MetricPoint] = []

    def increment(self, name: str, value: float = 1, **tags: str) -> None:
        """Record a counter increment.

        Args:
            name: Metric name.
            value: Amount to increment by (default 1).
            **tags: Key-value tag pairs for grouping.
        """
        self._metrics.append(MetricPoint(name=name, value=value, tags=tags))

    def histogram(self, name: str, value: float, **tags: str) -> None:
        """Record a histogram observation.

        Args:
            name: Metric name.
            value: Observed value.
            **tags: Key-value tag pairs for grouping.
        """
        self._metrics.append(MetricPoint(name=name, value=value, tags=tags))

    def gauge(self, name: str, value: float, **tags: str) -> None:
        """Set a gauge to a value.

        Args:
            name: Metric name.
            value: Current gauge value.
            **tags: Key-value tag pairs for grouping.
        """
        self._metrics.append(MetricPoint(name=name, value=value, tags=tags))

    def summary(self) -> dict[str, Any]:
        """Get summary of collected metrics."""
        by_name: dict[str, list[float]] = {}
        for m in self._metrics:
            if m.name not in by_name:
                by_name[m.name] = []
            by_name[m.name].append(m.value)

        return {
            name: {
                "count": len(values),
                "sum": sum(values),
                "avg": sum(values) / len(values) if values else 0,
                "min": min(values) if values else 0,
                "max": max(values) if values else 0,
            }
            for name, values in by_name.items()
        }

    def export(self) -> list[dict[str, Any]]:
        """Export all metrics."""
        return [
            {
                "name": m.name,
                "value": m.value,
                "timestamp": m.timestamp,
                "tags": m.tags,
            }
            for m in self._metrics
        ]

    def clear(self) -> None:
        """Clear all metrics."""
        self._metrics.clear()
```

## `loopy.observe.Redactor` (class)

v0.7.9 - PII / secret aware redaction for traces and exports.

Replaces sensitive substrings with stable placeholders so trace
storage and HTTP export never leak credentials, tokens, or PII.

Built-in patterns (all enabled by default):

| name            | matches                                  |
|-----------------|------------------------------------------|
| ``email``       | RFC-ish email addresses                  |
| ``phone``       | US/International phone-shaped numbers    |
| ``ssn``         | US Social Security Numbers               |
| ``credit_card`` | 13-19 digit card-shaped numbers          |
| ``openai_key``  | ``sk-...``, ``sk-proj-...`` tokens       |
| ``aws_key``     | ``AKIA``/``ASIA`` access keys            |
| ``jwt``         | Three-segment dot-delimited JWTs         |
| ``bearer``      | ``Bearer <token>`` headers               |
| ``ipv4``        | IPv4 addresses                           |

Patterns can be removed (``redactor.disable("phone")``) or extended
(``redactor.add_pattern("employee_id", r"EID-d{6}")``).

The redactor is *pure-string* and side-effect free: ``redact()``
never mutates its input, only returns a new string.

```python
@dataclass
class Redactor:
    """v0.7.9 - PII / secret aware redaction for traces and exports.

    Replaces sensitive substrings with stable placeholders so trace
    storage and HTTP export never leak credentials, tokens, or PII.

    Built-in patterns (all enabled by default):

    | name            | matches                                  |
    |-----------------|------------------------------------------|
    | ``email``       | RFC-ish email addresses                  |
    | ``phone``       | US/International phone-shaped numbers    |
    | ``ssn``         | US Social Security Numbers               |
    | ``credit_card`` | 13-19 digit card-shaped numbers          |
    | ``openai_key``  | ``sk-...``, ``sk-proj-...`` tokens       |
    | ``aws_key``     | ``AKIA``/``ASIA`` access keys            |
    | ``jwt``         | Three-segment dot-delimited JWTs         |
    | ``bearer``      | ``Bearer <token>`` headers               |
    | ``ipv4``        | IPv4 addresses                           |

    Patterns can be removed (``redactor.disable("phone")``) or extended
    (``redactor.add_pattern("employee_id", r"EID-d{6}")``).

    The redactor is *pure-string* and side-effect free: ``redact()``
    never mutates its input, only returns a new string.
    """

    name: str = "default"
    enabled: dict[str, re.Pattern[str]] = field(default_factory=dict)
    extra: dict[str, re.Pattern[str]] = field(default_factory=dict)
    placeholder_format: str = "[{name}_REDACTED]"

    def __post_init__(self) -> None:
        if not self.enabled:
            self.enabled = {
                "email": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
                "phone": re.compile(r"\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"),
                "ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
                "credit_card": re.compile(r"\b(?:\d[ -]*?){13,19}\b"),
                "openai_key": re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b"),
                "aws_key": re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
                "jwt": re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
                "bearer": re.compile(r"(?i)Bearer\s+[A-Za-z0-9._\-+/=]+"),
                "ipv4": re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
            }

    def add_pattern(self, name: str, pattern: str) -> None:
        """Register a custom regex pattern under ``name``.

        Raises ``ValueError`` if ``name`` collides with a built-in
        (use ``disable`` first if you really want to override).
        """
        if name in self.enabled:
            raise ValueError(
                f"{name!r} is a built-in pattern; disable it first if you want to override."
            )
        self.extra[name] = re.compile(pattern)

    def disable(self, name: str) -> None:
        """Remove a pattern from the active set."""
        self.enabled.pop(name, None)
        self.extra.pop(name, None)

    @property
    def active_patterns(self) -> dict[str, re.Pattern[str]]:
        """Combined dict of built-in + custom active patterns."""
        return {**self.enabled, **self.extra}

    def redact(self, text: str) -> str:
        """Return a copy of ``text`` with every match replaced."""
        if not isinstance(text, str) or not text:
            return text
        result = text
        for name, pattern in self.active_patterns.items():
            replacement = self.placeholder_format.format(name=name.upper())
            result = pattern.sub(replacement, result)
        return result

    def find_all(self, text: str) -> list[RedactionMatch]:
        """Return every match (name, span) without modifying ``text``."""
        if not isinstance(text, str) or not text:
            return []
        matches: list[RedactionMatch] = []
        for name, pattern in self.active_patterns.items():
            replacement = self.placeholder_format.format(name=name.upper())
            for m in pattern.finditer(text):
                matches.append(
                    RedactionMatch(
                        name=name,
                        start=m.start(),
                        end=m.end(),
                        replacement=replacement,
                    )
                )
        matches.sort(key=lambda m: m.start)
        return matches

    def redact_value(self, value: Any) -> Any:
        """Recursively redact string leaves inside dicts / lists / tuples."""
        if isinstance(value, str):
            return self.redact(value)
        if isinstance(value, dict):
            return {k: self.redact_value(v) for k, v in value.items()}
        if isinstance(value, list):
            return [self.redact_value(v) for v in value]
        if isinstance(value, tuple):
            return tuple(self.redact_value(v) for v in value)
        return value
```

## `loopy.observe.RedactionMatch` (class)

v0.7.9 - A single redacted substring.

```python
@dataclass
class RedactionMatch:
    """v0.7.9 - A single redacted substring."""

    name: str
    start: int
    end: int
    replacement: str

    def __repr__(self) -> str:
        return f"RedactionMatch(name={self.name!r}, len={self.end - self.start})"
```


# Module `loopy.mcp`

## `loopy.mcp.MCPClient` (class)

Model Context Protocol client.

Connects to MCP servers and exposes their tools.

Example:
    client = MCPClient("http://localhost:3000")

    # List available tools
    tools = await client.list_tools()
    for tool in tools:
        print(f"{tool.name}: {tool.description}")

    # Call a tool
    result = await client.call_tool("get_weather", {"city": "Portland"})

```python
class MCPClient:
    """
    Model Context Protocol client.

    Connects to MCP servers and exposes their tools.

    Example:
        client = MCPClient("http://localhost:3000")

        # List available tools
        tools = await client.list_tools()
        for tool in tools:
            print(f"{tool.name}: {tool.description}")

        # Call a tool
        result = await client.call_tool("get_weather", {"city": "Portland"})
    """

    def __init__(
        self,
        server_url: str,
        api_key: str | None = None,
        *,
        allow_private: bool = True,
    ):
        """
        Args:
            server_url: URL of the MCP server.
            api_key: Optional API key for authentication.
            allow_private: Permit loopback/private/link-local hosts. Keep
                True when *server_url* is operator-controlled (local MCP
                servers are the norm). Set False when the URL can be
                influenced by model output or other untrusted content — the
                SSRF guard then rejects internal destinations.

        Raises:
            ValueError: If the URL scheme is not http(s) or it has no host.
        """
        validate_outbound_url(server_url, allow_private=allow_private)
        self.server_url = server_url.rstrip("/")
        self._client = httpx.AsyncClient(timeout=30.0)
        self._headers: dict[str, str] = {"Content-Type": "application/json"}
        if api_key:
            self._headers["Authorization"] = f"Bearer {api_key}"

        self._tools: list[Tool] = []

    async def list_tools(self) -> list[Tool]:
        """
        List available tools from the MCP server.

        Returns:
            List of Tool definitions
        """
        response = await self._client.post(
            f"{self.server_url}/list_tools",
            headers=self._headers,
            json={},
        )
        response.raise_for_status()
        data = response.json()

        self._tools = [
            Tool(
                name=t["name"],
                description=t.get("description", ""),
                input_schema=t.get("input_schema", {}),
                annotations=t.get("annotations", {}),
            )
            for t in data.get("tools", [])
        ]

        logger.info("Listed %d tools from %s", len(self._tools), self.server_url)
        return self._tools

    async def call_tool(
        self,
        name: str,
        arguments: dict[str, Any] | None = None,
    ) -> MCPToolResult:
        """
        Call a tool on the MCP server.

        Validates that the tool name exists in the cached tool list
        before sending the request.

        Args:
            name: Tool name
            arguments: Tool arguments

        Returns:
            MCPToolResult with the response

        Raises:
            ValueError: If tool name is not in the cached tool list.
        """
        # Validate tool name against cached list.
        # Skip validation if tools haven't been loaded yet (call
        # list_tools() first to enable client-side validation).
        if self._tools and not any(t.name == name for t in self._tools):
            return MCPToolResult(
                content=f"Tool not found: {name}",
                is_error=True,
            )

        payload = {
            "name": name,
            "arguments": arguments or {},
        }

        response = await self._client.post(
            f"{self.server_url}/call_tool",
            headers=self._headers,
            json=payload,
        )
        response.raise_for_status()
        data = response.json()

        return MCPToolResult(
            content=data.get("content", ""),
            is_error=data.get("is_error", False),
            metadata=data.get("metadata", {}),
        )

    async def health_check(self) -> bool:
        """Check if the MCP server is healthy."""
        try:
            response = await self._client.get(
                f"{self.server_url}/health",
                headers=self._headers,
            )
            return response.status_code == 200
        except Exception:
            return False

    async def close(self) -> None:
        """Close the client."""
        await self._client.aclose()

    async def __aenter__(self) -> MCPClient:
        return self

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

## `loopy.mcp.MCPToolResult` (class)

Result of a tool call via MCP server.

```python
@dataclass
class MCPToolResult:
    """Result of a tool call via MCP server."""

    content: str | list[dict[str, Any]]
    is_error: bool = False
    metadata: dict[str, Any] = field(default_factory=dict)
```

## `loopy.mcp.LocalMCP` (class)

Local MCP server for testing without a running server.

Registers tools locally and routes calls to handlers.

Example:
    mcp = LocalMCP()

    @mcp.tool("get_weather", "Get weather for a city")
    async def get_weather(city: str) -> str:
        return f"Sunny in {city}"

    result = await mcp.call_tool("get_weather", {"city": "Portland"})

```python
class LocalMCP:
    """
    Local MCP server for testing without a running server.

    Registers tools locally and routes calls to handlers.

    Example:
        mcp = LocalMCP()

        @mcp.tool("get_weather", "Get weather for a city")
        async def get_weather(city: str) -> str:
            return f"Sunny in {city}"

        result = await mcp.call_tool("get_weather", {"city": "Portland"})
    """

    def __init__(self):
        self._tools: dict[str, Tool] = {}
        self._handlers: dict[str, Callable[..., Awaitable[Any]]] = {}

    def tool(
        self,
        name: str,
        description: str = "",
        input_schema: dict[str, Any] | None = None,
    ) -> Callable:
        """Decorator to register a tool handler."""

        def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable:
            self._tools[name] = Tool(
                name=name,
                description=description,
                input_schema=input_schema or {},
            )
            self._handlers[name] = fn
            return fn

        return decorator

    async def list_tools(self) -> list[Tool]:
        """List registered tools."""
        return list(self._tools.values())

    async def call_tool(
        self,
        name: str,
        arguments: dict[str, Any] | None = None,
    ) -> MCPToolResult:
        """Call a registered tool."""
        if name not in self._handlers:
            return MCPToolResult(
                content=f"Tool not found: {name}",
                is_error=True,
            )

        try:
            result = await self._handlers[name](**(arguments or {}))
            return MCPToolResult(content=str(result))
        except Exception as e:
            return MCPToolResult(
                content=str(e),
                is_error=True,
            )
```

## MCPTool

> Not exported by `loopy.mcp`.


# Module `loopy.agents`

## `loopy.agents.Orchestrator` (class)

Multi-agent orchestrator.

Manages a pool of subagents and routes tasks to the appropriate one.
Supports routing and task decomposition for 2026 orchestrator-workers pattern.

Example:
    orchestrator = Orchestrator()

    # Register agents
    orchestrator.add_agent(SubAgent(
        name="researcher",
        description="Searches the web",
        handler=research_fn,
    ))

    orchestrator.add_agent(SubAgent(
        name="coder",
        description="Writes and tests code",
        tools=["execute_code"],
        handler=coder_fn,
    ))

    # Run a task with routing
    result = await orchestrator.run("Build a REST API for user management")
    print(result)

    # Or decompose first
    subtasks = await orchestrator.decompose("Build REST API with tests")
    for task in subtasks:
        result = await orchestrator.run(task.description, agent_name=task.required_agent)

```python
class Orchestrator:
    """
    Multi-agent orchestrator.

    Manages a pool of subagents and routes tasks to the appropriate one.
    Supports routing and task decomposition for 2026 orchestrator-workers pattern.

    Example:
        orchestrator = Orchestrator()

        # Register agents
        orchestrator.add_agent(SubAgent(
            name="researcher",
            description="Searches the web",
            handler=research_fn,
        ))

        orchestrator.add_agent(SubAgent(
            name="coder",
            description="Writes and tests code",
            tools=["execute_code"],
            handler=coder_fn,
        ))

        # Run a task with routing
        result = await orchestrator.run("Build a REST API for user management")
        print(result)

        # Or decompose first
        subtasks = await orchestrator.decompose("Build REST API with tests")
        for task in subtasks:
            result = await orchestrator.run(task.description, agent_name=task.required_agent)
    """

    def __init__(self, max_concurrent: int = 5, router: Router | None = None):
        self.agents: dict[str, SubAgent] = {}
        self._semaphore = asyncio.Semaphore(max_concurrent)
        self._history: list[AgentResult] = []
        self.router = router or Router()
        self.decomposer = TaskDecomposer()

    def add_agent(self, agent: SubAgent) -> None:
        """Register a subagent."""
        self.agents[agent.name] = agent
        logger.info("Added agent: %s", agent.name)

    def get_agent(self, name: str) -> SubAgent | None:
        """Get an agent by name."""
        return self.agents.get(name)

    def list_agents(self) -> list[SubAgent]:
        """List all registered agents."""
        return list(self.agents.values())

    async def route(self, task: str) -> str:
        """
        Route a task to the appropriate agent using the router.

        Args:
            task: The task description

        Returns:
            Agent name to route to
        """
        return await self.router.classify(task)

    async def decompose(self, task: str) -> list[SubTask]:
        """
        Decompose a complex task into subtasks.

        Args:
            task: The high-level task

        Returns:
            List of SubTask objects with dependencies
        """
        return await self.decomposer.decompose(task)

    async def run_decomposed(
        self,
        task: str,
        context: dict[str, Any] | None = None,
    ) -> list[AgentResult]:
        """
        Decompose and run a task, executing subtasks in dependency order.

        Args:
            task: The high-level task to decompose and execute
            context: Optional context to pass to agents

        Returns:
            List of results from each subtask
        """
        subtasks = await self.decompose(task)
        results: list[AgentResult] = []
        completed: set[str] = set()

        # Execute in dependency order
        max_iterations = len(subtasks) * 2  # Safety limit
        iteration = 0

        while len(completed) < len(subtasks) and iteration < max_iterations:
            iteration += 1

            for subtask in subtasks:
                if subtask.id in completed:
                    continue

                # Check if dependencies are met
                deps_met = all(dep in completed for dep in subtask.dependencies)
                if not deps_met:
                    continue

                # Route to appropriate agent
                agent_name = subtask.required_agent or await self.route(subtask.description)

                # Run the subtask
                result = await self.run(
                    subtask.description,
                    agent_name=agent_name,
                    context=context,
                )

                subtask.status = "completed" if result.status == AgentStatus.COMPLETED else "failed"
                subtask.result = result.output
                completed.add(subtask.id)
                results.append(result)

        return results

    async def run_all(
        self,
        task: str,
        context: dict[str, Any] | None = None,
    ) -> list[AgentResult]:
        """
        Run a task on all agents in parallel.

        Returns:
            List of results from each agent
        """
        tasks = [self._run_agent(agent, task, context or {}) for agent in self.agents.values()]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        final_results = []
        for i, result in enumerate(results):
            if isinstance(result, Exception):
                agent_name = list(self.agents.keys())[i]
                final_results.append(
                    AgentResult(
                        agent_name=agent_name,
                        status=AgentStatus.FAILED,
                        error=str(result),
                    )
                )
            else:
                final_results.append(result)

        return final_results

    async def _run_agent(
        self,
        agent: SubAgent,
        task: str,
        context: dict[str, Any],
    ) -> AgentResult:
        """Execute a single agent and return its result.

        Args:
            agent: The subagent to run.
            task: The task description.
            context: Shared context dict.

        Returns:
            An AgentResult with the outcome.
        """
        start_time = time.time()

        agent.status = AgentStatus.RUNNING

        try:
            if agent.handler:
                output = await agent.handler(task, context)
            else:
                output = f"Agent {agent.name} has no handler"

            duration_ms = (time.time() - start_time) * 1000

            result = AgentResult(
                agent_name=agent.name,
                status=AgentStatus.COMPLETED,
                output=output,
                duration_ms=duration_ms,
            )

            agent.status = AgentStatus.COMPLETED
            agent.result = result
            self._history.append(result)

            logger.info("Agent %s completed in %.0fms", agent.name, duration_ms)
            return result

        except Exception as e:
            duration_ms = (time.time() - start_time) * 1000

            result = AgentResult(
                agent_name=agent.name,
                status=AgentStatus.FAILED,
                error=str(e),
                duration_ms=duration_ms,
            )

            agent.status = AgentStatus.FAILED
            agent.result = result
            self._history.append(result)

            logger.error("Agent %s failed: %s", agent.name, e)
            return result

    def get_history(self) -> list[AgentResult]:
        """Get execution history."""
        return self._history.copy()

    def get_summary(self) -> dict[str, Any]:
        """Get a summary of all agent executions.

        Returns:
            Dict with total_agents, total_runs, completed/failed
            counts, and average duration.
        """
        return {
            "total_agents": len(self.agents),
            "total_runs": len(self._history),
            "completed": sum(1 for r in self._history if r.status == AgentStatus.COMPLETED),
            "failed": sum(1 for r in self._history if r.status == AgentStatus.FAILED),
            "avg_duration_ms": (
                sum(r.duration_ms for r in self._history) / len(self._history)
                if self._history
                else 0
            ),
        }

    async def run(
        self,
        task: str,
        agent_name: str | None = None,
        context: dict[str, Any] | None = None,
    ) -> AgentResult:
        """
        Run a task, optionally targeting a specific agent.

        If no agent specified, uses the first available agent.
        """
        # Select agent
        if agent_name:
            agent = self.agents.get(agent_name)
            if not agent:
                return AgentResult(
                    agent_name=agent_name or "unknown",
                    status=AgentStatus.FAILED,
                    error=f"Agent not found: {agent_name}",
                )
        else:
            # Use first available agent
            if not self.agents:
                return AgentResult(
                    agent_name="none",
                    status=AgentStatus.FAILED,
                    error="No agents registered",
                )
            agent = next(iter(self.agents.values()))

        # Run with concurrency control
        async with self._semaphore:
            return await self._run_agent(agent, task, context or {})
```

## `loopy.agents.Router` (class)

Task router for orchestrator-workers pattern.

Classifies input and routes to specialist agents.
Part of the 2026 orchestrator-workers workflow pattern.

Example:
    router = Router()
    router.add_rule(RoutingRule(
        pattern="research|search|find",
        agent_name="researcher",
        priority=1,
    ))
    router.add_rule(RoutingRule(
        pattern="code|implement|build",
        agent_name="coder",
        priority=2,
    ))

    agent_name = await router.classify("Research Python async patterns")
    # Returns "researcher"

```python
class Router:
    """
    Task router for orchestrator-workers pattern.

    Classifies input and routes to specialist agents.
    Part of the 2026 orchestrator-workers workflow pattern.

    Example:
        router = Router()
        router.add_rule(RoutingRule(
            pattern="research|search|find",
            agent_name="researcher",
            priority=1,
        ))
        router.add_rule(RoutingRule(
            pattern="code|implement|build",
            agent_name="coder",
            priority=2,
        ))

        agent_name = await router.classify("Research Python async patterns")
        # Returns "researcher"
    """

    def __init__(
        self,
        classify_fn: Callable[[str, list[RoutingRule]], Awaitable[str]] | None = None,
    ):
        self.rules: list[RoutingRule] = []
        self.classify_fn = classify_fn

    def add_rule(self, rule: RoutingRule) -> None:
        """Add a routing rule."""
        self.rules.append(rule)
        self.rules.sort(key=lambda r: -r.priority)

    async def classify(self, task: str) -> str:
        """
        Classify a task and return the appropriate agent name.

        Args:
            task: The task description

        Returns:
            Agent name to route to
        """
        if self.classify_fn:
            return await self.classify_fn(task, self.rules)

        # Default: pattern matching with regex
        task_lower = task.lower()

        for rule in self.rules:
            if rule._compiled.search(task_lower):
                logger.info("Routed task to %s (pattern: %s)", rule.agent_name, rule.pattern)
                return rule.agent_name

        # Fallback to first agent if no match
        if self.rules:
            return self.rules[0].agent_name

        raise ValueError("No routing rules defined and no default agent")
```

## `loopy.agents.TaskDecomposer` (class)

Decomposes complex tasks into subtasks.

Part of the 2026 orchestrator-workers workflow pattern.

Example:
    decomposer = TaskDecomposer(classify_fn=my_classifier)

    subtasks = await decomposer.decompose(
        "Build a REST API with tests and documentation"
    )

    for task in subtasks:
        print(f"{task.id}: {task.description}")

```python
class TaskDecomposer:
    """
    Decomposes complex tasks into subtasks.

    Part of the 2026 orchestrator-workers workflow pattern.

    Example:
        decomposer = TaskDecomposer(classify_fn=my_classifier)

        subtasks = await decomposer.decompose(
            "Build a REST API with tests and documentation"
        )

        for task in subtasks:
            print(f"{task.id}: {task.description}")
    """

    def __init__(self, classify_fn: Callable[[str], Awaitable[str]] | None = None):
        self.classify_fn = classify_fn

    async def decompose(self, task: str) -> list[SubTask]:
        """
        Break a task into subtasks with dependencies.

        Uses built-in pattern matching for common task types (API, research,
        generic). Override ``classify_fn`` in ``__init__`` to plug in an LLM
        or custom classifier for richer decomposition.

        Args:
            task: The high-level task to decompose

        Returns:
            List of SubTask objects with dependencies
        """
        # Simple pattern-based decomposition
        # Override classify_fn for LLM-powered decomposition
        subtasks = []
        task_lower = task.lower()

        # Detect common patterns
        if "api" in task_lower or "rest" in task_lower:
            subtasks.append(
                SubTask(
                    id="design",
                    description="Design API endpoints and data models",
                    required_agent="architect",
                )
            )
            subtasks.append(
                SubTask(
                    id="implement",
                    description="Implement API endpoints",
                    dependencies=["design"],
                    required_agent="coder",
                )
            )
            subtasks.append(
                SubTask(
                    id="test",
                    description="Write and run tests",
                    dependencies=["implement"],
                    required_agent="tester",
                )
            )
        elif "research" in task_lower or "analyze" in task_lower:
            subtasks.append(
                SubTask(
                    id="gather",
                    description="Gather information and sources",
                    required_agent="researcher",
                )
            )
            subtasks.append(
                SubTask(
                    id="analyze",
                    description="Analyze findings",
                    dependencies=["gather"],
                    required_agent="analyst",
                )
            )
            subtasks.append(
                SubTask(
                    id="synthesize",
                    description="Synthesize into report",
                    dependencies=["analyze"],
                    required_agent="writer",
                )
            )
        else:
            # Generic decomposition
            subtasks.append(
                SubTask(
                    id="plan",
                    description=f"Plan approach for: {task[:50]}...",
                    required_agent="planner",
                )
            )
            subtasks.append(
                SubTask(
                    id="execute",
                    description="Execute the plan",
                    dependencies=["plan"],
                    required_agent="executor",
                )
            )

        return subtasks
```


# Module `loopy.middleware`

## Pipeline

> Not exported by `loopy.middleware`.

## `loopy.middleware.RetryMiddleware` (class)

Auto-retry with exponential backoff.

Tracks retry count per-execution via context metadata so that
reusing the same middleware instance across multiple calls does
not leak state between runs.

Args:
    max_retries: Maximum number of retry attempts.
    base_delay: Base delay in seconds before the first retry.
    max_delay: Maximum delay cap in seconds.
    retryable_exceptions: Tuple of exception types that trigger a retry.

```python
class RetryMiddleware(Middleware):
    """Auto-retry with exponential backoff.

    Tracks retry count per-execution via context metadata so that
    reusing the same middleware instance across multiple calls does
    not leak state between runs.

    Args:
        max_retries: Maximum number of retry attempts.
        base_delay: Base delay in seconds before the first retry.
        max_delay: Maximum delay cap in seconds.
        retryable_exceptions: Tuple of exception types that trigger a retry.
    """

    def __init__(
        self,
        max_retries: int = 3,
        base_delay: float = 1.0,
        max_delay: float = 30.0,
        retryable_exceptions: tuple[type[Exception], ...] = (Exception,),
    ):
        self.max_retries = max_retries
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.retryable_exceptions = retryable_exceptions

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        """Initialize per-execution retry state."""
        ctx.metadata["_retry_count"] = 0
        return ctx

    async def on_error(self, ctx: MiddlewareContext, error: Exception) -> Exception:
        """
        Handle errors with exponential backoff retry.

        Checks the per-execution retry count stored in context
        metadata so state doesn't leak between pipeline calls.
        """
        retry_count = ctx.metadata.get("_retry_count", 0)
        if isinstance(error, self.retryable_exceptions) and retry_count < self.max_retries:
            delay = min(self.base_delay * (2**retry_count), self.max_delay)
            ctx.metadata["_retry_count"] = retry_count + 1
            logger.warning(
                "Retry %d/%d after %.1fs: %s",
                retry_count + 1,
                self.max_retries,
                delay,
                error,
            )
            await asyncio.sleep(delay)
            ctx.metadata["retry_count"] = retry_count + 1
            ctx.metadata["should_retry"] = True
            return error
        raise
```

## `loopy.middleware.CircuitBreakerMiddleware` (class)

Circuit breaker to prevent cascade failures.

Tracks failure count and opens the circuit after a threshold,
blocking requests for *recovery_timeout* seconds before
allowing a probe (half-open state).  State mutations are
protected by an asyncio lock for safe concurrent use.

Args:
    failure_threshold: Consecutive failures before opening.
    recovery_timeout: Seconds before transitioning to half-open.

```python
class CircuitBreakerMiddleware(Middleware):
    """Circuit breaker to prevent cascade failures.

    Tracks failure count and opens the circuit after a threshold,
    blocking requests for *recovery_timeout* seconds before
    allowing a probe (half-open state).  State mutations are
    protected by an asyncio lock for safe concurrent use.

    Args:
        failure_threshold: Consecutive failures before opening.
        recovery_timeout: Seconds before transitioning to half-open.
    """

    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: float = 60.0,
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self._failure_count = 0
        self._last_failure_time: float = 0
        self._state = "closed"  # closed = normal, open = blocked, half-open = testing
        self._lock = asyncio.Lock()

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        """Block request if circuit is open (unless recovery timeout elapsed)."""
        async with self._lock:
            if self._state == "open":
                if time.time() - self._last_failure_time > self.recovery_timeout:
                    self._state = "half-open"
                    logger.info("Circuit breaker: half-open state")
                else:
                    ctx.cancel(f"Circuit breaker is open (failures: {self._failure_count})")
        return ctx

    async def after(self, ctx: MiddlewareContext, result: Any) -> Any:
        """Reset failure count on success."""
        async with self._lock:
            if self._state == "half-open":
                self._state = "closed"
                logger.info("Circuit breaker: closed (recovered)")
            self._failure_count = 0
        return result

    async def on_error(self, ctx: MiddlewareContext, error: Exception) -> Exception:
        """Increment failure count; open circuit if threshold reached."""
        async with self._lock:
            self._failure_count += 1
            self._last_failure_time = time.time()

            if self._failure_count >= self.failure_threshold:
                self._state = "open"
                logger.warning("Circuit breaker: open (failures: %d)", self._failure_count)

        return error
```

## `loopy.middleware.FallbackMiddleware` (class)

Provider failover middleware.

Returns a fallback result (from a callable or static data)
when the primary handler raises an exception.

Args:
    fallback_fn: Async callable ``(ctx, error) -> result``.
    fallback_data: Static dict to return as fallback result.

```python
class FallbackMiddleware(Middleware):
    """Provider failover middleware.

    Returns a fallback result (from a callable or static data)
    when the primary handler raises an exception.

    Args:
        fallback_fn: Async callable ``(ctx, error) -> result``.
        fallback_data: Static dict to return as fallback result.
    """

    def __init__(
        self,
        fallback_fn: Callable[[MiddlewareContext, Any], Awaitable[Any]] | None = None,
        fallback_data: dict[str, Any] | None = None,
    ):
        self.fallback_fn = fallback_fn
        self.fallback_data = fallback_data

    async def on_error(self, ctx: MiddlewareContext, error: Exception) -> Exception:
        """Attempt fallback when the primary handler fails."""
        if self.fallback_fn:
            try:
                result = await self.fallback_fn(ctx, error)
                ctx.metadata["fallback_result"] = result
                ctx.metadata["fallback_used"] = True
                logger.info("Fallback used for %s", ctx.operation)
            except Exception as fallback_error:
                logger.error("Fallback also failed: %s", fallback_error)
                return error
        elif self.fallback_data:
            ctx.metadata["fallback_result"] = self.fallback_data
            ctx.metadata["fallback_used"] = True

        return error
```

## `loopy.middleware.CacheMiddleware` (class)

Cache middleware for identical requests.

Caches responses and short-circuits duplicate requests
within the TTL window.

Args:
    ttl: Time-to-live in seconds for cached entries.

```python
class CacheMiddleware(Middleware):
    """Cache middleware for identical requests.

    Caches responses and short-circuits duplicate requests
    within the TTL window.

    Args:
        ttl: Time-to-live in seconds for cached entries.
    """

    def __init__(self, ttl: int = 60):
        self.ttl = ttl
        self._cache: dict[str, tuple[float, Any]] = {}

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        """Check cache and short-circuit on hit."""
        # Create cache key (SHA-256 instead of MD5 to avoid scanner flags)
        key_data = json.dumps(ctx.data, sort_keys=True, default=str)
        cache_key = hashlib.sha256(key_data.encode()).hexdigest()

        # Check cache
        if cache_key in self._cache:
            timestamp, cached_result = self._cache[cache_key]
            if time.time() - timestamp < self.ttl:
                ctx.metadata["cached"] = True
                ctx.metadata["cached_result"] = cached_result
                ctx.cancel("Cache hit")
            else:
                del self._cache[cache_key]

        ctx.metadata["cache_key"] = cache_key
        return ctx

    async def after(self, ctx: MiddlewareContext, result: Any) -> Any:
        """Store result in cache after successful execution."""
        if not ctx.metadata.get("cached"):
            cache_key = ctx.metadata.get("cache_key")
            if cache_key:
                self._cache[cache_key] = (time.time(), result)
        return result
```

## `loopy.middleware.LoggingMiddleware` (class)

Logs all operations.

```python
class LoggingMiddleware(Middleware):
    """Logs all operations."""

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        logger.info("[%s] Starting with %d data fields", ctx.operation, len(ctx.data))
        return ctx

    async def after(self, ctx: MiddlewareContext, result: Any) -> Any:
        logger.info("[%s] Completed", ctx.operation)
        return result

    async def on_error(self, ctx: MiddlewareContext, error: Exception) -> Exception:
        logger.error("[%s] Failed: %s", ctx.operation, error)
        return error
```

## `loopy.middleware.TimingMiddleware` (class)

Tracks operation timing.

```python
class TimingMiddleware(Middleware):
    """Tracks operation timing."""

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        ctx.metadata["start_time"] = time.time()
        return ctx

    async def after(self, ctx: MiddlewareContext, result: Any) -> Any:
        start = ctx.metadata.get("start_time")
        if start:
            elapsed_ms = (time.time() - start) * 1000
            ctx.metadata["elapsed_ms"] = elapsed_ms
            logger.debug("[%s] Took %.1fms", ctx.operation, elapsed_ms)
        return result
```

## `loopy.middleware.ValidationMiddleware` (class)

Validates data before processing.

```python
class ValidationMiddleware(Middleware):
    """Validates data before processing."""

    def __init__(
        self,
        required_fields: list[str] | None = None,
        validators: dict[str, Callable[[Any], bool]] | None = None,
    ):
        self.required_fields = required_fields or []
        self.validators = validators or {}

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        # Check required fields
        for field_name in self.required_fields:
            if field_name not in ctx.data:
                ctx.cancel(f"Missing required field: {field_name}")
                return ctx

        # Run validators
        for field_name, validator in self.validators.items():
            if field_name in ctx.data and not validator(ctx.data[field_name]):
                ctx.cancel(f"Validation failed for field: {field_name}")
                return ctx

        return ctx
```

## `loopy.middleware.RateLimitMiddleware` (class)

Simple rate limiter.

```python
class RateLimitMiddleware(Middleware):
    """Simple rate limiter."""

    def __init__(self, max_per_second: int = 10):
        self.max_per_second = max_per_second
        self._timestamps: list[float] = []

    async def before(self, ctx: MiddlewareContext) -> MiddlewareContext:
        now = time.time()

        # Remove old timestamps
        self._timestamps = [t for t in self._timestamps if now - t < 1.0]

        if len(self._timestamps) >= self.max_per_second:
            ctx.cancel(f"Rate limit exceeded: {self.max_per_second}/sec")
        else:
            self._timestamps.append(now)

        return ctx
```


# Module `loopy.plugins`

## `loopy.plugins.Plugin` (class)

Base plugin class.

All plugins must inherit from this and implement `setup()`.

Example:
    class MyPlugin(Plugin):
        @property
        def info(self) -> PluginInfo:
            return PluginInfo(
                name="my-plugin",
                version="1.0.0",
                description="My awesome plugin",
            )

        async def setup(self, registry: PluginRegistry) -> None:
            # Register tools, middleware, etc.
            registry.register_tool("my_tool", my_tool_handler)

```python
class Plugin(ABC):
    """
    Base plugin class.

    All plugins must inherit from this and implement `setup()`.

    Example:
        class MyPlugin(Plugin):
            @property
            def info(self) -> PluginInfo:
                return PluginInfo(
                    name="my-plugin",
                    version="1.0.0",
                    description="My awesome plugin",
                )

            async def setup(self, registry: PluginRegistry) -> None:
                # Register tools, middleware, etc.
                registry.register_tool("my_tool", my_tool_handler)
    """

    @property
    @abstractmethod
    def info(self) -> PluginInfo:
        """Return plugin metadata."""
        ...

    @abstractmethod
    async def setup(self, registry: PluginRegistry) -> None:
        """Initialize the plugin."""
        ...

    async def teardown(self) -> None:  # noqa: B027
        """Cleanup when plugin is unloaded."""
```

## `loopy.plugins.PluginInfo` (class)

Metadata about a plugin.

```python
@dataclass
class PluginInfo:
    """Metadata about a plugin."""

    name: str
    version: str = "0.1.0"
    description: str = ""
    author: str = ""
    url: str = ""

    # Capabilities this plugin provides
    capabilities: list[str] = field(default_factory=list)

    # Dependencies
    requires: list[str] = field(default_factory=list)
```

## `loopy.plugins.PluginLoader` (class)

Automatic plugin discovery and loading.

Example:
    loader = PluginLoader()

    # Discover plugins from entry points
    await loader.discover()

    # Or from specific locations
    await loader.discover(
        package="my_package.plugins",
        directory="~/.loopy/plugins",
    )

```python
class PluginLoader:
    """
    Automatic plugin discovery and loading.

    Example:
        loader = PluginLoader()

        # Discover plugins from entry points
        await loader.discover()

        # Or from specific locations
        await loader.discover(
            package="my_package.plugins",
            directory="~/.loopy/plugins",
        )
    """

    def __init__(self, registry: PluginRegistry | None = None):
        self.registry = registry or PluginRegistry()

    async def discover(
        self,
        package: str | None = None,
        directory: str | Path | None = None,
    ) -> int:
        """
        Discover and load plugins.

        Returns:
            Number of plugins loaded
        """
        loaded = 0

        # Load from package
        if package:
            try:
                mod = importlib.import_module(package)
                plugins_attr = getattr(mod, "__plugins__", [])
                for plugin_cls in plugins_attr:
                    if isinstance(plugin_cls, type) and issubclass(plugin_cls, Plugin):
                        await self.registry.load(plugin_cls())
                        loaded += 1
            except ImportError as e:
                logger.warning("Could not import %s: %s", package, e)

        # Load from directory
        if directory:
            loaded += await self.registry.load_directory(directory)

        return loaded
```

## `loopy.plugins.PluginRegistry` (class)

Central registry for plugins and their components.

Example:
    registry = PluginRegistry()

    # Load plugins
    await registry.load(MyPlugin())
    await registry.load_package("loopy.plugins.anthropic")

    # Use registered components
    tool = registry.get_tool("my_tool")
    middleware = registry.get_middleware("cache")

```python
class PluginRegistry:
    """
    Central registry for plugins and their components.

    Example:
        registry = PluginRegistry()

        # Load plugins
        await registry.load(MyPlugin())
        await registry.load_package("loopy.plugins.anthropic")

        # Use registered components
        tool = registry.get_tool("my_tool")
        middleware = registry.get_middleware("cache")
    """

    def __init__(self):
        self._plugins: dict[str, Plugin] = {}
        self._tools: dict[str, Callable] = {}
        self._tool_specs: dict[str, dict[str, Any]] = {}
        self._middleware: dict[str, Any] = {}
        self._providers: dict[str, Any] = {}
        self._extensions: dict[str, list[Callable]] = {}
        self._denials: deque = deque(maxlen=DENIAL_LOG_MAX)

    async def load(self, plugin: Plugin) -> None:
        """Load a plugin instance."""
        info = plugin.info

        if info.name in self._plugins:
            logger.warning("Plugin %s already loaded, skipping", info.name)
            return

        # Check dependencies
        for dep in info.requires:
            if dep not in self._plugins:
                raise RuntimeError(f"Plugin {info.name} requires {dep}, which is not loaded")

        # Load the plugin
        await plugin.setup(self)
        self._plugins[info.name] = plugin

        logger.info("Loaded plugin: %s v%s", info.name, info.version)

    async def load_package(self, module_path: str) -> None:
        """
        Load a plugin from a Python module path.

        The module must have a `plugin` attribute that is a Plugin instance.

        Example:
            await registry.load_package("my_package.my_plugin")
        """
        try:
            module = importlib.import_module(module_path)
            plugin_instance = getattr(module, "plugin", None)

            if plugin_instance is None:
                raise ValueError(f"No 'plugin' attribute in {module_path}")

            if not isinstance(plugin_instance, Plugin):
                raise TypeError(f"'plugin' in {module_path} is not a Plugin instance")

            await self.load(plugin_instance)

        except ImportError as e:
            logger.error("Failed to import %s: %s", module_path, e)
            raise

    async def load_directory(self, directory: str | Path) -> int:
        """
        Load all plugins from a directory.

        Looks for Python files with a `plugin` attribute.

        Returns:
            Number of plugins loaded
        """
        directory = Path(directory)
        loaded = 0

        if not directory.exists():
            logger.warning("Plugin directory not found: %s", directory)
            return 0

        for py_file in directory.glob("*.py"):
            if py_file.name.startswith("_"):
                continue

            module_name = py_file.stem
            try:
                spec = importlib.util.spec_from_file_location(
                    f"loopy_plugins.{module_name}",
                    py_file,
                )
                if spec and spec.loader:
                    module = importlib.util.module_from_spec(spec)
                    spec.loader.exec_module(module)

                    plugin_instance = getattr(module, "plugin", None)
                    if plugin_instance and isinstance(plugin_instance, Plugin):
                        await self.load(plugin_instance)
                        loaded += 1
            except Exception as e:
                logger.error("Failed to load plugin from %s: %s", py_file, e)

        return loaded

    def register_tool(
        self,
        name: str,
        handler: Callable,
        *,
        agent_visible: bool = True,
        requires_approval: bool = False,
        scope: str = "side_effecting",
        allowed_values: dict[str, set[str]] | None = None,
    ) -> None:
        """Register a tool handler.

        Args:
            name: The tool name.
            handler: The callable to invoke.
            agent_visible: If False, the tool is hidden from :meth:`list_tools`
                and is intended for operator callers only (the model cannot
                discover it). Defaults True.
            requires_approval: If True, :meth:`execute_tool` demands a human
                approver before running (deny-by-default otherwise).
            scope: ``"read_only"`` or ``"side_effecting"``.
            allowed_values: Per-parameter allow-lists (enum constraints)
                enforced by :meth:`execute_tool`.
        """
        self._tools[name] = handler
        self._tool_specs[name] = {
            "agent_visible": agent_visible,
            "requires_approval": requires_approval,
            "scope": scope,
            "allowed_values": allowed_values or {},
        }
        logger.debug("Registered tool: %s (visible=%s, scope=%s)", name, agent_visible, scope)

    def get_tool(self, name: str) -> Callable | None:
        """Get a registered tool handler."""
        return self._tools.get(name)

    def get_tool_spec(self, name: str) -> dict[str, Any] | None:
        """Get a registered tool's capability spec (security metadata)."""
        return self._tool_specs.get(name)

    def list_tools(self) -> list[str]:
        """List agent-visible tool names (hidden/operator tools excluded)."""
        return [name for name, spec in self._tool_specs.items() if spec["agent_visible"]]

    def list_all_tools(self) -> list[str]:
        """List every registered tool name, visible or not."""
        return list(self._tools.keys())

    def denials(self) -> list[dict[str, Any]]:
        """Audit trail of denied/blocked tool executions.

        Bounded to ``DENIAL_LOG_MAX`` entries (oldest dropped first);
        secret-looking argument values are redacted.
        """
        return list(self._denials)

    async def execute_tool(
        self,
        name: str,
        arguments: dict[str, Any] | None = None,
        *,
        approver: Callable[[str, dict[str, Any]], Awaitable[bool]] | None = None,
    ) -> Any:
        """Execute a registered tool with capability-gate enforcement.

        Enforces tool existence, per-parameter allow-lists, and the
        human-in-the-loop approval gate (a ``requires_approval`` tool is
        denied unless an *approver* approves). Denials are recorded.

        Args:
            name: The tool to execute.
            arguments: Keyword arguments for the handler.
            approver: Optional async callback ``(name, arguments) -> bool``.

        Returns:
            The handler's return value.

        Raises:
            PermissionError: If the call requires approval and none is given.
            ValueError: If an argument falls outside its allow-list.
        """
        handler = self._tools.get(name)
        if handler is None:
            self._denials.append({"tool": name, "reason": "not_found"})
            raise ValueError(f"Tool not found: {name}")

        spec = self._tool_specs.get(name, {})
        arguments = arguments or {}

        if spec.get("requires_approval"):
            if approver is None:
                self._denials.append(
                    {
                        "tool": name,
                        "reason": "approval_required_no_approver",
                        "arguments": redact_arguments(arguments),
                    }
                )
                raise PermissionError(
                    f"Tool '{name}' requires approval and no approver is configured"
                )
            approved = await approver(name, arguments)
            if not approved:
                self._denials.append(
                    {
                        "tool": name,
                        "reason": "approval_denied",
                        "arguments": redact_arguments(arguments),
                    }
                )
                raise PermissionError(f"Tool '{name}' was not approved")

        allowed = spec.get("allowed_values") or {}
        for param, values in allowed.items():
            value = arguments.get(param)
            if value is not None and value not in values:
                self._denials.append({"tool": name, "reason": f"parameter '{param}' out of range"})
                raise ValueError(f"Parameter '{param}' outside allowed values")

        return await handler(**arguments)

    def register_middleware(self, name: str, middleware: Any) -> None:
        """Register middleware."""
        self._middleware[name] = middleware
        logger.debug("Registered middleware: %s", name)

    def get_middleware(self, name: str) -> Any:
        """Get registered middleware."""
        return self._middleware.get(name)

    def register_provider(self, name: str, provider: Any) -> None:
        """Register an LLM provider."""
        self._providers[name] = provider
        logger.debug("Registered provider: %s", name)

    def get_provider(self, name: str) -> Any:
        """Get a registered provider."""
        return self._providers.get(name)

    def register_extension(self, hook_name: str, callback: Callable) -> None:
        """Register an extension hook."""
        if hook_name not in self._extensions:
            self._extensions[hook_name] = []
        self._extensions[hook_name].append(callback)
        logger.debug("Registered extension for hook: %s", hook_name)

    async def trigger_extension(self, hook_name: str, *args: Any, **kwargs: Any) -> list[Any]:
        """Trigger all callbacks for a hook."""
        results = []
        for callback in self._extensions.get(hook_name, []):
            try:
                if callable(callback):
                    result = await callback(*args, **kwargs)
                else:
                    result = callback(*args, **kwargs)
                results.append(result)
            except Exception as e:
                logger.error("Extension hook %s failed: %s", hook_name, e)
        return results

    def get_plugin(self, name: str) -> Plugin | None:
        """Get a loaded plugin."""
        return self._plugins.get(name)

    def list_plugins(self) -> list[PluginInfo]:
        """List all loaded plugins."""
        return [p.info for p in self._plugins.values()]

    async def unload(self, name: str) -> bool:
        """Unload a plugin."""
        if name not in self._plugins:
            return False

        plugin = self._plugins[name]
        await plugin.teardown()
        del self._plugins[name]

        logger.info("Unloaded plugin: %s", name)
        return True

    async def unload_all(self) -> None:
        """Unload all plugins."""
        for name in list(self._plugins.keys()):
            await self.unload(name)
```


# Module `loopy.state`

## `loopy.state.StateManager` (class)

Read/write loop state to disk.

Example:
    manager = StateManager("./loop-state.json")
    state = manager.load()
    state.current_task = "Fix CI"
    manager.save(state)

```python
class StateManager:
    """
    Read/write loop state to disk.

    Example:
        manager = StateManager("./loop-state.json")
        state = manager.load()
        state.current_task = "Fix CI"
        manager.save(state)
    """

    def __init__(self, path: str = "./loop-state.json"):
        self.path = Path(path)

    def load(self) -> LoopState:
        """Load state from disk, or return empty state."""
        if not self.path.exists():
            return LoopState()

        try:
            data = json.loads(self.path.read_text())
            return LoopState.from_dict(data)
        except Exception as e:
            logger.warning("Failed to load state: %s", e)
            return LoopState()

    def save(self, state: LoopState) -> None:
        """Save state to disk."""
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.path.write_text(json.dumps(state.to_dict(), indent=2))

    def prune(self, max_age_days: int = 30) -> int:
        """
        Remove records older than max_age_days.

        Returns:
            Number of records pruned
        """
        state = self.load()
        cutoff = datetime.now() - timedelta(days=max_age_days)

        original_count = len(state.history)
        state.history = [r for r in state.history if _parse_timestamp(r.timestamp) >= cutoff]
        pruned = original_count - len(state.history)

        if pruned > 0:
            self.save(state)
            logger.info("Pruned %d old records", pruned)

        return pruned
```

## `loopy.state.LoopState` (class)

Durable state for an agent loop.

```python
@dataclass
class LoopState:
    """Durable state for an agent loop."""

    current_task: str | None = None
    attempts: int = 0
    max_attempts: int = 5
    history: list[RunRecord] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)

    @property
    def total_tokens(self) -> int:
        return sum(r.tokens_used for r in self.history)

    @property
    def last_run(self) -> RunRecord | None:
        return self.history[-1] if self.history else None

    def add_record(self, record: RunRecord) -> None:
        self.history.append(record)

    def to_dict(self) -> dict[str, Any]:
        return {
            "current_task": self.current_task,
            "attempts": self.attempts,
            "max_attempts": self.max_attempts,
            "history": [r.to_dict() for r in self.history],
            "metadata": self.metadata,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> LoopState:
        return cls(
            current_task=data.get("current_task"),
            attempts=data.get("attempts", 0),
            max_attempts=data.get("max_attempts", 5),
            history=[RunRecord.from_dict(r) for r in data.get("history", [])],
            metadata=data.get("metadata", {}),
        )
```

## `loopy.state.RunRecord` (class)

Record of a single loop run.

```python
@dataclass
class RunRecord:
    """Record of a single loop run."""

    task: str
    outcome: RunOutcome
    tokens_used: int = 0
    duration_ms: float = 0
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
    metadata: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "task": self.task,
            "outcome": self.outcome.value,
            "tokens_used": self.tokens_used,
            "duration_ms": self.duration_ms,
            "timestamp": self.timestamp,
            "metadata": self.metadata,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> RunRecord:
        return cls(
            task=data["task"],
            outcome=RunOutcome(data["outcome"]),
            tokens_used=data.get("tokens_used", 0),
            duration_ms=data.get("duration_ms", 0),
            timestamp=data.get("timestamp", ""),
            metadata=data.get("metadata", {}),
        )
```

## `loopy.state.RunOutcome` (class)

Outcome of a loop run.

```python
class RunOutcome(str, Enum):
    """Outcome of a loop run."""

    SUCCESS = "success"
    FAILURE = "failure"
    ESCALATED = "escalated"
    # v0.8.0 — loop paused for human-in-the-loop review.
    INTERRUPTED = "interrupted"
```


# Module `loopy.safety`

## `loopy.safety.SafetyGate` (class)

Production safety checks for agent loops.

Example:
    gate = SafetyGate(denylist_paths=["secrets/*", ".env*"])
    result = await gate.check(path="src/main.py", attempts=1, confidence=0.9)
    if result.safe:
        proceed()

```python
class SafetyGate:
    """
    Production safety checks for agent loops.

    Example:
        gate = SafetyGate(denylist_paths=["secrets/*", ".env*"])
        result = await gate.check(path="src/main.py", attempts=1, confidence=0.9)
        if result.safe:
            proceed()
    """

    DEFAULT_DENYLIST = [
        "src/auth/*",
        "src/payments/*",
        ".env*",
        "secrets/*",
        "*.pem",
        "*.key",
        "credentials/*",
    ]

    def __init__(
        self,
        denylist_paths: list[str] | None = None,
        max_attempts: int = 3,
        human_gate_threshold: float = 0.7,
    ):
        self.denylist_paths = denylist_paths or self.DEFAULT_DENYLIST
        self.max_attempts = max_attempts
        self.human_gate_threshold = human_gate_threshold

    async def check_path(self, path: str) -> SafetyCheck:
        """Check if path is in denylist."""
        for pattern in self.denylist_paths:
            if fnmatch.fnmatch(path, pattern):
                return SafetyCheck(
                    name="path_check",
                    passed=False,
                    reason=f"Path in denylist: {pattern}",
                    escalation=EscalationReason.DENYLIST_PATH,
                )

        return SafetyCheck(
            name="path_check",
            passed=True,
            reason="Path not in denylist",
        )

    def should_escalate(self, attempts: int, confidence: float, path_safe: bool = True) -> bool:
        """Determine if human escalation is needed."""
        if not path_safe:
            return True
        if attempts >= self.max_attempts:
            return True
        return confidence < self.human_gate_threshold

    async def check(
        self,
        path: str | None = None,
        attempts: int = 0,
        confidence: float = 1.0,
    ) -> SafetyResult:
        """
        Full safety check.

        Args:
            path: File path to check
            attempts: Number of attempts so far
            confidence: Confidence score (0-1)

        Returns:
            SafetyResult with safety status
        """
        checks: list[SafetyCheck] = []

        # Path check
        if path:
            path_check = await self.check_path(path)
            checks.append(path_check)

        # Attempt check
        attempts_ok = attempts < self.max_attempts
        checks.append(
            SafetyCheck(
                name="attempts_check",
                passed=attempts_ok,
                reason=f"Attempts: {attempts}/{self.max_attempts}",
            )
        )

        # Confidence check
        confidence_ok = confidence >= self.human_gate_threshold
        checks.append(
            SafetyCheck(
                name="confidence_check",
                passed=confidence_ok,
                reason=f"Confidence: {confidence:.2f} (threshold: {self.human_gate_threshold})",
            )
        )

        path_safe = all(c.passed for c in checks if c.name == "path_check")
        safe = all(c.passed for c in checks)
        should_escalate = self.should_escalate(attempts, confidence, path_safe)

        return SafetyResult(
            safe=safe,
            checks=checks,
            should_escalate=should_escalate,
        )
```

## `loopy.safety.SafetyCheck` (class)

Result of a single safety check.

```python
@dataclass
class SafetyCheck:
    """Result of a single safety check."""

    name: str
    passed: bool
    reason: str = ""
    escalation: EscalationReason | None = None
```

## `loopy.safety.SafetyResult` (class)

Overall safety check result.

```python
@dataclass
class SafetyResult:
    """Overall safety check result."""

    safe: bool
    checks: list[SafetyCheck]
    should_escalate: bool = False
```

## `loopy.safety.EscalationReason` (class)

Why escalation is needed.

```python
class EscalationReason(str, Enum):
    """Why escalation is needed."""

    MAX_ATTEMPTS = "max_attempts"
    DENYLIST_PATH = "denylist_path"
    LOW_CONFIDENCE = "low_confidence"
    AMBIGUOUS_INPUT = "ambiguous_input"
```

## PermissionMode

> Not exported by `loopy.safety`.


# Module `loopy.tools`

## ToolDef

> Could not import `loopy.tools.ToolDef`: No module named 'loopy.tools'

## ToolContext

> Could not import `loopy.tools.ToolContext`: No module named 'loopy.tools'

## ToolCall

> Could not import `loopy.tools.ToolCall`: No module named 'loopy.tools'

## ToolExecutor

> Could not import `loopy.tools.ToolExecutor`: No module named 'loopy.tools'

## ToolParamSchema

> Could not import `loopy.tools.ToolParamSchema`: No module named 'loopy.tools'


# Module `loopy.cost`

## `loopy.cost.CostTracker` (class)

Track and limit token spending.

Example:
    tracker = CostTracker(daily_limit=10000)
    tracker.record(500)
    report = tracker.report()
    print(f"Used: {report.used}/{report.limit}")

```python
class CostTracker:
    """
    Track and limit token spending.

    Example:
        tracker = CostTracker(daily_limit=10000)
        tracker.record(500)
        report = tracker.report()
        print(f"Used: {report.used}/{report.limit}")
    """

    def __init__(
        self,
        daily_limit: int = 10000,
        persist_path: str | None = None,
    ):
        self.daily_limit = daily_limit
        self.persist_path = Path(persist_path) if persist_path else None
        self._usage: dict[str, int] = {}
        # v0.9.0 — USD totals across the run (not persisted; resets
        # on process restart). The token ``_usage`` dict is keyed by
        # day; the USD totals are session-scoped.
        self._estimated_usd: float = 0.0
        self._actual_usd: float = 0.0
        self._savings_usd: float = 0.0

        if self.persist_path and self.persist_path.exists():
            self._load()

    @property
    def used_today(self) -> int:
        """Tokens used today."""
        today = date.today().isoformat()
        return self._usage.get(today, 0)

    @property
    def remaining(self) -> int:
        """Tokens remaining today."""
        return max(0, self.daily_limit - self.used_today)

    @property
    def should_stop(self) -> bool:
        """Whether budget is exceeded."""
        return self.remaining <= 0

    def record(self, tokens: int) -> None:
        """Record token usage."""
        today = date.today().isoformat()
        self._usage[today] = self._usage.get(today, 0) + tokens

        if self.persist_path:
            self._save()

        if self.should_stop:
            logger.warning("Budget exceeded: %s/%s", self.used_today, self.daily_limit)

    # ── v0.9.0 — Cost-Aware Routing ───────────────────────────

    def record_estimated(self, usd: float) -> None:
        """Record the estimated USD cost of a planned call."""
        self._estimated_usd += float(usd)

    def record_actual(
        self,
        usd: float,
        *,
        savings_from_fallback: float = 0.0,
    ) -> None:
        """Record the actual USD cost of a completed call.

        ``savings_from_fallback`` is the dollar amount the routing
        decision saved vs. the originally-requested provider (when
        the gateway fell back to a cheaper option).
        """
        self._actual_usd += float(usd)
        self._savings_usd += float(savings_from_fallback)

    def report(self) -> CostReport:
        """Generate cost report."""
        used = self.used_today
        return CostReport(
            used=used,
            limit=self.daily_limit,
            remaining=max(0, self.daily_limit - used),
            usage_percent=(used / self.daily_limit * 100) if self.daily_limit > 0 else 0,
            estimated_usd=self._estimated_usd,
            actual_usd=self._actual_usd,
            savings_usd=self._savings_usd,
        )

    def reset(self) -> None:
        """Reset daily usage."""
        self._usage.clear()
        self._estimated_usd = 0.0
        self._actual_usd = 0.0
        self._savings_usd = 0.0
        if self.persist_path:
            self._save()

    def _save(self) -> None:
        """Save usage to disk."""
        if not self.persist_path:
            return
        self.persist_path.parent.mkdir(parents=True, exist_ok=True)
        self.persist_path.write_text(json.dumps(self._usage, indent=2))

    def _load(self) -> None:
        """Load usage from disk."""
        if not self.persist_path or not self.persist_path.exists():
            return
        try:
            self._usage = json.loads(self.persist_path.read_text())
        except Exception as e:
            logger.warning("Failed to load cost data: %s", e)
            self._usage = {}
```

## `loopy.cost.CostReport` (class)

Report of token usage and (v0.9.0) USD cost.

```python
@dataclass
class CostReport:
    """Report of token usage and (v0.9.0) USD cost."""

    used: int
    limit: int
    remaining: int
    usage_percent: float
    # v0.9.0 — Cost-Aware Routing. All four USD fields are 0.0 by
    # default for the v0.7.x token-only callers; callers that
    # opt in to USD tracking see them populated.
    estimated_usd: float = 0.0
    actual_usd: float = 0.0
    savings_usd: float = 0.0

    def summary(self) -> dict[str, Any]:
        return {
            "used": self.used,
            "limit": self.limit,
            "remaining": self.remaining,
            "usage_percent": self.usage_percent,
            "estimated_usd": self.estimated_usd,
            "actual_usd": self.actual_usd,
            "savings_usd": self.savings_usd,
        }
```

## `loopy.cost.BudgetExceeded` (class)

Raised when token budget is exceeded.

```python
class BudgetExceeded(Exception):
    """Raised when token budget is exceeded."""

    def __init__(self, limit: int, used: int):
        self.limit = limit
        self.used = used
        super().__init__(f"Budget exceeded: {used}/{limit} tokens")
```


# Module `loopy.drift`

## `loopy.drift.DriftDetector` (class)

Detect drift between config and state.

Example:
    detector = DriftDetector()
    report = await detector.check(config, state)
    if report.drifted:
        print(f"Drift detected: {len(report.issues)} issues")

```python
class DriftDetector:
    """
    Detect drift between config and state.

    Example:
        detector = DriftDetector()
        report = await detector.check(config, state)
        if report.drifted:
            print(f"Drift detected: {len(report.issues)} issues")
    """

    async def check(
        self,
        config: dict[str, Any],
        state: dict[str, Any],
    ) -> DriftReport:
        """
        Check for drift between config and state.

        Args:
            config: Loop configuration
            state: Runtime state

        Returns:
            DriftReport with any drift issues found
        """
        issues: list[DriftIssue] = []
        suggestions: list[str] = []

        # Check max_steps drift
        config_max = config.get("max_steps")
        state_max = state.get("max_steps")
        if config_max is not None and state_max is not None and config_max != state_max:
            issues.append(
                DriftIssue(
                    component="max_steps",
                    expected=str(config_max),
                    actual=str(state_max),
                    severity="error",
                )
            )
            suggestions.append(f"Align max_steps: config={config_max}, state={state_max}")

        # Check attempts vs max
        attempts = state.get("attempts", 0)
        max_attempts = config.get("max_attempts") or state.get("max_attempts", 5)
        if attempts >= max_attempts:
            issues.append(
                DriftIssue(
                    component="attempts",
                    expected=f"< {max_attempts}",
                    actual=str(attempts),
                    severity="warning",
                )
            )
            suggestions.append(
                f"Reset attempts or increase max_attempts (currently {attempts}/{max_attempts})"
            )

        # Check required callbacks
        for callback in ["planner", "actor", "observer", "reflector"]:
            if config.get(callback) is not None and callback not in state:
                issues.append(
                    DriftIssue(
                        component=callback,
                        expected="present in state",
                        actual="missing from state",
                        severity="warning",
                    )
                )
                suggestions.append(f"Register callback '{callback}' in state for tracking")

        # Check state has required fields
        required_fields = ["current_task", "attempts", "history"]
        for field_name in required_fields:
            if field_name not in state:
                issues.append(
                    DriftIssue(
                        component=field_name,
                        expected="present",
                        actual="missing",
                        severity="error",
                    )
                )
                suggestions.append(f"Add '{field_name}' to state for better tracking")

        drifted = any(i.severity == "error" for i in issues)

        return DriftReport(
            drifted=drifted,
            issues=issues,
            suggestions=suggestions,
        )
```

## `loopy.drift.DriftIssue` (class)

A single drift issue.

```python
@dataclass
class DriftIssue:
    """A single drift issue."""

    component: str
    expected: str
    actual: str
    severity: str = "warning"  # "warning" or "error"
```

## `loopy.drift.DriftReport` (class)

Report of drift between config and state.

```python
@dataclass
class DriftReport:
    """Report of drift between config and state."""

    drifted: bool
    issues: list[DriftIssue]
    suggestions: list[str]

    def summary(self) -> dict[str, Any]:
        return {
            "drifted": self.drifted,
            "error_count": sum(1 for i in self.issues if i.severity == "error"),
            "warning_count": sum(1 for i in self.issues if i.severity == "warning"),
            "issues": [
                {"component": i.component, "expected": i.expected, "actual": i.actual}
                for i in self.issues
            ],
            "suggestions": self.suggestions,
        }
```


# Module `loopy.skills`

## `loopy.skills.Skill` (class)

Persistent agent knowledge.

```python
@dataclass
class Skill:
    """Persistent agent knowledge."""

    name: str
    description: str
    instructions: str
    triggers: list[str] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)

    def matches(self, task: str) -> bool:
        """Check if task matches any trigger.

        Multi-word triggers require ALL words to appear in the task.
        Single-word triggers require a whole-word match (word boundary)
        to avoid false positives from substrings.
        """
        return self.score(task) > 0.0

    def score(self, task: str) -> float:
        """v0.7.8 — Return a relevance score in [0.0, 1.0+].

        Scoring rules:
        - Each multi-word trigger matched contributes +1.0 if every word is
          present in the task, +0.5 if only some words are present.
        - Each single-word trigger matched (whole-word) contributes +0.5.
        - Result is normalized by the number of triggers so a skill with
          many triggers doesn't dominate simply by volume. Final score is
          clamped at 1.0.
        """
        if not self.triggers:
            return 0.0

        task_lower = task.lower()
        total = 0.0

        for trigger in self.triggers:
            trigger_words = trigger.lower().split()
            if len(trigger_words) > 1:
                hits = sum(1 for w in trigger_words if w in task_lower)
                if hits == len(trigger_words):
                    total += 1.0
                elif hits > 0:
                    total += 0.5 * (hits / len(trigger_words))
            else:
                if re.search(r"\b" + re.escape(trigger_words[0]) + r"\b", task_lower):
                    total += 0.5

        return min(total / max(len(self.triggers), 1), 1.0)

    @classmethod
    def from_markdown(cls, content: str) -> Skill:
        """Parse skill from markdown content."""
        lines = content.strip().split("\n")

        # Extract title
        name = "Unnamed Skill"
        description = ""
        instructions = ""
        triggers: list[str] = []

        section = None
        section_content: list[str] = []

        for line in lines:
            if line.startswith("# "):
                name = line[2:].strip()
            elif line.startswith("## Purpose"):
                if section == "triggers":
                    triggers = _extract_triggers(section_content)
                elif section == "instructions":
                    instructions = "\n".join(section_content).strip()
                section = "purpose"
                section_content = []
            elif line.startswith("## Triggers"):
                if section == "purpose":
                    description = "\n".join(section_content).strip()
                elif section == "instructions":
                    instructions = "\n".join(section_content).strip()
                section = "triggers"
                section_content = []
            elif line.startswith("## Instructions"):
                if section == "purpose":
                    description = "\n".join(section_content).strip()
                elif section == "triggers":
                    triggers = _extract_triggers(section_content)
                section = "instructions"
                section_content = []
            elif line.startswith("## "):
                if section == "purpose":
                    description = "\n".join(section_content).strip()
                elif section == "triggers":
                    triggers = _extract_triggers(section_content)
                elif section == "instructions":
                    instructions = "\n".join(section_content).strip()
                section = None
                section_content = []
            elif section is not None:
                section_content.append(line)

        # Save last section
        if section == "purpose":
            description = "\n".join(section_content).strip()
        elif section == "triggers":
            triggers = _extract_triggers(section_content)
        elif section == "instructions":
            instructions = "\n".join(section_content).strip()

        # Fallback description from first paragraph
        if not description:
            for line in lines:
                if line.strip() and not line.startswith("#") and not line.startswith("##"):
                    description = line.strip()
                    break

        return cls(
            name=name,
            description=description or f"Skill: {name}",
            instructions=instructions,
            triggers=triggers,
        )

    # v0.7.10 - A2A interop: convert to/from an A2A "Skill" primitive
    # so loopy skills can be advertised in any A2A-compatible runtime
    # (Google Agent2Agent v1.0 / Linux Foundation Agentic AI Foundation).
    # https://a2a-protocol.org/latest/specification/
    def to_a2a_card(
        self,
        *,
        tags: list[str] | None = None,
        examples: list[str] | None = None,
        input_modes: list[str] | None = None,
        output_modes: list[str] | None = None,
    ) -> dict[str, Any]:
        """Serialize this Skill into an A2A "Skill" primitive (dict).

        The shape matches the A2A spec section on Skills inside an
        Agent Card. Any field with no sensible loopy analogue (e.g.
        ``inputModes``) is omitted rather than fabricated.

        Args:
            tags: A2A-compatible tags describing the skill domain.
                Defaults to the first three ``triggers``.
            examples: Example user inputs that should match this skill.
                Defaults to empty list.
            input_modes: Accepted input modalities (e.g. ``["text"]``,
                ``["text", "image"]``). Defaults to ``["text"]``.
            output_modes: Produced output modalities. Defaults to ``["text"]``.

        Returns:
            A dict matching the A2A Skill JSON shape.
        """
        if tags is None:
            tags = list(self.triggers[:3])
        if input_modes is None:
            input_modes = ["text"]
        if output_modes is None:
            output_modes = ["text"]
        return {
            "id": self.name.lower().replace(" ", "-").replace("_", "-"),
            "name": self.name,
            "description": self.description or f"Skill: {self.name}",
            "tags": tags,
            "examples": examples or [],
            "inputModes": input_modes,
            "outputModes": output_modes,
        }

    @classmethod
    def from_a2a_card(cls, card: dict[str, Any]) -> Skill:
        """Reconstruct a :class:`Skill` from an A2A Skill primitive dict.

        ``name`` and ``description`` are required by the A2A spec; if
        either is missing the reconstruction raises ``ValueError``.
        ``tags`` are become `` ``triggers``; ``examples`` and modality
        lists are preserved in ``metadata`` for round-trip fidelity.
        """
        if not isinstance(card, dict):
            raise TypeError(f"a2a card must be a dict, got {type(card).__name__}")
        name = card.get("name")
        description = card.get("description")
        if not name:
            raise ValueError("a2a Skill primitive requires a non-empty 'name'")
        if description is None:
            raise ValueError("a2a Skill primitive requires a 'description'")

        triggers = [str(t) for t in card.get("tags", []) if t]
        instructions = "\n".join(card.get("examples", [])) or card.get("description", "")
        metadata: dict[str, Any] = {}
        if "examples" in card:
            metadata["a2a_examples"] = list(card.get("examples", []))
        if "inputModes" in card:
            metadata["a2a_input_modes"] = list(card.get("inputModes", []))
        if "outputModes" in card:
            metadata["a2a_output_modes"] = list(card.get("outputModes", []))
        if "id" in card:
            metadata["a2a_id"] = card["id"]

        return cls(
            name=name,
            description=description,
            instructions=instructions,
            triggers=triggers,
            metadata=metadata,
        )
```

## `loopy.skills.SkillRegistry` (class)

Load and manage skills.

Example:
    registry = SkillRegistry()
    registry.load_directory("./skills")
    matched = registry.match("Fix CI workflow")

```python
class SkillRegistry:
    """
    Load and manage skills.

    Example:
        registry = SkillRegistry()
        registry.load_directory("./skills")
        matched = registry.match("Fix CI workflow")
    """

    def __init__(self):
        self._skills: dict[str, Skill] = {}

    def add(self, skill: Skill) -> None:
        """Add a skill."""
        self._skills[skill.name] = skill

    def get(self, name: str) -> Skill | None:
        """Get skill by name."""
        return self._skills.get(name)

    def list_all(self) -> list[Skill]:
        """List all skills."""
        return list(self._skills.values())

    def match(self, task: str) -> list[Skill]:
        """Match skills to a task."""
        return [s for s in self._skills.values() if s.matches(task)]

    def match_ranked(
        self,
        task: str,
        min_score: float = 0.0,
        limit: int | None = None,
    ) -> list[tuple[Skill, float]]:
        """v0.7.8 — Return matched skills ordered by relevance score (desc).

        Args:
            task: Task description to match against.
            min_score: Drop matches below this score (default 0.0).
            limit: Cap on number of returned matches (default unlimited).

        Returns:
            List of ``(Skill, score)`` tuples, highest score first.
        """
        scored: list[tuple[Skill, float]] = []
        for skill in self._skills.values():
            score = skill.score(task)
            if score >= min_score:
                scored.append((skill, score))

        scored.sort(key=lambda pair: pair[1], reverse=True)
        if limit is not None:
            scored = scored[:limit]
        return scored

    def match_one(self, task: str, min_score: float = 0.0) -> Skill | None:
        """v0.7.8 — Return the single best-matching skill, or None.

        Convenience wrapper around :meth:`match_ranked` for the common
        "pick one" case. Returns the highest-scoring skill above
        ``min_score``, or ``None`` if nothing qualifies.
        """
        ranked = self.match_ranked(task, min_score=min_score, limit=1)
        return ranked[0][0] if ranked else None

    def to_a2a_skills(self) -> list[dict[str, Any]]:
        """v0.7.10 — Export every skill in this registry as A2A primitives.

        Returns a list of A2A Skill dicts, suitable for embedding in the
        ``skills`` field of an Agent Card served at
        ``/.well-known/agent-card.json`` (A2A v1.0 spec).
        """
        return [s.to_a2a_card() for s in self._skills.values()]

    def load_file(self, path: str) -> Skill:
        """Load a single skill file."""
        content = Path(path).read_text(encoding="utf-8")
        skill = Skill.from_markdown(content)
        self.add(skill)
        return skill

    def load_directory(self, directory: str) -> int:
        """Load all .md files from a directory."""
        dir_path = Path(directory)
        loaded = 0

        if not dir_path.exists():
            logger.warning("Skill directory not found: %s", directory)
            return 0

        for md_file in dir_path.glob("*.md"):
            try:
                self.load_file(str(md_file))
                loaded += 1
            except Exception as e:
                logger.error("Failed to load skill from %s: %s", md_file, e)

        return loaded
```


# Module `loopy.verification`

## Verifier

> Not exported by `loopy.verification`.

## AssertionResult

> Not exported by `loopy.verification`.


# Module `loopy.audit`

## `loopy.audit.AuditReport` (class)

Full audit report with score and suggestions.

```python
@dataclass
class AuditReport:
    """Full audit report with score and suggestions."""

    score: int
    level: ReadinessLevel
    checks: list[CheckItem]
    suggestions: list[str]

    def summary(self) -> dict[str, Any]:
        """Return summary dict."""
        return {
            "score": self.score,
            "level": self.level.value,
            "passed": sum(1 for c in self.checks if c.passed),
            "failed": sum(1 for c in self.checks if not c.passed),
            "total": len(self.checks),
            "suggestions": self.suggestions,
        }
```

## `loopy.audit.CheckItem` (class)

A single audit check.

```python
@dataclass
class CheckItem:
    """A single audit check."""

    name: str
    passed: bool
    weight: int
    description: str

    @property
    def score(self) -> int:
        return self.weight if self.passed else 0
```

## `loopy.audit.ReadinessLevel` (class)

Loop readiness levels.

```python
class ReadinessLevel(str, Enum):
    """Loop readiness levels."""

    L0 = "L0"  # 0-29: Draft
    L1 = "L1"  # 30-59: Report only
    L2 = "L2"  # 60-79: Assisted fixes
    L3 = "L3"  # 80-100: Unattended

    @classmethod
    def from_score(cls, score: int) -> ReadinessLevel:
        """Derive readiness level from score."""
        if score < 30:
            return cls.L0
        if score < 60:
            return cls.L1
        if score < 80:
            return cls.L2
        return cls.L3
```


# Module `loopy.streaming`

## `loopy.streaming.StreamBuffer` (class)

Buffer for accumulating stream tokens.

Accumulates tokens and periodically flushes them into
larger chunks when *flush_threshold* is reached.

Args:
    flush_threshold: Number of tokens before auto-flush.

```python
class StreamBuffer:
    """Buffer for accumulating stream tokens.

    Accumulates tokens and periodically flushes them into
    larger chunks when *flush_threshold* is reached.

    Args:
        flush_threshold: Number of tokens before auto-flush.
    """

    def __init__(self, flush_threshold: int = 10):
        self.tokens: list[str] = []
        self.flush_threshold = flush_threshold
        self.total_tokens = 0

    def add(self, token: str) -> str | None:
        """Append a token; returns flushed content if threshold met.

        Args:
            token: A single token string.

        Returns:
            Flushed content if threshold reached, else *None*.
        """
        self.tokens.append(token)
        self.total_tokens += 1

        if len(self.tokens) >= self.flush_threshold:
            return self.flush()
        return None

    def flush(self) -> str:
        """Flush all buffered tokens into a single string.

        Returns:
            The concatenated buffered content.
        """
        content = "".join(self.tokens)
        self.tokens.clear()
        return content

    @property
    def pending(self) -> str:
        """The currently buffered (not yet flushed) content."""
        return "".join(self.tokens)
```

## `loopy.streaming.StreamEvent` (class)

Types of stream events.

```python
class StreamEvent(str, Enum):
    """Types of stream events."""

    TOKEN = "token"
    TOOL_CALL = "tool_call"
    TOOL_RESULT = "tool_result"
    THINKING = "thinking"
    ERROR = "error"
    DONE = "done"
```

## `loopy.streaming.StreamChunk` (class)

A single chunk in a stream.

```python
@dataclass
class StreamChunk:
    """A single chunk in a stream."""

    event: StreamEvent
    data: Any
    index: int = 0
    metadata: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "event": self.event.value,
            "data": self.data,
            "index": self.index,
            "metadata": self.metadata,
        }

    def to_sse(self) -> str:
        """Format as Server-Sent Event."""
        payload = json.dumps(self.to_dict())
        return f"event: {self.event.value}\ndata: {payload}\n\n"
```


# Module `loopy.multimodal`

## `loopy.multimodal.MultiModalMessage` (class)

A message with text and media content.

```python
@dataclass
class MultiModalMessage:
    """A message with text and media content."""

    text: str
    media: list[MediaContent] = field(default_factory=list)

    @property
    def has_media(self) -> bool:
        return len(self.media) > 0

    @property
    def images(self) -> list[MediaContent]:
        return [m for m in self.media if m.type == MediaType.IMAGE]

    @property
    def audio(self) -> list[MediaContent]:
        return [m for m in self.media if m.type == MediaType.AUDIO]

    def to_openai(self) -> list[dict[str, Any]]:
        """Convert to OpenAI multi-modal format."""
        content: list[dict[str, Any]] = []

        # Add images first
        for media in self.media:
            if media.type == MediaType.IMAGE:
                content.append(media.to_openai())

        # Add text
        if self.text:
            content.append({"type": "text", "text": self.text})

        return content

    def to_anthropic(self) -> list[dict[str, Any]]:
        """Convert to Anthropic multi-modal format."""
        content: list[dict[str, Any]] = []

        for media in self.media:
            content.append(media.to_anthropic())

        if self.text:
            content.append({"type": "text", "text": self.text})

        return content
```

## `loopy.multimodal.MultiModalBuilder` (class)

Build multi-modal messages easily.

Example:
    msg = (MultiModalBuilder()
        .text("What's in this image?")
        .image("photo.jpg")
        .image("https://example.com/chart.png")
        .build())

```python
class MultiModalBuilder:
    """
    Build multi-modal messages easily.

    Example:
        msg = (MultiModalBuilder()
            .text("What's in this image?")
            .image("photo.jpg")
            .image("https://example.com/chart.png")
            .build())
    """

    def __init__(self):
        self._text = ""
        self._media: list[MediaContent] = []

    def text(self, content: str) -> MultiModalBuilder:
        """Add text content."""
        self._text = content
        return self

    def image(self, source: str) -> MultiModalBuilder:
        """Add image from file or URL."""
        if source.startswith(("http://", "https://")):
            self._media.append(MediaContent.from_url(source, MediaType.IMAGE))
        else:
            self._media.append(MediaContent.from_file(source))
        return self

    def audio(self, source: str) -> MultiModalBuilder:
        """Add audio from file or URL."""
        if source.startswith(("http://", "https://")):
            self._media.append(MediaContent.from_url(source, MediaType.AUDIO))
        else:
            self._media.append(MediaContent.from_file(source))
        return self

    def file(self, path: str) -> MultiModalBuilder:
        """Add any file as media."""
        self._media.append(MediaContent.from_file(path))
        return self

    def build(self) -> MultiModalMessage:
        """Build the multi-modal message."""
        return MultiModalMessage(text=self._text, media=self._media)
```

## `loopy.multimodal.MediaContent` (class)

A piece of media content.

```python
@dataclass
class MediaContent:
    """A piece of media content."""

    type: MediaType
    data: str  # base64 or URL
    mime_type: str = ""
    metadata: dict[str, Any] = field(default_factory=dict)

    @classmethod
    def from_file(cls, path: str) -> MediaContent:
        """Load media from file."""
        file_path = Path(path)
        if not file_path.exists():
            raise FileNotFoundError(f"Media file not found: {path}")

        suffix = file_path.suffix.lower()
        mime_map = {
            ".png": "image/png",
            ".jpg": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".webp": "image/webp",
            ".gif": "image/gif",
            ".mp3": "audio/mpeg",
            ".wav": "audio/wav",
            ".mp4": "video/mp4",
            ".pdf": "application/pdf",
        }

        mime_type = mime_map.get(suffix, "application/octet-stream")
        media_type = (
            MediaType.IMAGE
            if mime_type.startswith("image/")
            else MediaType.AUDIO
            if mime_type.startswith("audio/")
            else MediaType.VIDEO
            if mime_type.startswith("video/")
            else MediaType.DOCUMENT
        )

        data = base64.b64encode(file_path.read_bytes()).decode()
        return cls(
            type=media_type,
            data=data,
            mime_type=mime_type,
            metadata={"filename": file_path.name, "size": file_path.stat().st_size},
        )

    @classmethod
    def from_url(
        cls,
        url: str,
        media_type: MediaType = MediaType.IMAGE,
        *,
        allow_private: bool = True,
    ) -> MediaContent:
        """Create media from URL (no download).

        The URL is passed through to the provider, which fetches it
        server-side — so keep *allow_private* True only for operator-supplied
        URLs. Set False to reject internal/loopback destinations when the URL
        can come from model output or untrusted content.

        Raises:
            ValueError: If the URL scheme is not http(s) or it has no host.
        """
        validate_outbound_url(url, allow_private=allow_private)
        return cls(
            type=media_type,
            data=url,
            metadata={"url": url},
        )

    def to_openai(self) -> dict[str, Any]:
        """Convert to OpenAI vision API format.

        Returns a dict suitable for use in the ``content`` array
        of an OpenAI chat completion request.
        """
        if self.data.startswith(("http://", "https://")):
            return {"type": "image_url", "image_url": {"url": self.data}}
        return {
            "type": "image_url",
            "image_url": {"url": f"data:{self.mime_type};base64,{self.data}"},
        }

    def to_anthropic(self) -> dict[str, Any]:
        """Convert to Anthropic Messages API format.

        Returns a dict suitable for use in the ``content`` array
        of an Anthropic message request.
        """
        if self.data.startswith(("http://", "https://")):
            return {"type": "image", "source": {"type": "url", "url": self.data}}
        return {
            "type": "image",
            "source": {"type": "base64", "media_type": self.mime_type, "data": self.data},
        }
```

## `loopy.multimodal.MediaType` (class)

Supported media types.

```python
class MediaType(str, Enum):
    """Supported media types."""

    IMAGE = "image"
    AUDIO = "audio"
    VIDEO = "video"
    DOCUMENT = "document"
```

## `loopy.multimodal.ImageFormat` (class)

Image formats.

```python
class ImageFormat(str, Enum):
    """Image formats."""

    PNG = "png"
    JPEG = "jpeg"
    WEBP = "webp"
    GIF = "gif"
```

## `loopy.multimodal.RealtimeSession` (class)

v0.7.10 - Async iterator over realtime events from a transport.

Lightweight adapter that consumes raw WebSocket frames from any
:class:`RealtimeTransport` and yields normalized
:class:`RealtimeEvent` instances. Useful for voice-first agent
loops, transcription-only bots, and OpenAI Realtime clients.

Example `` `` ``
    from loopy.multimodal import RealtimeSession, RealtimeTransport

    class MyOpenAITransport:
        async def send(self, payload): ...
        async def recv(self): ...
        async def close(self): ...

    async with RealtimeSession(MyOpenAITransport()) as session:
        await session.send({"type": "session.update", "session": {...}})
        async for event in session:
            if event.type == RealtimeEventType.TRANSCRIPT_DELTA:
                print(event.transcript, end="", flush=True)
```

The ``websockets`` library itself is NOT a dependency - users wire
in their preferred WebSocket client via the ``transport=`` argument.
This keeps the loopy-agent core dependency surface at just
``httpx`` + ``pydantic`` while leaving the door open for voice /
realtime use cases.

```python
class RealtimeSession:
    """v0.7.10 - Async iterator over realtime events from a transport.

    Lightweight adapter that consumes raw WebSocket frames from any
    :class:`RealtimeTransport` and yields normalized
    :class:`RealtimeEvent` instances. Useful for voice-first agent
    loops, transcription-only bots, and OpenAI Realtime clients.

    Example `` `` ``
        from loopy.multimodal import RealtimeSession, RealtimeTransport

        class MyOpenAITransport:
            async def send(self, payload): ...
            async def recv(self): ...
            async def close(self): ...

        async with RealtimeSession(MyOpenAITransport()) as session:
            await session.send({"type": "session.update", "session": {...}})
            async for event in session:
                if event.type == RealtimeEventType.TRANSCRIPT_DELTA:
                    print(event.transcript, end="", flush=True)
    ```

    The ``websockets`` library itself is NOT a dependency - users wire
    in their preferred WebSocket client via the ``transport=`` argument.
    This keeps the loopy-agent core dependency surface at just
    ``httpx`` + ``pydantic`` while leaving the door open for voice /
    realtime use cases.
    """

    __slots__ = ("_transport", "_closed", "_events", "_pump_task")

    def __init__(self, transport: RealtimeTransport) -> None:
        self._transport = transport
        self._closed = False
        self._events: asyncio.Queue[RealtimeEvent] = asyncio.Queue()
        self._pump_task: asyncio.Task[None] | None = None

    async def __aenter__(self) -> RealtimeSession:
        # Start the background pump that drains the transport into the queue.
        self._pump_task = asyncio.create_task(self._pump())
        return self

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

    async def send(self, payload: dict[str, Any]) -> None:
        """Send a payload upstream through the transport."""
        if self._closed:
            raise RuntimeError("RealtimeSession is closed")
        await self._transport.send(payload)

    async def close(self) -> None:
        """Close the transport and mark the session done."""
        if self._closed:
            return
        self._closed = True
        # Cancel pump first so it cannot put more events after we close.
        if self._pump_task is not None and not self._pump_task.done():
            self._pump_task.cancel()
            with contextlib.suppress(asyncio.CancelledError, Exception):  # noqa: BLE001
                await self._pump_task
        try:
            await self._transport.close()
        finally:
            # Always emit a CLOSED event so consumers see the terminal.
            with contextlib.suppress(RuntimeError):
                # Queue may already be closed if pump was cancelled.
                await self._events.put(RealtimeEvent(type=RealtimeEventType.CLOSED, data={}))

    def __aiter__(self) -> RealtimeSession:
        return self

    async def __anext__(self) -> RealtimeEvent:
        if self._closed and self._events.empty():
            raise StopAsyncIteration
        try:
            return await asyncio.wait_for(self._events.get(), timeout=0.05)
        except asyncio.TimeoutError:
            # Background pump will refill the queue. Loop again.
            return await self.__anext__()

    async def _pump(self) -> None:
        """Internal: drain the transport until closed, normalising events.

        The pump does NOT call :meth:`close` - that would re-enter
        ``close`` which is awaiting this task. It simply stops putting
        new events and exits; ``close`` is responsible for cancelling
        this task and emitting the final ``CLOSED`` event.
        """
        try:
            while not self._closed:
                try:
                    payload = await self._transport.recv()
                except Exception as e:  # noqa: BLE001 - transport errors become ERROR events
                    logger.warning("RealtimeSession transport error: %s", e)
                    break
                if payload is None:
                    break
                try:
                    await self._events.put(_build_event(payload))
                except Exception:  # noqa: BLE001
                    # Queue may be closed during shutdown.
                    break
        except asyncio.CancelledError:
            pass
```

## `loopy.multimodal.RealtimeEvent` (class)

v0.7.10 - Normalized realtime event surfaced to the agent loop.

```python
@dataclass
class RealtimeEvent:
    """v0.7.10 - Normalized realtime event surfaced to the agent loop."""

    type: RealtimeEventType
    data: dict[str, Any] = field(default_factory=dict)
    timestamp: float = field(default_factory=lambda: __import__("time").time())

    @property
    def transcript(self) -> str:
        """Concatenated transcript text (from ``transcript.delta`` events)."""
        return str(self.data.get("transcript", ""))

    @property
    def audio_bytes(self) -> bytes:
        """Raw audio payload (from ``audio.delta`` events)."""
        return self.data.get("audio", b"")
```

## `loopy.multimodal.RealtimeEventType` (class)

v0.7.10 - Subset of OpenAI Realtime event types we support natively.

```python
class RealtimeEventType(str, Enum):
    """v0.7.10 - Subset of OpenAI Realtime event types we support natively."""

    SESSION_CREATED = "session.created"
    TRANSCRIPT_DELTA = "transcript.delta"
    TRANSCRIPT_DONE = "transcript.done"
    AUDIO_DELTA = "audio.delta"
    TOOL_CALL = "tool.call"
    ERROR = "error"
    CLOSED = "closed"
```

## `loopy.multimodal.RealtimeTransport` (class)

v0.7.10 - Pluggable WebSocket transport for ``RealtimeSession``.

Any object exposing ``async send(payload)``, ``async recv()``, and
``async close()`` can drive a ``RealtimeSession``. Loopy ships no
concrete WebSocket implementation - users wire in their preferred
client (``websockets``, ``openai-agents`` realtime client, etc.).

```python
class RealtimeTransport(Protocol):
    """v0.7.10 - Pluggable WebSocket transport for ``RealtimeSession``.

    Any object exposing ``async send(payload)``, ``async recv()``, and
    ``async close()`` can drive a ``RealtimeSession``. Loopy ships no
    concrete WebSocket implementation - users wire in their preferred
    client (``websockets``, ``openai-agents`` realtime client, etc.).
    """

    async def send(self, payload: dict[str, Any]) -> None: ...

    async def recv(self) -> dict[str, Any] | None: ...

    async def close(self) -> None: ...
```


# Module `loopy.compliance`

## `loopy.compliance.ComplianceChecker` (class)

Check compliance against frameworks.

Example:
    checker = ComplianceChecker()
    report = checker.check_soc2(config)
    if not report.passed:
        print(f"Violations: {report.violations}")

```python
class ComplianceChecker:
    """
    Check compliance against frameworks.

    Example:
        checker = ComplianceChecker()
        report = checker.check_soc2(config)
        if not report.passed:
            print(f"Violations: {report.violations}")
    """

    def __init__(self, audit_logger: AuditLogger | None = None):
        self.audit_logger = audit_logger

    def check_soc2(self, config: dict[str, Any]) -> ComplianceReport:
        """Check SOC2 compliance."""
        checks = []
        violations = []
        recommendations = []

        # Check: Access controls
        has_auth = config.get("authentication") is not None
        checks.append({"name": "access_controls", "passed": has_auth})
        if not has_auth:
            violations.append("No authentication configured")
            recommendations.append("Add authentication to restrict agent access")

        # Check: Audit logging
        has_audit = config.get("audit_logging") is True
        checks.append({"name": "audit_logging", "passed": has_audit})
        if not has_audit:
            violations.append("Audit logging not enabled")
            recommendations.append("Enable audit logging for all agent actions")

        # Check: Encryption
        has_encryption = config.get("encryption") is not None
        checks.append({"name": "encryption", "passed": has_encryption})
        if not has_encryption:
            recommendations.append("Configure encryption for data at rest and in transit")

        # Check: Rate limiting
        has_rate_limit = config.get("rate_limit") is not None
        checks.append({"name": "rate_limiting", "passed": has_rate_limit})
        if not has_rate_limit:
            recommendations.append("Add rate limiting to prevent abuse")

        return ComplianceReport(
            framework=ComplianceFramework.SOC2,
            passed=all(c["passed"] for c in checks),
            checks=checks,
            violations=violations,
            recommendations=recommendations,
        )

    def check_gdpr(self, config: dict[str, Any]) -> ComplianceReport:
        """Check GDPR compliance."""
        checks = []
        violations = []
        recommendations = []

        # Check: Data minimization
        has_minimization = config.get("data_minimization") is True
        checks.append({"name": "data_minimization", "passed": has_minimization})
        if not has_minimization:
            violations.append("Data minimization not enforced")
            recommendations.append("Only collect necessary data for agent operation")

        # Check: Right to deletion
        has_deletion = config.get("deletion_support") is True
        checks.append({"name": "right_to_deletion", "passed": has_deletion})
        if not has_deletion:
            recommendations.append("Implement data deletion on user request")

        # Check: Consent tracking
        has_consent = config.get("consent_tracking") is True
        checks.append({"name": "consent_tracking", "passed": has_consent})
        if not has_consent:
            recommendations.append("Track user consent for data processing")

        # Check: PII handling
        has_pii_protection = config.get("pii_protection") is not None
        checks.append({"name": "pii_protection", "passed": has_pii_protection})
        if not has_pii_protection:
            violations.append("No PII protection configured")
            recommendations.append("Add PII detection and masking via guardrails")

        return ComplianceReport(
            framework=ComplianceFramework.GDPR,
            passed=all(c["passed"] for c in checks),
            checks=checks,
            violations=violations,
            recommendations=recommendations,
        )

    def check_eu_ai_act(self, config: dict[str, Any]) -> ComplianceReport:
        """Check EU AI Act compliance."""
        checks = []
        violations = []
        recommendations = []

        # Check: Risk classification
        has_risk_class = config.get("risk_classification") is not None
        checks.append({"name": "risk_classification", "passed": has_risk_class})
        if not has_risk_class:
            violations.append("No risk classification for AI system")
            recommendations.append("Classify AI system risk level per EU AI Act")

        # Check: Human oversight
        has_human_oversight = config.get("human_oversight") is True
        checks.append({"name": "human_oversight", "passed": has_human_oversight})
        if not has_human_oversight:
            violations.append("No human oversight mechanism")
            recommendations.append("Add human-in-the-loop for high-risk decisions")

        # Check: Transparency
        has_transparency = config.get("transparency") is True
        checks.append({"name": "transparency", "passed": has_transparency})
        if not has_transparency:
            recommendations.append("Document AI system capabilities and limitations")

        # Check: Explainability
        has_explainability = config.get("explainability") is True
        checks.append({"name": "explainability", "passed": has_explainability})
        if not has_explainability:
            recommendations.append("Add decision audit trail for agent actions")

        return ComplianceReport(
            framework=ComplianceFramework.EU_AI_ACT,
            passed=all(c["passed"] for c in checks),
            checks=checks,
            violations=violations,
            recommendations=recommendations,
        )
```

## `loopy.compliance.AuditLogger` (class)

Log all agent actions for compliance.

Example:
    logger = AuditLogger("./audit.log")
    logger.log("summarize", agent_id="agent-1", ...)

```python
class AuditLogger:
    """
    Log all agent actions for compliance.

    Example:
        logger = AuditLogger("./audit.log")
        logger.log("summarize", agent_id="agent-1", ...)
    """

    def __init__(self, path: str = "./audit.jsonl"):
        self.path = Path(path)
        self.path.parent.mkdir(parents=True, exist_ok=True)

    def log(self, entry: AuditEntry) -> None:
        """Append an audit entry to the JSONL log file.

        Args:
            entry: The AuditEntry to persist.
        """
        with open(self.path, "a") as f:
            f.write(json.dumps(entry.to_dict()) + "\n")

    def query(
        self,
        agent_id: str | None = None,
        start_time: str | None = None,
        end_time: str | None = None,
    ) -> list[AuditEntry]:
        """Query audit log entries with optional filters.

        Args:
            agent_id: Filter by agent identifier.
            start_time: ISO-format start timestamp (inclusive).
            end_time: ISO-format end timestamp (inclusive).

        Returns:
            List of matching AuditEntry objects.
        """
        entries: list[AuditEntry] = []

        if not self.path.exists():
            return entries

        with open(self.path) as f:
            for line in f:
                if not line.strip():
                    continue
                data = json.loads(line)

                if agent_id and data.get("agent_id") != agent_id:
                    continue
                if start_time and data.get("timestamp", "") < start_time:
                    continue
                if end_time and data.get("timestamp", "") > end_time:
                    continue

                entries.append(
                    AuditEntry(
                        timestamp=data["timestamp"],
                        action=data["action"],
                        agent_id=data["agent_id"],
                        input_summary=data["input_summary"],
                        output_summary=data["output_summary"],
                        classification=DataClassification(data["classification"]),
                        tokens_used=data.get("tokens_used", 0),
                        model=data.get("model", ""),
                        metadata=data.get("metadata", {}),
                    )
                )

        return entries

    def summary(self, days: int = 30) -> dict[str, Any]:
        """Generate a summary of audit activity.

        Args:
            days: Number of days to look back (currently unused,
                  reserved for future filtering).

        Returns:
            Dict with total_actions, total_tokens, breakdowns
            by agent and classification.
        """
        entries = self.query()

        total_tokens = sum(e.tokens_used for e in entries)
        by_agent: dict[str, int] = {}
        by_classification: dict[str, int] = {}

        for e in entries:
            by_agent[e.agent_id] = by_agent.get(e.agent_id, 0) + 1
            cls_key = e.classification.value
            by_classification[cls_key] = by_classification.get(cls_key, 0) + 1

        return {
            "total_actions": len(entries),
            "total_tokens": total_tokens,
            "by_agent": by_agent,
            "by_classification": by_classification,
        }
```


# Module `loopy.explainability`

## `loopy.explainability.DecisionTracker` (class)

Track and explain agent decisions.

Example:
    tracker = DecisionTracker(max_traces=100)
    trace = tracker.start("Summarize document")
    tracker.add_step(trace, DecisionType.PLAN, "Will extract key points")
    # ... agent works ...
    tracker.finish(trace, "Summary complete")
    print(trace.summary)

```python
class DecisionTracker:
    """
    Track and explain agent decisions.

    Example:
        tracker = DecisionTracker(max_traces=100)
        trace = tracker.start("Summarize document")
        tracker.add_step(trace, DecisionType.PLAN, "Will extract key points")
        # ... agent works ...
        tracker.finish(trace, "Summary complete")
        print(trace.summary)
    """

    def __init__(self, max_traces: int = 100):
        self.traces: list[DecisionTrace] = []
        self._max_traces = max_traces

    def start(self, task: str) -> DecisionTrace:
        """Start tracking a new task."""
        trace = DecisionTrace(task=task)
        self.traces.append(trace)

        # Evict oldest when at capacity
        if len(self.traces) > self._max_traces:
            evicted = self.traces.pop(0)
            logger.debug("Evicted old trace: %s", evicted.task)

        return trace

    def add_step(
        self,
        trace: DecisionTrace,
        type: DecisionType,
        reasoning: str,
        input_summary: str = "",
        output_summary: str = "",
        confidence: float = 1.0,
        alternatives: list[str] | None = None,
        **metadata: Any,
    ) -> DecisionStep:
        """Add a decision step to the trace."""
        step = DecisionStep(
            type=type,
            reasoning=reasoning,
            input_summary=input_summary,
            output_summary=output_summary,
            confidence=confidence,
            alternatives=alternatives or [],
            metadata=metadata,
        )
        trace.add_step(step)
        return step

    def finish(self, trace: DecisionTrace, output: str, success: bool = True) -> None:
        """Finish tracking a task."""
        trace.final_output = output
        trace.success = success

    def explain(self, trace: DecisionTrace) -> str:
        """Generate human-readable explanation."""
        lines = [
            f"## Decision Trace: {trace.task}",
            "",
            "### Reasoning Chain:",
        ]

        for i, step in enumerate(trace.steps, 1):
            lines.append(f"\n**Step {i}: {step.type.value}**")
            lines.append(f"- Reasoning: {step.reasoning}")
            if step.alternatives:
                lines.append(f"- Alternatives considered: {', '.join(step.alternatives)}")
            lines.append(f"- Confidence: {step.confidence:.0%}")

        lines.extend(
            [
                "",
                "### Final Output:",
                trace.final_output[:500],
                "",
                "### Stats:",
                f"- Steps: {len(trace.steps)}",
                f"- Time: {trace.total_time_ms:.0f}ms",
                f"- Success: {'✅' if trace.success else '❌'}",
            ]
        )

        return "\n".join(lines)

    def export(self, trace: DecisionTrace, path: str) -> None:
        """Export a decision trace to a JSON file.

        Args:
            trace: The DecisionTrace to export.
            path: Destination file path.
        """
        from pathlib import Path

        Path(path).write_text(trace.to_json())
```

## `loopy.explainability.DecisionTrace` (class)

Full trace of agent decision-making.

```python
@dataclass
class DecisionTrace:
    """Full trace of agent decision-making."""

    task: str
    steps: list[DecisionStep] = field(default_factory=list)
    final_output: str = ""
    total_time_ms: float = 0
    success: bool = True

    def add_step(self, step: DecisionStep) -> None:
        self.steps.append(step)

    @property
    def summary(self) -> str:
        """Human-readable summary of decision chain."""
        lines = [f"Task: {self.task}"]
        for i, step in enumerate(self.steps, 1):
            lines.append(f"  {i}. [{step.type.value}] {step.reasoning}")
        lines.append(f"Output: {self.final_output[:100]}...")
        return "\n".join(lines)

    def to_dict(self) -> dict[str, Any]:
        return {
            "task": self.task,
            "steps": [s.to_dict() for s in self.steps],
            "final_output": self.final_output,
            "total_time_ms": self.total_time_ms,
            "success": self.success,
        }

    def to_json(self) -> str:
        return json.dumps(self.to_dict(), indent=2)
```

## `loopy.explainability.DecisionStep` (class)

A single decision in the reasoning chain.

```python
@dataclass
class DecisionStep:
    """A single decision in the reasoning chain."""

    type: DecisionType
    reasoning: str
    input_summary: str
    output_summary: str
    confidence: float = 1.0
    alternatives: list[str] = field(default_factory=list)
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
    metadata: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "type": self.type.value,
            "reasoning": self.reasoning,
            "input_summary": self.input_summary,
            "output_summary": self.output_summary,
            "confidence": self.confidence,
            "alternatives": self.alternatives,
            "timestamp": self.timestamp,
            "metadata": self.metadata,
        }
```

## `loopy.explainability.DecisionType` (class)

Types of agent decisions.

```python
class DecisionType(str, Enum):
    """Types of agent decisions."""

    PLAN = "plan"
    ACTION = "action"
    TOOL_USE = "tool_use"
    ROUTE = "route"
    ESCALATE = "escalate"
    STOP = "stop"
    RETRY = "retry"
```


# Module `loopy.patterns`

## `loopy.patterns.PatternRegistry` (class)

Built-in production patterns.

Example:
    registry = PatternRegistry()
    patterns = registry.list_all()
    daily = registry.get("daily-triage")

```python
class PatternRegistry:
    """
    Built-in production patterns.

    Example:
        registry = PatternRegistry()
        patterns = registry.list_all()
        daily = registry.get("daily-triage")
    """

    def __init__(self):
        self._patterns: dict[str, LoopPattern] = {}
        self._register_builtins()

    def _register_builtins(self) -> None:
        """Register built-in patterns."""
        builtins = [
            LoopPattern(
                name="daily-triage",
                description="Triage issues and PRs on a daily cadence",
                cadence=PatternCadence.DAILY,
                risk=RiskLevel.LOW,
                readiness_level="L1",
            ),
            LoopPattern(
                name="pr-babysitter",
                description="Monitor and respond to PR events",
                cadence=PatternCadence.MINUTES_15,
                risk=RiskLevel.MEDIUM,
                readiness_level="L1",
            ),
            LoopPattern(
                name="ci-sweeper",
                description="Sweep CI failures and create fixes",
                cadence=PatternCadence.MINUTES_15,
                risk=RiskLevel.MEDIUM,
                readiness_level="L2",
            ),
            LoopPattern(
                name="dependency-sweeper",
                description="Check and update dependencies",
                cadence=PatternCadence.HOURS_6,
                risk=RiskLevel.MEDIUM,
                readiness_level="L2",
            ),
            LoopPattern(
                name="changelog-drafter",
                description="Draft changelog from commits",
                cadence=PatternCadence.DAILY,
                risk=RiskLevel.LOW,
                readiness_level="L1",
            ),
            LoopPattern(
                name="post-merge-cleanup",
                description="Clean up after merges",
                cadence=PatternCadence.HOURS_6,
                risk=RiskLevel.LOW,
                readiness_level="L1",
            ),
            LoopPattern(
                name="issue-triage",
                description="Triage new issues",
                cadence=PatternCadence.HOURS_1,
                risk=RiskLevel.LOW,
                readiness_level="L1",
            ),
        ]

        for pattern in builtins:
            self._patterns[pattern.name] = pattern

    def get(self, name: str) -> LoopPattern | None:
        """Get pattern by name."""
        return self._patterns.get(name)

    def list_all(self) -> list[LoopPattern]:
        """List all patterns."""
        return list(self._patterns.values())

    def list_by_risk(self, risk: RiskLevel) -> list[LoopPattern]:
        """List patterns by risk level."""
        return [p for p in self._patterns.values() if p.risk == risk]

    def list_by_cadence(self, cadence: PatternCadence) -> list[LoopPattern]:
        """List patterns by cadence."""
        return [p for p in self._patterns.values() if p.cadence == cadence]
```

## `loopy.patterns.LoopPattern` (class)

A reusable loop pattern template.

```python
@dataclass
class LoopPattern:
    """A reusable loop pattern template."""

    name: str
    description: str
    cadence: PatternCadence
    risk: RiskLevel
    readiness_level: str  # L1, L2, or L3

    def to_dict(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "description": self.description,
            "cadence": self.cadence.value,
            "risk": self.risk.value,
            "readiness_level": self.readiness_level,
        }
```

## `loopy.patterns.PatternCadence` (class)

How often the pattern runs.

```python
class PatternCadence(str, Enum):
    """How often the pattern runs."""

    MINUTES_5 = "5m"
    MINUTES_15 = "15m"
    HOURS_1 = "1h"
    HOURS_6 = "6h"
    DAILY = "1d"
```

## `loopy.patterns.RiskLevel` (class)

Risk level of the pattern.

```python
class RiskLevel(str, Enum):
    """Risk level of the pattern."""

    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
```

## PatternType

> Not exported by `loopy.patterns`.

## PatternResult

> Not exported by `loopy.patterns`.

## DynamicPatternRegistry

> Not exported by `loopy.patterns`.

## FanOutSynthesize

> Not exported by `loopy.patterns`.

## ClassifyAndAct

> Not exported by `loopy.patterns`.

## AdversarialVerification

> Not exported by `loopy.patterns`.

## Tournament

> Not exported by `loopy.patterns`.


# Module `loopy.session`

## Session

> Could not import `loopy.session.Session`: No module named 'loopy.session'

## SessionConfig

> Could not import `loopy.session.SessionConfig`: No module named 'loopy.session'

## SessionManager

> Could not import `loopy.session.SessionManager`: No module named 'loopy.session'

## TranscriptEntry

> Could not import `loopy.session.TranscriptEntry`: No module named 'loopy.session'

## MessageOrigin

> Could not import `loopy.session.MessageOrigin`: No module named 'loopy.session'


# Module `loopy.subagents`

## IsolatedAgentPool

> Could not import `loopy.subagents.IsolatedAgentPool`: No module named 'loopy.subagents'

## IsolatedSubAgent

> Could not import `loopy.subagents.IsolatedSubAgent`: No module named 'loopy.subagents'

## IsolationLevel

> Could not import `loopy.subagents.IsolationLevel`: No module named 'loopy.subagents'

## SubagentConfig

> Could not import `loopy.subagents.SubagentConfig`: No module named 'loopy.subagents'


# Module `loopy.a2a`

## `loopy.a2a.A2AClient` (class)

Client for agent-to-agent communication.

Supports both local (registered handler) and remote (HTTP)
dispatch. When a remote *endpoint* is set on the agent's
:class:`AgentCard`, the client sends an HTTP POST with the
request payload. Otherwise it falls back to a local handler
registered via :meth:`register_handler`.

Example:
    client = A2AClient(registry)
    response = await client.call("code-assistant", "Write hello world")
    print(response.result)

```python
class A2AClient:
    """
    Client for agent-to-agent communication.

    Supports both local (registered handler) and remote (HTTP)
    dispatch. When a remote *endpoint* is set on the agent's
    :class:`AgentCard`, the client sends an HTTP POST with the
    request payload. Otherwise it falls back to a local handler
    registered via :meth:`register_handler`.

    Example:
        client = A2AClient(registry)
        response = await client.call("code-assistant", "Write hello world")
        print(response.result)
    """

    def __init__(
        self,
        registry: AgentRegistry,
        *,
        allow_private: bool = True,
        card_ttl: float = 3600.0,
    ):
        """Args:
        registry: The agent registry to route through.
        allow_private: Permit loopback/private/link-local agent
            endpoints. Keep True for operator-registered endpoints (local
            A2A meshes are normal). Set False when endpoints can be
            influenced by untrusted content — the SSRF guard then
            rejects internal destinations.
        card_ttl: Seconds a fetched :class:`AgentCard` is cached before
            the next call to :meth:`fetch_agent_card` will re-fetch
            (v0.9.0). Default 3600 (1 hour).
        """
        self.registry = registry
        self._allow_private = allow_private
        self.card_ttl = card_ttl
        self._handlers: dict[str, Callable[[AgentRequest], Awaitable[AgentResponse]]] = {}
        # v0.9.0 — when the client was built from a single Agent Card
        # (see :meth:`from_agent_card`), the source card lives here so
        # callers can re-read it without rebuilding.
        self._agent_card: AgentCard | None = None
        # v0.9.0 — URL -> (AgentCard, fetched_at_monotonic) cache.
        self._card_cache: dict[str, tuple[AgentCard, float]] = {}

    def register_handler(
        self,
        agent_name: str,
        handler: Callable[[AgentRequest], Awaitable[AgentResponse]],
    ) -> None:
        """Register a local handler for incoming requests.

        Args:
            agent_name: Name of the agent this handler serves.
            handler: Async callable receiving an AgentRequest
                     and returning an AgentResponse.
        """
        self._handlers[agent_name] = handler

    # ── v0.9.0 — Agent Card discovery ────────────────────────────

    @property
    def agent_card(self) -> AgentCard:
        """The :class:`AgentCard` this client was built from.

        Always returns a card; if the client was constructed directly
        via :class:`A2AClient(registry)`, a synthetic placeholder
        card derived from the registry name is returned so callers
        can rely on a non-None value.
        """
        if self._agent_card is not None:
            return self._agent_card
        return AgentCard(name=self.registry.__class__.__name__)

    @classmethod
    def from_agent_card(
        cls,
        card: AgentCard,
        *,
        allow_private: bool = True,
        card_ttl: float = 3600.0,
    ) -> A2AClient:
        """Build an :class:`A2AClient` from a single :class:`AgentCard`.

        The registry is auto-populated with the card so the legacy
        ``call`` / ``broadcast`` paths keep working. The card is
        also retained on the client so ``client.agent_card`` returns
        the source.

        Raises:
            ValueError: if ``card.authentication`` is not in the
                A2A v1.0 allowed set (``none``, ``api_key``,
                ``oauth2``, ``openIdConnect``).
        """
        if card.authentication not in _ALLOWED_AUTHENTICATION:
            raise ValueError(
                f"Authentication method {card.authentication!r} is not allowed; "
                f"must be one of {sorted(_ALLOWED_AUTHENTICATION)}"
            )
        registry = AgentRegistry()
        registry.register(card)
        client = cls(
            registry,
            allow_private=allow_private,
            card_ttl=card_ttl,
        )
        client._agent_card = card
        return client

    async def fetch_agent_card(self, url: str) -> AgentCard:
        """Fetch and parse an A2A v1.0 Agent Card from a URL.

        Args:
            url: HTTPS URL of a ``/.well-known/agent-card.json``
                document.

        Returns:
            The parsed :class:`AgentCard`.

        Raises:
            A2AError: if the document is missing required fields
                (e.g. ``name``) or cannot be decoded.
            ValueError: if the URL scheme is not allowed (re-raised
                from :func:`loopy.netutil.validate_outbound_url`).
        """
        # Cache hit?
        cached = self._card_cache.get(url)
        if cached is not None:
            card, fetched_at = cached
            if (time.monotonic() - fetched_at) < self.card_ttl:
                return card

        # SSRF guard: only http(s) are allowed.
        validate_outbound_url(url, allow_private=self._allow_private)

        try:
            data = await self._fetch_json(url)
        except A2AError:
            raise
        except Exception as exc:
            raise A2AError(f"Could not load Agent Card from {url}: {exc}") from exc

        return self._parse_agent_card(data, url)

    async def _fetch_json(self, url: str) -> dict[str, Any]:
        """GET a URL and return the parsed JSON body.

        Wrapped in its own method so tests can patch it with
        ``AsyncMock(return_value=payload)`` without touching
        :mod:`httpx` directly.
        """
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.get(url)
            resp.raise_for_status()
            return resp.json()

    def _parse_agent_card(self, data: dict[str, Any], url: str) -> AgentCard:
        if "name" not in data:
            raise A2AError(f"Agent Card at {url} is missing required field 'name'")

        auth = data.get("authentication") or {}
        schemes = auth.get("schemes") if isinstance(auth, dict) else None
        authentication = schemes[0] if schemes else "none"

        provider_field = data.get("provider") or {}
        if isinstance(provider_field, dict):
            provider = provider_field.get("name", "")
        else:
            provider = str(provider_field)

        card = AgentCard(
            name=data["name"],
            description=data.get("description", ""),
            version=data.get("version", "0.0.0"),
            capabilities=[],
            endpoint=data.get("url", url),
            url=data.get("url", ""),
            skills=list(data.get("skills", [])),
            provider=provider,
            authentication=authentication,
        )
        self._card_cache[url] = (card, time.monotonic())
        return card

    # ── v0.9.0 — Task lifecycle + streaming ──────────────────────

    async def create_task(
        self,
        skill_id: str,
        inputs: dict[str, Any],
        *,
        callback_url: str | None = None,
        idempotency_key: str | None = None,
    ) -> A2ATask:
        """Submit a task to a remote agent by skill id.

        Args:
            skill_id: Must match one of the ``skills`` entries on the
                client's :attr:`agent_card`. Unknown ids raise
                :class:`A2AError` without making a network call.
            inputs: Skill-specific input payload.
            callback_url: Optional webhook URL. When set, the remote
                agent will POST status updates here; the receiving
                side must verify the HMAC via :meth:`verify_webhook`.
            idempotency_key: Re-submitting with the same key yields
                the same task id from the server.

        Returns:
            The initial :class:`A2ATask` (typically ``state="submitted"``).
        """
        skill_ids = {s.get("id") for s in self.agent_card.skills if isinstance(s, dict)}
        if skill_id not in skill_ids:
            raise A2AError(
                f"Unknown skill {skill_id!r}; available: {sorted(x for x in skill_ids if x)}"
            )

        body: dict[str, Any] = {
            "skill_id": skill_id,
            "inputs": inputs,
        }
        if callback_url is not None:
            body["callback_url"] = callback_url
        if idempotency_key is not None:
            body["idempotency_key"] = idempotency_key

        url = self._endpoint()
        data = await self._post_json(url, json=body)
        return A2ATask.from_dict(data)

    def _endpoint(self) -> str:
        """The base URL of the remote agent, with no trailing slash."""
        return (self.agent_card.url or self.agent_card.endpoint).rstrip("/")

    async def get_task(self, task_id: str) -> A2ATask:
        """Fetch the current state of a task by id."""
        data = await self._get_json(f"{self._endpoint()}/tasks/{task_id}")
        return A2ATask.from_dict(data)

    async def cancel_task(self, task_id: str) -> A2ATask:
        """Request cancellation of a running task.

        Returns the updated task; the server transitions the state
        to ``"canceled"`` (idempotent: cancelling a canceled task
        is a no-op).
        """
        data = await self._post_json(
            f"{self._endpoint()}/tasks/{task_id}/cancel",
            json={},
        )
        return A2ATask.from_dict(data)

    async def stream_task(self, task_id: str) -> AsyncIterator[A2ATask]:
        """Yield :class:`A2ATask` updates as they stream in via SSE.

        The iterator terminates naturally when the server sends a
        terminal state (``completed``, ``failed``, ``canceled``, or
        ``rejected``).
        """
        url = f"{self._endpoint()}/tasks/{task_id}/stream"
        async for event in self._sse_events(url):
            yield A2ATask.from_dict(event)

    def verify_webhook(
        self,
        body: bytes,
        signature: str,
        secret: bytes,
    ) -> bool:
        """Verify the HMAC-SHA256 signature of an incoming webhook.

        Returns ``True`` if the signature is valid, ``False`` otherwise.
        Uses :func:`hmac.compare_digest` for constant-time comparison.
        Never raises; callers should treat ``False`` as a 400.
        """
        expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
        # ``hmac.compare_digest`` raises TypeError on length mismatch in
        # Python <3.10; safe across versions when both args are str.
        if not isinstance(signature, str) or len(signature) != len(expected):
            return False
        return hmac.compare_digest(expected, signature)

    # ── HTTP helpers (monkey-patchable for tests) ───────────────

    async def _post_json(self, url: str, *, json: dict[str, Any]) -> dict[str, Any]:
        async with httpx.AsyncClient(timeout=30.0) as client:
            resp = await client.post(url, json=json)
            resp.raise_for_status()
            return resp.json()

    async def _get_json(self, url: str) -> dict[str, Any]:
        async with httpx.AsyncClient(timeout=30.0) as client:
            resp = await client.get(url)
            resp.raise_for_status()
            return resp.json()

    async def _sse_events(self, url: str) -> AsyncIterator[dict[str, Any]]:
        """Default SSE transport. Parses ``data: <json>`` lines.

        Tests may monkey-patch this to a small async generator that
        yields pre-canned dicts.
        """
        async with (
            httpx.AsyncClient(timeout=None) as client,
            client.stream("GET", url) as resp,
        ):
            resp.raise_for_status()
            async for line in resp.aiter_lines():
                if line.startswith("data:"):
                    payload = line[len("data:") :].strip()
                    if payload:
                        yield json.loads(payload)

    async def call(
        self,
        agent_name: str,
        task: str,
        context: dict[str, Any] | None = None,
        sender: str = "",
    ) -> AgentResponse:
        """
        Call another agent by name.

        Dispatch order:
        1. Local registered handler (if any).
        2. HTTP POST to ``AgentCard.endpoint`` (if set).
        3. Placeholder response (if neither handler nor endpoint).

        Args:
            agent_name: Registered agent name.
            task: Task description for the remote agent.
            context: Optional shared context dict.
            sender: Sender identity string.

        Returns:
            An AgentResponse with the result.
        """
        card = self.registry.get(agent_name)
        if not card:
            return AgentResponse(
                result="",
                success=False,
                error=f"Agent not found: {agent_name}",
            )

        request = AgentRequest(
            task=task,
            context=context or {},
            sender=sender,
        )

        # 1. Local handler
        handler = self._handlers.get(agent_name)
        if handler:
            try:
                return await handler(request)
            except Exception as e:
                return AgentResponse(
                    result="",
                    success=False,
                    error=str(e),
                )

        # 2. HTTP dispatch via AgentCard.endpoint
        if card.endpoint and card.endpoint != "local":
            try:
                validate_outbound_url(
                    card.endpoint,
                    allow_private=self._allow_private,
                )
                async with httpx.AsyncClient(timeout=request.timeout_seconds) as client:
                    resp = await client.post(
                        card.endpoint,
                        json=request.to_dict(),
                        headers={"Content-Type": "application/json"},
                    )
                    resp.raise_for_status()
                    data = resp.json()
                    return AgentResponse(
                        result=data.get("result", ""),
                        success=data.get("success", True),
                        error=data.get("error", ""),
                        metadata=data.get("metadata", {}),
                        tokens_used=data.get("tokens_used", 0),
                    )
            except Exception as e:
                return AgentResponse(
                    result="",
                    success=False,
                    error=f"HTTP call to {card.endpoint} failed: {e}",
                )

        # 3. Placeholder
        return AgentResponse(
            result=f"[Agent {agent_name} would process: {task}]",
            success=True,
            metadata={"placeholder": True},
        )

    async def broadcast(
        self,
        capability: AgentCapability,
        task: str,
        sender: str = "",
        *,
        max_depth: int = 3,
        _visited: set[str] | None = None,
        _depth: int = 0,
    ) -> list[AgentResponse]:
        """Broadcast request to all agents with a capability.

        Includes cycle detection (skips agents already visited) and a
        configurable depth limit to prevent amplification when agents
        re-broadcast back.

        Args:
            capability: Filter agents by this capability.
            task: Task description for each agent.
            sender: Sender identity string.
            max_depth: Maximum broadcast depth (default 3).
        """
        if _depth >= max_depth:
            logger.warning("Broadcast depth limit (%d) reached", max_depth)
            return []

        agents = self.registry.find_by_capability(capability)
        visited = _visited if _visited is not None else set()
        responses: list[AgentResponse] = []

        for agent in agents:
            if agent.name in visited:
                continue
            visited.add(agent.name)

            response = await self.call(agent.name, task, sender=sender)
            responses.append(response)

        return responses
```

## `loopy.a2a.A2AError` (class)

Raised when an A2A protocol operation fails.

v0.9.0 — used for malformed Agent Cards, unknown task
lifecycle states, and invalid task IDs.

```python
class A2AError(Exception):
    """Raised when an A2A protocol operation fails.

    v0.9.0 — used for malformed Agent Cards, unknown task
    lifecycle states, and invalid task IDs.
    """
```

## `loopy.a2a.A2ATask` (class)

A2A v1.0 task lifecycle record.

States: ``submitted`` → ``working`` → (``completed`` | ``failed`` |
``canceled`` | ``rejected``), with ``input-required`` as an
asynchronous pause that carries a question artifact for the
human to answer.

```python
@dataclass
class A2ATask:
    """A2A v1.0 task lifecycle record.

    States: ``submitted`` → ``working`` → (``completed`` | ``failed`` |
    ``canceled`` | ``rejected``), with ``input-required`` as an
    asynchronous pause that carries a question artifact for the
    human to answer.
    """

    id: str
    state: str
    artifacts: list[dict[str, Any]] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if self.state not in _TASK_STATES:
            raise ValueError(
                f"A2ATask.state must be one of {sorted(_TASK_STATES)}; got {self.state!r}"
            )

    def to_dict(self) -> dict[str, Any]:
        return {
            "id": self.id,
            "state": self.state,
            "artifacts": list(self.artifacts),
            "metadata": dict(self.metadata),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> A2ATask:
        return cls(
            id=data["id"],
            state=data["state"],
            artifacts=list(data.get("artifacts", [])),
            metadata=dict(data.get("metadata", {})),
        )
```

## `loopy.a2a.AgentCapability` (class)

Agent capabilities for discovery.

```python
class AgentCapability(str, Enum):
    """Agent capabilities for discovery."""

    TEXT_GENERATION = "text_generation"
    CODE_GENERATION = "code_generation"
    DATA_ANALYSIS = "data_analysis"
    IMAGE_GENERATION = "image_generation"
    TRANSLATION = "translation"
    SUMMARIZATION = "summarization"
    RESEARCH = "research"
    CUSTOM = "custom"
```

## `loopy.a2a.AgentCard` (class)

Agent identity card for discovery.

Two shapes live in this dataclass:

* **legacy** — used by :class:`AgentRegistry` and the
  :class:`A2AClient` ``call`` / ``broadcast`` paths. The card
  carries ``capabilities`` (list[AgentCapability]) and an
  ``endpoint`` URL.
* **A2A v1.0** — used by :meth:`A2AClient.fetch_agent_card` and
  :meth:`A2AClient.from_agent_card`. The card carries
  ``skills`` (list[dict]) and a top-level ``url``.

The fields are unioned here so one dataclass satisfies both
consumers; the :class:`A2AClient` chooses the right shape per
use. Default factories keep both shapes constructible.

```python
@dataclass
class AgentCard:
    """Agent identity card for discovery.

    Two shapes live in this dataclass:

    * **legacy** — used by :class:`AgentRegistry` and the
      :class:`A2AClient` ``call`` / ``broadcast`` paths. The card
      carries ``capabilities`` (list[AgentCapability]) and an
      ``endpoint`` URL.
    * **A2A v1.0** — used by :meth:`A2AClient.fetch_agent_card` and
      :meth:`A2AClient.from_agent_card`. The card carries
      ``skills`` (list[dict]) and a top-level ``url``.

    The fields are unioned here so one dataclass satisfies both
    consumers; the :class:`A2AClient` chooses the right shape per
    use. Default factories keep both shapes constructible.
    """

    name: str
    description: str = ""
    version: str = "0.0.0"
    capabilities: list[AgentCapability] = field(default_factory=list)
    endpoint: str = "local"
    # A2A v1.0 fields:
    url: str = ""
    skills: list[dict[str, Any]] = field(default_factory=list)
    provider: str = ""
    authentication: str = "none"  # none, api_key, oauth2, openIdConnect
    pricing: str = "free"  # free, per_token, per_request
    metadata: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        """Serialize to a JSON-compatible dict (legacy shape)."""
        return {
            "name": self.name,
            "description": self.description,
            "version": self.version,
            "capabilities": [c.value for c in self.capabilities],
            "endpoint": self.endpoint,
            "authentication": self.authentication,
            "pricing": self.pricing,
            "metadata": self.metadata,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> AgentCard:
        """Deserialize from a legacy dict created by :meth:`to_dict`."""
        return cls(
            name=data["name"],
            description=data.get("description", ""),
            version=data["version"],
            capabilities=[AgentCapability(c) for c in data.get("capabilities", [])],
            endpoint=data.get("endpoint", "local"),
            authentication=data.get("authentication", "none"),
            pricing=data.get("pricing", "free"),
            metadata=data.get("metadata", {}),
        )
```

## `loopy.a2a.AgentRegistry` (class)

Registry of available agents for discovery.

Example:
    registry = AgentRegistry()
    registry.register(my_agent_card)
    agents = registry.find_by_capability(AgentCapability.CODE_GENERATION)

```python
class AgentRegistry:
    """
    Registry of available agents for discovery.

    Example:
        registry = AgentRegistry()
        registry.register(my_agent_card)
        agents = registry.find_by_capability(AgentCapability.CODE_GENERATION)
    """

    def __init__(self):
        self._agents: dict[str, AgentCard] = {}

    def register(self, card: AgentCard) -> None:
        """Register an agent."""
        self._agents[card.name] = card
        logger.info("Registered agent: %s", card.name)

    def unregister(self, name: str) -> None:
        """Unregister an agent."""
        self._agents.pop(name, None)

    def get(self, name: str) -> AgentCard | None:
        """Get agent by name."""
        return self._agents.get(name)

    def list_all(self) -> list[AgentCard]:
        """List all registered agents."""
        return list(self._agents.values())

    def find_by_capability(self, capability: AgentCapability) -> list[AgentCard]:
        """Find agents with a specific capability."""
        return [card for card in self._agents.values() if capability in card.capabilities]

    def find_by_pricing(self, pricing: str) -> list[AgentCard]:
        """Find agents by pricing model."""
        return [card for card in self._agents.values() if card.pricing == pricing]

    def to_dict(self) -> dict[str, Any]:
        """Export registry."""
        return {name: card.to_dict() for name, card in self._agents.items()}
```

## `loopy.a2a.AgentRequest` (class)

Request from one agent to another.

```python
@dataclass
class AgentRequest:
    """Request from one agent to another."""

    task: str
    context: dict[str, Any] = field(default_factory=dict)
    sender: str = ""
    request_id: str = ""
    max_tokens: int = 1000
    timeout_seconds: int = 30

    def to_dict(self) -> dict[str, Any]:
        """Serialize to a JSON-compatible dict."""
        return {
            "task": self.task,
            "context": self.context,
            "sender": self.sender,
            "request_id": self.request_id,
            "max_tokens": self.max_tokens,
            "timeout_seconds": self.timeout_seconds,
        }
```

## `loopy.a2a.AgentResponse` (class)

Response from an agent.

```python
@dataclass
class AgentResponse:
    """Response from an agent."""

    result: str
    success: bool = True
    error: str = ""
    metadata: dict[str, Any] = field(default_factory=dict)
    tokens_used: int = 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "result": self.result,
            "success": self.success,
            "error": self.error,
            "metadata": self.metadata,
            "tokens_used": self.tokens_used,
        }
```


# Module `loopy.lsp`

## LspServer

> Could not import `loopy.lsp.LspServer`: No module named 'loopy.lsp'


# Module `loopy.plugins.audio`

## `loopy.plugins.audio.AudioPlugin` (class)

Audio processing plugin for STT/TTS.

Example:
    plugin = AudioPlugin(api_key="sk-...")
    await registry.load(plugin)

    stt = plugin.stt
    tts = plugin.tts

    # Transcribe
    result = await stt.transcribe("recording.mp3")

    # Synthesize
    result = await tts.synthesize("Hello world!")

```python
class AudioPlugin(Plugin):
    """
    Audio processing plugin for STT/TTS.

    Example:
        plugin = AudioPlugin(api_key="sk-...")
        await registry.load(plugin)

        stt = plugin.stt
        tts = plugin.tts

        # Transcribe
        result = await stt.transcribe("recording.mp3")

        # Synthesize
        result = await tts.synthesize("Hello world!")
    """

    @property
    def info(self) -> PluginInfo:
        return PluginInfo(
            name="loopy-audio",
            version="0.4.0",
            description="Speech-to-text and text-to-speech for loopy",
            author="Dream Pixels Forge",
            capabilities=["tool", "audio"],
            requires=[],
        )

    async def setup(self, registry: PluginRegistry) -> None:
        """Initialize the Audio plugin."""
        self.config = AudioConfig()
        self.stt = SpeechToText(config=self.config)
        self.tts = TextToSpeech(config=self.config)

        # Register tools
        registry.register_tool("transcribe", self._transcribe)
        registry.register_tool("synthesize", self._synthesize)

        logger.info("Audio plugin initialized")

    async def _transcribe(self, audio_path: str, language: str | None = None) -> dict[str, Any]:
        """Transcribe audio to text."""
        result = await self.stt.transcribe(audio_path, language)
        return {
            "text": result.text,
            "language": result.language,
            "duration_ms": result.duration_ms,
        }

    async def _synthesize(self, text: str, output_path: str | None = None) -> dict[str, Any]:
        """Synthesize text to speech."""
        result = await self.tts.synthesize(text, output_path)
        return {
            "audio_path": result.audio_path,
            "duration_ms": result.duration_ms,
        }
```

## `loopy.plugins.audio.SpeechToText` (class)

Speech-to-text transcription.

Example:
    stt = SpeechToText(api_key="sk-...")
    result = await stt.transcribe("audio.mp3")
    print(result.text)

```python
class SpeechToText:
    """
    Speech-to-text transcription.

    Example:
        stt = SpeechToText(api_key="sk-...")
        result = await stt.transcribe("audio.mp3")
        print(result.text)
    """

    def __init__(
        self,
        api_key: str | None = None,
        config: AudioConfig | None = None,
        provider_fn: Callable[[str, bytes], Awaitable[dict[str, Any]]] | None = None,
    ):
        self.api_key = api_key
        self.config = config or AudioConfig()
        self.provider_fn = provider_fn

    async def transcribe(
        self,
        audio_path: str,
        language: str | None = None,
    ) -> TranscriptionResult:
        """
        Transcribe an audio file.

        Args:
            audio_path: Path to audio file
            language: Optional language override

        Returns:
            TranscriptionResult with transcribed text
        """
        import time

        start_time = time.time()

        # Read audio file
        with open(audio_path, "rb") as f:
            audio_data = f.read()

        if self.provider_fn:
            # Use custom provider
            result = await self.provider_fn(audio_path, audio_data)
            return TranscriptionResult(
                text=result.get("text", ""),
                language=result.get("language", language or self.config.stt_language),
                duration_ms=(time.time() - start_time) * 1000,
                segments=result.get("segments", []),
            )

        # Default: simple mock for testing
        return TranscriptionResult(
            text=f"[Transcription of {audio_path}]",
            language=language or self.config.stt_language,
            duration_ms=(time.time() - start_time) * 1000,
        )
```

## `loopy.plugins.audio.TextToSpeech` (class)

Text-to-speech synthesis.

Example:
    tts = TextToSpeech(api_key="sk-...")
    result = await tts.synthesize("Hello world!", output_path="output.mp3")
    print(result.audio_path)

```python
class TextToSpeech:
    """
    Text-to-speech synthesis.

    Example:
        tts = TextToSpeech(api_key="sk-...")
        result = await tts.synthesize("Hello world!", output_path="output.mp3")
        print(result.audio_path)
    """

    def __init__(
        self,
        api_key: str | None = None,
        config: AudioConfig | None = None,
        provider_fn: Callable[[str, dict[str, Any]], Awaitable[bytes]] | None = None,
    ):
        self.api_key = api_key
        self.config = config or AudioConfig()
        self.provider_fn = provider_fn

    async def synthesize(
        self,
        text: str,
        output_path: str | None = None,
        voice: str | None = None,
        speed: float | None = None,
    ) -> SynthesisResult:
        """
        Synthesize speech from text.

        Args:
            text: Text to synthesize
            output_path: Optional output file path
            voice: Optional voice override
            speed: Optional speed override

        Returns:
            SynthesisResult with audio file path
        """
        import time

        start_time = time.time()

        voice = voice or self.config.tts_voice
        speed = speed or self.config.tts_speed
        output_path = output_path or "tts_output.mp3"

        if self.provider_fn:
            # Use custom provider
            audio_data = await self.provider_fn(
                text,
                {
                    "model": self.config.tts_model,
                    "voice": voice,
                    "speed": speed,
                },
            )

            with open(output_path, "wb") as f:
                f.write(audio_data)

            return SynthesisResult(
                audio_path=output_path,
                duration_ms=(time.time() - start_time) * 1000,
            )

        # Default: create placeholder file for testing
        with open(output_path, "wb") as f:
            f.write(b"")  # Empty placeholder

        return SynthesisResult(
            audio_path=output_path,
            duration_ms=(time.time() - start_time) * 1000,
        )
```

## `loopy.plugins.audio.AudioConfig` (class)

Configuration for audio processing.

```python
@dataclass
class AudioConfig:
    """Configuration for audio processing."""

    # STT settings
    stt_model: str = "whisper-1"
    stt_language: str = "en"

    # TTS settings
    tts_model: str = "tts-1"
    tts_voice: str = "alloy"
    tts_speed: float = 1.0

    # Provider
    provider: str = "openai"  # openai, elevenlabs, local
```

## `loopy.plugins.audio.TranscriptionResult` (class)

Result of speech-to-text transcription.

```python
@dataclass
class TranscriptionResult:
    """Result of speech-to-text transcription."""

    text: str
    language: str = ""
    duration_ms: float = 0
    segments: list[dict[str, Any]] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)
```

## `loopy.plugins.audio.SynthesisResult` (class)

Result of text-to-speech synthesis.

```python
@dataclass
class SynthesisResult:
    """Result of text-to-speech synthesis."""

    audio_path: str
    duration_ms: float = 0
    format: str = "mp3"
    metadata: dict[str, Any] = field(default_factory=dict)
```


# Module `loopy.plugins.memory`

## `loopy.plugins.memory.Memory` (class)

A single memory entry.

```python
@dataclass
class Memory:
    """A single memory entry."""

    id: str
    content: str
    category: str = "general"
    metadata: dict[str, Any] = field(default_factory=dict)
    importance: float = 0.5  # 0.0 to 1.0
    embedding: list[float] | None = None
    created_at: float = field(default_factory=time.time)
    last_accessed: float = field(default_factory=time.time)
    access_count: int = 0

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for storage."""
        return {
            "id": self.id,
            "content": self.content,
            "category": self.category,
            "metadata": self.metadata,
            "importance": self.importance,
            "created_at": self.created_at,
            "last_accessed": self.last_accessed,
            "access_count": self.access_count,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Memory:
        """Create from dictionary."""
        return cls(**data)
```

## `loopy.plugins.memory.MemoryPlugin` (class)

Long-term memory plugin for agents.

Provides persistent memory storage with search capabilities.

Example:
    plugin = MemoryPlugin(storage_path="./agent_memory.json")
    await registry.load(plugin)

    memory_store = plugin.memory_store

    # Store a memory
    await memory_store.add(Memory(
        id="user_pref_1",
        content="User prefers concise responses",
        category="preferences",
    ))

    # Recall
    memories = memory_store.recall("response style")

```python
class MemoryPlugin(Plugin):
    """
    Long-term memory plugin for agents.

    Provides persistent memory storage with search capabilities.

    Example:
        plugin = MemoryPlugin(storage_path="./agent_memory.json")
        await registry.load(plugin)

        memory_store = plugin.memory_store

        # Store a memory
        await memory_store.add(Memory(
            id="user_pref_1",
            content="User prefers concise responses",
            category="preferences",
        ))

        # Recall
        memories = memory_store.recall("response style")
    """

    @property
    def info(self) -> PluginInfo:
        return PluginInfo(
            name="loopy-memory",
            version="0.3.0",
            description="Long-term memory for loopy agents",
            author="Dream Pixels Forge",
            capabilities=["tool", "storage"],
            requires=[],
        )

    async def setup(self, registry: PluginRegistry) -> None:
        """Initialize the Memory plugin."""
        self.memory_store = MemoryStore()

        # Memory is a privileged store: reads are read-only, but writes are
        # side-effecting and require human approval (injection could
        # otherwise persist poisoned instructions into future sessions).
        registry.register_tool(
            "memory_store",
            self._store_memory,
            requires_approval=True,
            scope="side_effecting",
        )
        registry.register_tool(
            "memory_clear",
            self._clear_memories,
            requires_approval=True,
            scope="side_effecting",
        )
        registry.register_tool("memory_recall", self._recall_memories, scope="read_only")
        registry.register_tool("memory_list", self._list_memories, scope="read_only")

        logger.info("Memory plugin initialized")

    async def _store_memory(
        self,
        content: str,
        category: str = "general",
        importance: float = 0.5,
        metadata: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Store a new memory."""
        memory = Memory(
            id="",
            content=content,
            category=category,
            importance=importance,
            metadata=metadata or {},
        )
        await self.memory_store.add(memory)
        return {"id": memory.id, "status": "stored"}

    async def _recall_memories(
        self,
        query: str,
        category: str | None = None,
        top_k: int = 5,
    ) -> list[dict[str, Any]]:
        """Recall memories similar to the query."""
        memories = self.memory_store.recall(query, category, top_k)
        return [
            {
                "id": m.id,
                "content": m.content,
                "category": m.category,
                "importance": m.importance,
                "access_count": m.access_count,
            }
            for m in memories
        ]

    async def _list_memories(
        self,
        category: str | None = None,
    ) -> list[dict[str, Any]]:
        """List all memories."""
        memories = self.memory_store.list_all(category)
        return [
            {
                "id": m.id,
                "content": m.content,
                "category": m.category,
                "importance": m.importance,
            }
            for m in memories
        ]

    async def _clear_memories(self) -> dict[str, Any]:
        """Kill-switch: wipe all stored memories (approval-gated tool)."""
        count = await self.memory_store.clear()
        return {"status": "cleared", "removed": count}
```

## `loopy.plugins.memory.MemoryStore` (class)

Persistent memory storage with search capabilities.

Example:
    store = MemoryStore()

    # Store memories
    await store.add(Memory(
        id="pref_1",
        content="User prefers dark mode",
        category="preferences",
        importance=0.8,
    ))

    # Recall memories
    results = store.recall("user preferences")

```python
class MemoryStore:
    """
    Persistent memory storage with search capabilities.

    Example:
        store = MemoryStore()

        # Store memories
        await store.add(Memory(
            id="pref_1",
            content="User prefers dark mode",
            category="preferences",
            importance=0.8,
        ))

        # Recall memories
        results = store.recall("user preferences")
    """

    def __init__(self, storage_path: str | Path | None = None):
        self.memories: dict[str, Memory] = {}
        self.storage_path = Path(storage_path) if storage_path else None
        self._counter = 0
        self._dirty = False

        if self.storage_path and self.storage_path.exists():
            self._load()

    async def add(self, memory: Memory) -> None:
        """Add a memory."""
        if not memory.id:
            self._counter += 1
            memory.id = f"mem_{self._counter:08d}"

        self.memories[memory.id] = memory
        self._dirty = True
        await self._save()
        logger.debug("Added memory: %s", memory.id)

    def get(self, memory_id: str) -> Memory | None:
        """Get a memory by ID."""
        memory = self.memories.get(memory_id)
        if memory:
            memory.last_accessed = time.time()
            memory.access_count += 1
        return memory

    async def delete(self, memory_id: str) -> bool:
        """Delete a memory."""
        if memory_id in self.memories:
            del self.memories[memory_id]
            self._dirty = True
            await self._save()
            return True
        return False

    async def clear(self) -> int:
        """Kill-switch: delete every stored memory (returns count removed).

        Use when memory poisoning is suspected — a full reset beats
        piecemeal deletion.
        """
        count = len(self.memories)
        self.memories.clear()
        self._dirty = True
        await self._save()
        return count

    def recall(
        self,
        query: str,
        category: str | None = None,
        top_k: int = 5,
        min_importance: float = 0.0,
    ) -> list[Memory]:
        """
        Recall memories similar to the query.

        Args:
            query: Search query
            category: Filter by category
            top_k: Number of results
            min_importance: Minimum importance score

        Returns:
            List of matching memories
        """
        results = []

        for memory in self.memories.values():
            # Filter by category
            if category and memory.category != category:
                continue

            # Filter by importance
            if memory.importance < min_importance:
                continue

            # Simple keyword matching (could be enhanced with embeddings)
            score = self._score_memory(memory, query)
            if score > 0:
                results.append((memory, score))

        # Sort by score * importance
        results.sort(key=lambda x: -(x[1] * x[0].importance))

        # Update access stats (transient, not persisted)
        memories = [m for m, _ in results[:top_k]]
        for m in memories:
            m.last_accessed = time.time()
            m.access_count += 1

        return memories

    def _score_memory(self, memory: Memory, query: str) -> float:
        """Score a memory against a query."""
        query_words = set(query.lower().split())
        content_words = set(memory.content.lower().split())

        overlap = len(query_words & content_words)
        return overlap / max(len(query_words), 1)

    def list_all(self, category: str | None = None) -> list[Memory]:
        """List all memories, optionally filtered by category."""
        if category:
            return [m for m in self.memories.values() if m.category == category]
        return list(self.memories.values())

    def get_summary(self) -> dict[str, Any]:
        """Get summary of stored memories."""
        categories = {}
        for m in self.memories.values():
            categories[m.category] = categories.get(m.category, 0) + 1

        return {
            "total_memories": len(self.memories),
            "categories": categories,
            "avg_importance": (
                sum(m.importance for m in self.memories.values()) / len(self.memories)
                if self.memories
                else 0
            ),
        }

    async def _save(self) -> None:
        """Save memories to disk only when state has changed."""
        if not self.storage_path or not self._dirty:
            return

        self._dirty = False
        self.storage_path.parent.mkdir(parents=True, exist_ok=True)
        data = [m.to_dict() for m in self.memories.values()]

        def _write() -> None:
            with open(self.storage_path, "w") as f:
                json.dump(data, f, indent=2)

        await asyncio.to_thread(_write)

    def _load(self) -> None:
        """Load memories from disk."""
        if not self.storage_path or not self.storage_path.exists():
            return

        try:
            with open(self.storage_path) as f:
                data = json.load(f)

            for item in data:
                memory = Memory.from_dict(item)
                self.memories[memory.id] = memory

            logger.info("Loaded %d memories from %s", len(self.memories), self.storage_path)
        except Exception as e:
            logger.error("Failed to load memories: %s", e)
```


# Module `loopy.plugins.rag`

## `loopy.plugins.rag.Document` (class)

A document in the RAG store.

```python
@dataclass
class Document:
    """A document in the RAG store."""

    id: str
    content: str
    metadata: dict[str, Any] = field(default_factory=dict)
    embedding: list[float] | None = None
    created_at: float = field(default_factory=time.time)

    @classmethod
    def from_text(cls, text: str, metadata: dict[str, Any] | None = None) -> Document:
        """Create a document from text with auto-generated ID."""
        doc_id = hashlib.sha256(text.encode()).hexdigest()[:12]
        return cls(
            id=doc_id,
            content=text,
            metadata=metadata or {},
        )
```

## `loopy.plugins.rag.RAGPlugin` (class)

Retrieval-Augmented Generation plugin.

Provides document storage, embedding, and retrieval for RAG workflows.

Example:
    plugin = RAGPlugin()
    await registry.load(plugin)

    retriever = registry.get_tool("rag_retrieve")
    results = await retriever("What is Python?")

```python
class RAGPlugin(Plugin):
    """
    Retrieval-Augmented Generation plugin.

    Provides document storage, embedding, and retrieval for RAG workflows.

    Example:
        plugin = RAGPlugin()
        await registry.load(plugin)

        retriever = registry.get_tool("rag_retrieve")
        results = await retriever("What is Python?")
    """

    @property
    def info(self) -> PluginInfo:
        return PluginInfo(
            name="loopy-rag",
            version="0.3.0",
            description="Retrieval-Augmented Generation for loopy",
            author="Dream Pixels Forge",
            capabilities=["tool", "retriever"],
            requires=[],
        )

    async def setup(self, registry: PluginRegistry) -> None:
        """Initialize the RAG plugin."""
        self.retriever = Retriever()

        # Register tools
        registry.register_tool("rag_add", self._add_document)
        registry.register_tool("rag_search", self._search)
        registry.register_tool("rag_retrieve", self._retrieve_context)

        logger.info("RAG plugin initialized")

    async def _add_document(
        self,
        content: str,
        metadata: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Add a document to the RAG store."""
        doc = Document.from_text(content, metadata)
        self.retriever.add(doc)
        return {"id": doc.id, "status": "added"}

    async def _search(
        self,
        query: str,
        top_k: int = 5,
    ) -> list[dict[str, Any]]:
        """Search for similar documents."""
        results = await self.retriever.search(query, top_k)
        return [
            {
                "rank": r.rank,
                "score": r.score,
                "content": r.document.content,
                "metadata": r.document.metadata,
            }
            for r in results
        ]

    async def _retrieve_context(
        self,
        query: str,
        top_k: int = 3,
    ) -> str:
        """Retrieve context for RAG augmentation."""
        results = await self.retriever.search(query, top_k)

        if not results:
            return "No relevant context found."

        context_parts = []
        for r in results:
            context_parts.append(f"[Source {r.rank}] {r.document.content}")

        return "\n\n".join(context_parts)
```

## `loopy.plugins.rag.Retriever` (class)

Document retriever with vector similarity search.

Example:
    retriever = Retriever()

    # Add documents
    retriever.add(Document.from_text("Python is a programming language"))
    retriever.add(Document.from_text("JavaScript is used for web development"))

    # Search
    results = retriever.search("programming", top_k=5)
    for result in results:
        print(f"{result.score:.3f}: {result.document.content[:50]}")

```python
class Retriever:
    """
    Document retriever with vector similarity search.

    Example:
        retriever = Retriever()

        # Add documents
        retriever.add(Document.from_text("Python is a programming language"))
        retriever.add(Document.from_text("JavaScript is used for web development"))

        # Search
        results = retriever.search("programming", top_k=5)
        for result in results:
            print(f"{result.score:.3f}: {result.document.content[:50]}")
    """

    def __init__(self, embed_fn: Callable[[str], Awaitable[list[float]]] | None = None):
        self.documents: dict[str, Document] = {}
        self.embed_fn = embed_fn

    def add(self, document: Document) -> None:
        """Add a document to the store."""
        self.documents[document.id] = document
        logger.debug("Added document: %s", document.id)

    def add_many(self, documents: list[Document]) -> None:
        """Add multiple documents."""
        for doc in documents:
            self.add(doc)

    def get(self, doc_id: str) -> Document | None:
        """Get a document by ID."""
        return self.documents.get(doc_id)

    def delete(self, doc_id: str) -> bool:
        """Delete a document."""
        if doc_id in self.documents:
            del self.documents[doc_id]
            return True
        return False

    def list_all(self) -> list[Document]:
        """List all documents."""
        return list(self.documents.values())

    async def search(
        self,
        query: str,
        top_k: int = 5,
        min_score: float = 0.0,
    ) -> list[SearchResult]:
        """
        Search for documents similar to the query.

        Args:
            query: Search query
            top_k: Number of results to return
            min_score: Minimum similarity score

        Returns:
            List of SearchResult objects
        """
        if not self.documents:
            return []

        # If we have an embed function, use vector search
        if self.embed_fn:
            return await self._vector_search(query, top_k, min_score)

        # Fallback to keyword search
        return self._keyword_search(query, top_k, min_score)

    async def _vector_search(
        self,
        query: str,
        top_k: int,
        min_score: float,
    ) -> list[SearchResult]:
        """Vector similarity search using embeddings."""
        query_embedding = await self.embed_fn(query)

        results = []
        for doc in self.documents.values():
            if doc.embedding is None:
                # Generate embedding if not present
                doc.embedding = await self.embed_fn(doc.content)

            score = self._cosine_similarity(query_embedding, doc.embedding)
            if score >= min_score:
                results.append(SearchResult(document=doc, score=score))

        # Sort by score descending
        results.sort(key=lambda r: -r.score)

        # Assign ranks and return top_k
        for i, result in enumerate(results[:top_k]):
            result.rank = i + 1

        return results[:top_k]

    def _keyword_search(
        self,
        query: str,
        top_k: int,
        min_score: float,
    ) -> list[SearchResult]:
        """Simple keyword-based search."""
        query_words = set(query.lower().split())

        results = []
        for doc in self.documents.values():
            doc_words = set(doc.content.lower().split())
            overlap = len(query_words & doc_words)
            score = overlap / max(len(query_words), 1)

            if score >= min_score:
                results.append(SearchResult(document=doc, score=score))

        results.sort(key=lambda r: -r.score)

        for i, result in enumerate(results[:top_k]):
            result.rank = i + 1

        return results[:top_k]

    def _cosine_similarity(self, a: list[float], b: list[float]) -> float:
        """Calculate cosine similarity between two vectors."""
        if len(a) != len(b):
            return 0.0

        dot_product = sum(x * y for x, y in zip(a, b, strict=True))
        norm_a = sum(x * x for x in a) ** 0.5
        norm_b = sum(x * x for x in b) ** 0.5

        if norm_a == 0 or norm_b == 0:
            return 0.0

        return dot_product / (norm_a * norm_b)
```


# Module `loopy.plugins.tools`

## `loopy.plugins.tools.Tool` (class)

A tool that agents can use.

Security-relevant fields: deny-by-default, least privilege, and
human-in-the-loop enforcement live on the tool. ``scope``, ``enabled``,
``requires_approval`` and ``allowed_values`` are checked by
:meth:`ToolRegistry.execute` before a handler ever runs.

```python
@dataclass
class Tool:
    """A tool that agents can use.

    Security-relevant fields: deny-by-default, least privilege, and
    human-in-the-loop enforcement live on the tool. ``scope``, ``enabled``,
    ``requires_approval`` and ``allowed_values`` are checked by
    :meth:`ToolRegistry.execute` before a handler ever runs.
    """

    name: str
    description: str
    handler: Callable[..., Awaitable[Any]]
    parameters: list[ToolParameter] = field(default_factory=list)

    # --- capability scoping (security) ---
    scope: str = "side_effecting"  # "read_only" | "side_effecting"
    enabled: bool = True  # deny-by-default — False = never executes
    requires_approval: bool = False  # HITL gate, enforced in execute()
    # Enumerate legal values per parameter (allow-list for free-text args)
    allowed_values: dict[str, set[str]] | None = None

    def is_read_only(self) -> bool:
        """Return True if the tool has no side effects."""
        return self.scope == "read_only"

    def to_schema(self) -> dict[str, Any]:
        """Convert to OpenAI function calling schema."""
        properties = {}
        required = []

        for param in self.parameters:
            properties[param.name] = param.to_schema()
            if param.required:
                required.append(param.name)

        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": {
                    "type": "object",
                    "properties": properties,
                    "required": required,
                },
            },
        }
```

## `loopy.plugins.tools.ToolResult` (class)

Result of a tool execution.

```python
@dataclass
class ToolResult:
    """Result of a tool execution."""

    success: bool
    output: Any = None
    error: str | None = None
    duration_ms: float = 0
    metadata: dict[str, Any] = field(default_factory=dict)
```

## `loopy.plugins.tools.ToolsPlugin` (class)

Tool-use plugin for function calling.

Provides a registry for tools that agents can use during execution.

Example:
    plugin = ToolsPlugin()
    await registry.load(plugin)

    tool_registry = plugin.tool_registry

    # Register custom tools
    tool_registry.register(Tool(
        name="calculate",
        description="Perform a calculation",
        handler=calculate_fn,
    ))

    # Execute
    result = await tool_registry.execute("calculate", {"expression": "2+2"})

```python
class ToolsPlugin(Plugin):
    """
    Tool-use plugin for function calling.

    Provides a registry for tools that agents can use during execution.

    Example:
        plugin = ToolsPlugin()
        await registry.load(plugin)

        tool_registry = plugin.tool_registry

        # Register custom tools
        tool_registry.register(Tool(
            name="calculate",
            description="Perform a calculation",
            handler=calculate_fn,
        ))

        # Execute
        result = await tool_registry.execute("calculate", {"expression": "2+2"})
    """

    @property
    def info(self) -> PluginInfo:
        return PluginInfo(
            name="loopy-tools",
            version="0.3.0",
            description="Tool-use and function calling for loopy agents",
            author="Dream Pixels Forge",
            capabilities=["tool", "registry"],
            requires=[],
        )

    async def setup(self, registry: PluginRegistry) -> None:
        """Initialize the Tools plugin."""
        self.tool_registry = ToolRegistry()

        # Register built-in tools
        self._register_builtins()

        # Register read-only registry introspection tools (agent-visible).
        # NOTE: a universal 'execute_tool' meta-tool is deliberately NOT
        # registered — it would grant the model arbitrary execution over the
        # whole registry (excessive agency). Executing a tool is the caller's
        # job via ToolRegistry.execute(), which enforces capability gates.
        registry.register_tool("list_tools", self._list_tools, scope="read_only")
        registry.register_tool("get_tool_schema", self._get_tool_schema, scope="read_only")

        logger.info("Tools plugin initialized")

    def _register_builtins(self) -> None:
        """Register built-in tools (read-only, no approval needed)."""
        # Calculator tool
        self.tool_registry.register(
            Tool(
                name="calculator",
                description="Perform basic arithmetic calculations",
                handler=self._calculator,
                scope="read_only",
                parameters=[
                    ToolParameter(
                        name="expression",
                        type="string",
                        description="Math expression (e.g., '2 + 2')",
                    ),
                ],
            )
        )

        # JSON parser tool
        self.tool_registry.register(
            Tool(
                name="parse_json",
                description="Parse a JSON string",
                handler=self._parse_json,
                scope="read_only",
                parameters=[
                    ToolParameter(name="text", type="string", description="JSON string to parse"),
                ],
            )
        )

    async def _calculator(self, expression: str) -> Any:
        """Calculate a math expression using an AST whitelist (no ``eval``).

        Only numeric literals, ``+ - * / % ** //`` and unary ``+/-`` are
        accepted. Attribute access, calls, names, and comprehensions are
        rejected outright, so arbitrary code cannot run.

        Raises:
            ValueError: If the expression uses unsupported syntax.
        """
        return {"result": _eval_math(expression), "expression": expression}

    async def _parse_json(self, text: str) -> Any:
        """Parse JSON text."""
        return json.loads(text)

    async def _execute_tool(
        self,
        name: str,
        arguments: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Execute a tool via the registry (operator use only).

        Not registered as an agent-visible tool — intended for programmatic
        / operator callers. Capability gates are enforced by
        :meth:`ToolRegistry.execute`.
        """
        result = await self.tool_registry.execute(name, arguments or {})
        return {
            "success": result.success,
            "output": result.output,
            "error": result.error,
            "duration_ms": result.duration_ms,
        }

    async def _list_tools(self) -> list[dict[str, Any]]:
        """List all available tools."""
        return [
            {
                "name": tool.name,
                "description": tool.description,
                "parameters": len(tool.parameters),
            }
            for tool in self.tool_registry.list_all()
        ]

    async def _get_tool_schema(self, name: str) -> dict[str, Any] | None:
        """Get a tool's schema."""
        tool = self.tool_registry.get(name)
        if tool:
            return tool.to_schema()
        return None
```
