# Skill Refresh Hooks

This document covers the **M4 skill-registry refresh** feature: dynamic recomputation
of skill tool sets triggered by `notifications/tools/list_changed` events from MCP
servers.

**Module:** `cleveragents.skills.refresh`, `cleveragents.skills.registry`,
`cleveragents.mcp.refresh_hook`

---

## Overview

| Component | Purpose |
|-----------|---------|
| `SkillRefreshResult` | Immutable summary of a refresh operation |
| `SkillRegistry.refresh(name)` | Recompute a single skill's tool set |
| `SkillRegistry.refresh_all()` | Recompute all registered skills |
| `MCPRefreshHook` | Wire MCP notifications to `refresh_all()` with debouncing |

When an MCP server's tool list changes, the adapter broadcasts a
`notifications/tools/list_changed` notification. `MCPRefreshHook` listens for this
event and triggers `SkillRegistry.refresh_all()` after a configurable debounce
window, ensuring rapid successive notifications collapse into a single refresh.

---

## SkillRefreshResult

**Module:** `cleveragents.skills.refresh`  
**Export:** `cleveragents.skills.SkillRefreshResult`

An immutable (`frozen=True`) dataclass summarising the outcome of one or more
refresh operations.

### Fields

| Field | Type | Description |
|-------|------|-------------|
| `refreshed` | `list[str]` | Skill names successfully recomputed |
| `failed` | `dict[str, str]` | Mapping of skill name → error message for each failure |
| `skipped` | `list[str]` | Skill names skipped (tool registry unavailable) |

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `total_refreshed` | `int` | `len(refreshed)` |
| `total_failed` | `int` | `len(failed)` |
| `total_skipped` | `int` | `len(skipped)` |

### Methods

#### `to_summary() → str`

Returns a human-readable one-liner suitable for CLI output or log messages.

```
"refreshed: 3, failed: 1, skipped: 0"
```

#### `merge(other: SkillRefreshResult) → SkillRefreshResult`

Combines two results into one by concatenating the `refreshed` and `skipped`
lists and merging the `failed` dicts. Used internally by `refresh_all()` to
aggregate per-skill results.

### Example

```python
from cleveragents.skills import SkillRefreshResult

r1 = SkillRefreshResult(refreshed=["ns/skill-a"], failed={}, skipped=[])
r2 = SkillRefreshResult(refreshed=[], failed={"ns/skill-b": "tool missing"}, skipped=[])
merged = r1.merge(r2)
print(merged.to_summary())  # "refreshed: 1, failed: 1, skipped: 0"
```

---

## SkillRegistry Refresh Methods

**Module:** `cleveragents.skills.registry`

### `refresh(name: str) → SkillRefreshResult`

Recomputes the flattened tool set for a single registered skill and validates tool
references against the `ToolRegistry` (if one is configured on the registry).

**Parameters**

| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | `str` | Namespaced skill name to refresh |

**Returns:** `SkillRefreshResult` — skill appears in `refreshed`, `failed`, or
`skipped` depending on the outcome.

**Raises:** `SkillExecutionError` with `SKILL_NOT_FOUND` if `name` is not registered.

**Behaviour**

| Condition | Outcome |
|-----------|---------|
| Resolution succeeds, tool registry present, all refs found | `refreshed = [name]` |
| Resolution succeeds, tool registry present, refs missing | `failed = {name: "<missing refs>"}` |
| Resolution succeeds, **no tool registry** | `refreshed = [name]`, warning logged |
| Resolution fails (cycle, missing include) | `failed = {name: "<error>"}` |

When no `ToolRegistry` is configured, a `WARNING`-level log message is emitted
with recovery instructions (pass a `ToolRegistry` to `SkillRegistry.__init__`).
The skill is still counted as refreshed so callers are not permanently blocked.

### `refresh_all() → SkillRefreshResult`

Iterates over all registered skills, calls `refresh(name)` for each, and returns
an aggregated `SkillRefreshResult`.

**Returns:** Aggregated `SkillRefreshResult` across all skills.

**Example**

```python
from cleveragents.skills.registry import SkillRegistry
from cleveragents.tool.registry import ToolRegistry

tool_reg = ToolRegistry()
# ... register tools ...

skill_reg = SkillRegistry(tool_registry=tool_reg)
# ... register skills ...

result = skill_reg.refresh_all()
print(result.to_summary())  # e.g. "refreshed: 5, failed: 0, skipped: 0"
```

---

## MCPRefreshHook

**Module:** `cleveragents.mcp.refresh_hook`  
**Export:** `cleveragents.mcp.MCPRefreshHook`

Connects an `MCPToolAdapter` to a `SkillRegistry` via the adapter's notification
listener mechanism.  On receiving `notifications/tools/list_changed`, it schedules
a debounced call to `skill_registry.refresh_all()`.

### Constructor

```python
MCPRefreshHook(
    adapter: MCPToolAdapter,
    skill_registry: SkillRegistry,
    debounce_seconds: float = 0.5,
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `adapter` | `MCPToolAdapter` | — | Adapter whose notifications trigger refresh |
| `skill_registry` | `SkillRegistry` | — | Registry to refresh on notification |
| `debounce_seconds` | `float` | `0.5` | Seconds to wait after the **last** notification before triggering refresh |

**Raises:** `ValueError` if `debounce_seconds < 0`.

### Debounce Behaviour

Rapid successive `notifications/tools/list_changed` events within the debounce
window are coalesced: each new notification resets the countdown timer, so only
one `refresh_all()` fires once the storm subsides.

```
notification → reset timer (0.5 s)
notification → reset timer (0.5 s)   ← only this one fires
notification → reset timer (0.5 s)
                                     0.5 s later → refresh_all() ×1
```

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `refresh_count` | `int` | Number of times `refresh_all()` has been triggered (thread-safe) |

### Methods

#### `cancel() → None`

Cancels any pending debounced refresh timer. Safe to call multiple times and from
any thread. Does **not** un-register the notification listener from the adapter.

### Example

```python
from cleveragents.mcp import MCPRefreshHook
from cleveragents.mcp.adapter import MCPServerConfig, MCPToolAdapter
from cleveragents.skills.registry import SkillRegistry

adapter = MCPToolAdapter(config=MCPServerConfig(name="my-server", transport="stdio", command="my-mcp"))
skill_registry = SkillRegistry()

hook = MCPRefreshHook(
    adapter=adapter,
    skill_registry=skill_registry,
    debounce_seconds=0.5,
)

# ... adapter receives notifications automatically during normal operation ...

# Clean up when done
hook.cancel()
```

### Thread Safety

`MCPRefreshHook` uses `threading.Lock` internally. The `_on_notification` callback,
`_do_refresh`, `refresh_count`, and `cancel` are all safe to call from multiple
threads concurrently.

---

## MCPToolAdapter Notification API

**Module:** `cleveragents.mcp.adapter`

`MCPToolAdapter` exposes a generic notification listener API used by `MCPRefreshHook`.

### `add_notification_listener(callback)`

Registers a callable to receive all notifications dispatched by this adapter.

```python
def callback(method: str, params: dict[str, Any]) -> None: ...
```

### `dispatch_notification(method, params=None)`

Dispatches a notification to all registered listeners. Used internally when the
underlying MCP server sends a notification, and available for testing.

---

## Notification Flow

```
MCP Server
  │  notifications/tools/list_changed
  ▼
MCPToolAdapter.dispatch_notification()
  │
  ▼  (fan-out to all listeners)
MCPRefreshHook._on_notification()
  │  debounce_seconds timer
  ▼
SkillRegistry.refresh_all()
  │  per-skill
  ▼
SkillRegistry.refresh(name)
  │  validate via ToolRegistry
  ▼
SkillRefreshResult  →  logged at INFO
```

---

## Exports

### `cleveragents.skills`

```python
from cleveragents.skills import SkillRefreshResult
```

### `cleveragents.mcp`

```python
from cleveragents.mcp import MCPRefreshHook
```

---

## Error Reference

| Situation | Result field | Details |
|-----------|-------------|---------|
| Skill not registered | `SkillExecutionError` raised | `SKILL_NOT_FOUND` error type |
| Tool ref missing from registry | `failed[name]` | Lists missing ref names |
| Resolution cycle / missing include | `failed[name]` | Exception message |
| No `ToolRegistry` configured | `refreshed[name]` | `WARNING` log emitted |
| `debounce_seconds < 0` | `ValueError` raised | Constructor validation |
