# 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] = []

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

        Returns:
            List of StepResult for each iteration.
        """
        self.history = []

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

        # v0.7.8 — resume support: skip ahead to resume_from when set
        if self.config.resume_from is not None:
            start_step = max(1, self.config.resume_from + 1)
            logger.info("Resuming loop at step %d", start_step)
        else:
            start_step = 1

        for step_num in range(start_step, self.config.max_steps + 1):
            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

        return self.history

    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)

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

        try:
            # PLAN
            if self.config.planner:
                result.plan = await self.config.planner(self.history)
                logger.debug("Step %d plan: %s...", step_num, result.plan[:100])

            # ACT
            result.status = StepStatus.ACTING
            if self.config.actor:
                result.action = await self.config.actor(result.plan)
                logger.debug("Step %d action: %s...", step_num, result.action[:100])

            # OBSERVE
            result.status = StepStatus.OBSERVING
            if self.config.observer:
                result.observation = await self.config.observer(result.action)
                logger.debug("Step %d observation: %s...", step_num, result.observation[:100])

            # REFLECT
            result.status = StepStatus.REFLECTING
            if self.config.reflector:
                result.reflection = await self.config.reflector(self.history)
                logger.debug("Step %d reflection: %s...", step_num, result.reflection[:100])

            result.status = StepStatus.COMPLETE

        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.CostReport` (class)

Report of token usage.

```python
@dataclass
class CostReport:
    """Report of token usage."""

    used: int
    limit: int
    remaining: int
    usage_percent: float

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

## `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] = {}

        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)

    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,
        )

    def reset(self) -> None:
        """Reset daily usage."""
        self._usage.clear()
        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.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):
        self.providers: dict[str, ProviderConfig] = {}
        self._pool: ConnectionPool = ConnectionPool()
        self._logs: list[dict[str, Any]] = []

    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.")

    # 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,
        **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:
            return await self._call_test(
                test_model,
                message,
                system,
                temperature,
                max_tokens,
                response_format,
            )

        provider, config = self._resolve_provider(provider)

        # 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()
```

## 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
```

## `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.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]
```

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

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

    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"
```

## `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)

    @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.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
```

## `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

    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
        """
        trace_id = self._current_trace_id or self._generate_id()
        span_id = self._generate_id()

        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 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`.


# 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] = []

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

        Returns:
            List of StepResult for each iteration.
        """
        self.history = []

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

        # v0.7.8 — resume support: skip ahead to resume_from when set
        if self.config.resume_from is not None:
            start_step = max(1, self.config.resume_from + 1)
            logger.info("Resuming loop at step %d", start_step)
        else:
            start_step = 1

        for step_num in range(start_step, self.config.max_steps + 1):
            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

        return self.history

    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)

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

        try:
            # PLAN
            if self.config.planner:
                result.plan = await self.config.planner(self.history)
                logger.debug("Step %d plan: %s...", step_num, result.plan[:100])

            # ACT
            result.status = StepStatus.ACTING
            if self.config.actor:
                result.action = await self.config.actor(result.plan)
                logger.debug("Step %d action: %s...", step_num, result.action[:100])

            # OBSERVE
            result.status = StepStatus.OBSERVING
            if self.config.observer:
                result.observation = await self.config.observer(result.action)
                logger.debug("Step %d observation: %s...", step_num, result.observation[:100])

            # REFLECT
            result.status = StepStatus.REFLECTING
            if self.config.reflector:
                result.reflection = await self.config.reflector(self.history)
                logger.debug("Step %d reflection: %s...", step_num, result.reflection[:100])

            result.status = StepStatus.COMPLETE

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

## `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.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):
        self.providers: dict[str, ProviderConfig] = {}
        self._pool: ConnectionPool = ConnectionPool()
        self._logs: list[dict[str, Any]] = []

    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.")

    # 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,
        **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:
            return await self._call_test(
                test_model,
                message,
                system,
                temperature,
                max_tokens,
                response_format,
            )

        provider, config = self._resolve_provider(provider)

        # 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

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

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

    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
        """
        trace_id = self._current_trace_id or self._generate_id()
        span_id = self._generate_id()

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

    @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"
```


# 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"
```


# 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] = {}

        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)

    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,
        )

    def reset(self) -> None:
        """Reset daily usage."""
        self._usage.clear()
        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.

```python
@dataclass
class CostReport:
    """Report of token usage."""

    used: int
    limit: int
    remaining: int
    usage_percent: float

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

## `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"
```
