Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,32 @@ synthesizer: claude

Then: `conclave ask "..." --council fast`.

## Result cache (optional, off by default)

Repeated or eval runs can be served from an on-disk cache instead of re-calling
the providers. It is **off by default** and **never persists API keys** — the
cache key is a SHA-256 over the normalized prompt, the ordered council members
(friendly name + resolved model id), the mode, the synthesizer/judge identity,
and the mode params (temperature, debate `rounds`, adversarial `proposer`). No
key value or env-var name ever reaches the key or the stored payload.

Enable it per run with `--cache` (or disable a config default with `--no-cache`):

```bash
conclave ask "..." --council fast --cache
```

or set a default in `~/.conclave/config.yml`:

```yaml
cache: true
```

A cache hit returns the prior `CouncilResult` with `cached: true` set and does
not touch the network. Entries live under `$XDG_CACHE_HOME/conclave` (else
`~/.cache/conclave`); a corrupt or unreadable entry is treated as a miss and
never crashes a run.

## Test

```bash
Expand Down
7 changes: 7 additions & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,10 @@ councils:

# Default synthesizer (friendly name). Overridable with `--synthesizer`.
synthesizer: claude

# Optional result cache (OFF by default). When true, an identical repeat run is
# served from an on-disk cache instead of re-calling the providers -- handy for
# repeated/eval runs. Override per invocation with `--cache` / `--no-cache`.
# The cache is keyed on (prompt, council, mode, model ids) and NEVER stores keys.
# Stored under $XDG_CACHE_HOME/conclave (else ~/.cache/conclave).
cache: false
201 changes: 201 additions & 0 deletions src/conclave/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""Optional on-disk result cache for council runs (off by default).

This is the §9 #4 roadmap item: an opt-in cache keyed on
``(prompt, council, mode, model ids)`` so repeated or eval runs are cheap. It is
**off by default** and **never persists key material** -- the cache key and the
stored payload are derived solely from the normalized prompt, the ordered council
member friendly-names + resolved model ids, the run mode, the synthesizer/judge
identity, and the mode parameters that affect output. No environment variable is
read here; no key value reaches the key string or the on-disk artifact.

Storage
=======
Entries live one-per-file under ``$XDG_CACHE_HOME/conclave`` (falling back to
``~/.cache/conclave``). Each file is named ``<sha256-hex>.json`` and holds the
JSON serialization of a :class:`conclave.models.CouncilResult` (via
``model_dump(mode="json")``), which by construction carries no secrets.

Graceful degradation
====================
A corrupt, unreadable, or schema-incompatible cache entry is treated as a **miss**
(logged at warning level), never an error: a bad cache file can never crash a run.
Writes that fail (e.g. a read-only cache dir) are likewise logged and swallowed --
caching is a best-effort optimization, never a correctness dependency.

Key-ordering choice
===================
Member order is **preserved** (not sorted) in the cache key. For ``synthesize`` /
``raw`` the member order does not change the output, but for ``debate`` and
``adversarial`` it does: the adversarial proposer defaults to the first member and
debate assigns stable letter labels by member position. Preserving order is
therefore the conservative, always-correct choice -- two runs collide only when
they would genuinely produce equivalent results.
"""

from __future__ import annotations

import hashlib
import json
import os
import re
from pathlib import Path

from pydantic import ValidationError

from .logging import get_logger
from .models import CouncilResult

logger = get_logger("cache")

# Bumped if the cache-key composition or stored schema changes incompatibly, so
# old entries simply miss instead of being mis-served against new code.
_CACHE_VERSION = 1

_WHITESPACE = re.compile(r"\s+")


def cache_dir() -> Path:
"""Return the conclave cache directory, honoring ``XDG_CACHE_HOME``.

Falls back to ``~/.cache/conclave`` when ``XDG_CACHE_HOME`` is unset or empty.
The directory is not created here; :func:`store` creates it lazily on write.
"""
xdg = os.environ.get("XDG_CACHE_HOME", "").strip()
base = Path(xdg) if xdg else Path.home() / ".cache"
return base / "conclave"


def _normalize_prompt(prompt: str) -> str:
"""Collapse runs of whitespace and strip ends for a stable prompt key.

Two prompts that differ only in incidental whitespace should hit the same
cache entry; semantic content is otherwise preserved verbatim.
"""
return _WHITESPACE.sub(" ", prompt).strip()


def make_key(
*,
prompt: str,
mode: str,
members: list[tuple[str, str]],
synthesizer: str | None,
synthesizer_model_id: str | None,
temperature: float,
rounds: int | None = None,
proposer: str | None = None,
) -> str:
"""Build the stable cache key (sha256 hex) for a council run.

The key is a SHA-256 over a canonical JSON document of only output-affecting,
secret-free identity:

* normalized prompt,
* run mode,
* ordered ``(friendly_name, resolved_model_id)`` member pairs (order matters --
see module docstring),
* synthesizer/judge friendly name + resolved model id,
* temperature, and mode params (``rounds`` for debate, ``proposer`` for
adversarial) when they apply.

Args:
prompt: The raw user prompt (normalized internally).
mode: ``"synthesize" | "raw" | "debate" | "adversarial"``.
members: Ordered ``(friendly_name, resolved_model_id)`` pairs actually run.
synthesizer: Synthesizer/judge friendly name (``None`` when not applicable).
synthesizer_model_id: Resolved synthesizer/judge model id.
temperature: Sampling temperature (affects output).
rounds: Debate round count (included only for ``debate``).
proposer: Adversarial proposer friendly name (included only for
``adversarial``).

Returns:
A 64-char lowercase hex SHA-256 digest. Contains zero key material.
"""
payload: dict[str, object] = {
"v": _CACHE_VERSION,
"prompt": _normalize_prompt(prompt),
"mode": mode,
# Pairs as lists so JSON round-trips; order preserved deliberately.
"members": [[name, model_id] for name, model_id in members],
"synthesizer": synthesizer,
"synthesizer_model_id": synthesizer_model_id,
"temperature": temperature,
}
if mode == "debate":
payload["rounds"] = rounds
if mode == "adversarial":
payload["proposer"] = proposer

canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def _entry_path(key: str) -> Path:
"""Map a cache key to its on-disk entry path."""
return cache_dir() / f"{key}.json"


def load(key: str) -> CouncilResult | None:
"""Return the cached :class:`CouncilResult` for ``key``, or ``None`` on miss.

A missing file is a normal miss (silent). A present-but-unreadable or
schema-incompatible file is a degraded miss: it is logged at warning level and
treated as absent so a corrupt entry can never crash a run.

Args:
key: The cache key from :func:`make_key`.

Returns:
The deserialized result with ``cached=True`` set, or ``None``.
"""
try:
path = _entry_path(key)
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except OSError as exc:
logger.warning("cache read failed for key %s: %s; treating as miss", key[:12], exc)
return None

try:
data = json.loads(raw)
result = CouncilResult.model_validate(data)
except (json.JSONDecodeError, ValidationError, TypeError) as exc:
logger.warning("corrupt cache entry %s: %s; treating as miss", path, exc)
return None

# Mark as cache-served so consumers can distinguish a hit from a live run.
result.cached = True
return result


def store(key: str, result: CouncilResult) -> None:
"""Persist ``result`` under ``key``, best-effort (failures are swallowed).

The cache directory is created lazily. Any write failure (read-only dir, disk
full, serialization error) is logged at warning level and ignored -- caching
must never turn a successful run into a failure. The stored payload is
``result.model_dump(mode="json")``, which carries no secrets.

The ``cached`` flag is normalized to ``False`` before writing so a stored
entry reflects how it was produced (live), not how it will later be served.

Args:
key: The cache key from :func:`make_key`.
result: The live :class:`CouncilResult` to persist.
"""
try:
path = _entry_path(key)
path.parent.mkdir(parents=True, exist_ok=True)
payload = result.model_dump(mode="json")
payload["cached"] = False
# Atomic-ish write: write to a temp sibling then replace, so a crash mid
# write never leaves a half-written (corrupt) entry behind.
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
except (OSError, TypeError, ValueError) as exc:
logger.warning(
"cache write failed for key %s: %s; continuing without caching", key[:12], exc
)
11 changes: 10 additions & 1 deletion src/conclave/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ def ask(
as_json: bool = typer.Option(
False, "--json", help="Emit the full result as JSON instead of panels."
),
cache: bool | None = typer.Option(
None,
"--cache/--no-cache",
help=(
"Use the on-disk result cache (off by default; defers to config when "
"unset). On a hit an identical prior run is returned without calling "
"the providers. The cache never stores API keys."
),
),
) -> None:
"""Fan PROMPT out to a council and synthesize, debate, or adversarially review.

Expand All @@ -190,7 +199,7 @@ def ask(
err_console.print(f"[red]No council members resolved from '{council}'.[/red]")
raise typer.Exit(code=2)

c = Council(models=members, synthesizer=synthesizer, config=cfg)
c = Council(models=members, synthesizer=synthesizer, config=cfg, cache=cache)
if mode_lower == "debate":
result = c.debate_sync(prompt, rounds=rounds)
elif mode_lower == "adversarial":
Expand Down
9 changes: 9 additions & 0 deletions src/conclave/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,17 @@ class ConclaveConfig(BaseModel):
councils: named lists of friendly names.
synthesizer: friendly name of the default synthesizer model.
endpoints: prefix -> custom OpenAI-compatible endpoint declaration.
cache: opt-in result cache (off by default). When ``True`` an identical
repeat run is served from the on-disk cache (see :mod:`conclave.cache`)
instead of re-calling the providers. The cache never persists keys.
A ``--cache/--no-cache`` CLI flag overrides this per invocation.
"""

models: dict[str, str] = Field(default_factory=dict)
councils: dict[str, list[str]] = Field(default_factory=dict)
synthesizer: str = DEFAULT_SYNTHESIZER
endpoints: dict[str, CustomEndpoint] = Field(default_factory=dict)
cache: bool = False

def resolve_model_id(self, name: str) -> str:
"""Map a friendly name to a provider-prefixed model id.
Expand Down Expand Up @@ -183,9 +188,13 @@ def _load_config_uncached(path: Path) -> ConclaveConfig:
if isinstance(spec, dict)
}

# Off by default; only an explicit truthy ``cache: true`` in the file enables it.
cache = bool(raw.get("cache", False))

return ConclaveConfig(
models=merged_models,
councils=councils,
synthesizer=synthesizer,
endpoints=endpoints,
cache=cache,
)
Loading
Loading