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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ htmlcov/
.env
.DS_Store

.cursor/
.cursor/
.pickled-cache/
94 changes: 94 additions & 0 deletions docs/decisions/0002-cache-and-budget-in-pickled-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# ADR-0002: Cache and budget in pickled.config.yaml

- **Status:** Accepted
- **Date:** 2026-05-19
- **Deciders:** pickled-spec contributors

## Context

`pickled-core` already ships disk-backed `LLMCache` and a `BudgetGuard` that
can cap cumulative LLM spend per process. Until now, leaf MCP servers and
package CLIs called `build_client(...)` directly without passing `cache=`, so
every `draft_*` and `validate_*` tool hit the live API on each rerun. No
bootstrap path installed a budget guard, so agent-driven loops had no
deterministic cost ceiling.

Dogfood workflows and Cursor-native MCP sessions repeat the same prompts
across iterations. Without wiring cache and budget at client construction,
token spend scales linearly with retries and a runaway tool loop can exhaust
quota before an operator notices.

## Decision drivers

- Reduce token cost on repeated dogfood runs without changing gate semantics.
- Provide an upper bound on runaway LLM loops in long-lived MCP processes.
- One construction path shared by bdd, schema, and iac (no duplicated factory
parsing in each leaf).
- No new third-party dependencies; existing configs must keep working.
- Relative cache paths should stay stable when the repo root moves (resolve
against the loaded YAML, not the shell CWD).

## Considered options

1. **Env vars only** — `PICKLED_CACHE_DIR`, `PICKLED_MAX_COST_USD`, etc., with
no schema change. Rejected: easy to forget in docs; no single file checked
into the repo for dogfood; harder to share defaults across teammates.

2. **YAML schema extension + env overrides (chosen)** — optional `cache:` and
`budget:` blocks in `pickled.config.yaml`, with env winning on conflict.
Central `build_default_client()` in `pickled-core` wires cache, budget, and
provider; leaf `_build_llm_client()` functions delegate to it.

3. **Per-leaf YAML keys** — each package defines its own cache/budget section.
Rejected: duplication, drift risk, and no shared semantics for
`pickled-spec mcp` umbrella behavior.

## Decision outcome

Adopt option 2. Extend `PickledConfig` with `CacheSettings`, `BudgetSettings`,
and `source_path` (set by `load_config` when a file is read). Add
`build_default_client()` in `pickled_core.llm.bootstrap` that:

- Honors `PICKLED_*_LLM_FACTORY` for tests (no cache/budget wiring).
- Installs `BudgetGuard` when `budget.max_cost_usd` or `PICKLED_MAX_COST_USD`
is set.
- Builds `LLMCache` unless cache mode is `off`.
- Passes the cache into `build_client(provider, config=cfg, cache=cache)`.

Leaf MCP CLIs (`pickled-bdd`, `pickled-schema`, `pickled-iac`) and
`pickled-bdd` CLI replace inline factory parsing with a single bootstrap call.

## Consequences

**Positive**

- Dogfood reruns can reuse cached completions (large reduction in repeat
provider calls when inputs are unchanged).
- A configured `max_cost_usd` aborts further billed calls once the guard
trips.
- Four copies of `_build_llm_client()` logic collapse to one helper.

**Negative**

- `PickledConfig` grows (`source_path`, `budget`); callers that construct
configs manually must accept new defaults.

**Neutral**

- Configs without `cache:` / `budget:` behave as before: cache on at
`.pickled-cache` (resolved next to the YAML), no budget cap.

## Path semantics

- Relative `cache.dir` resolves against the directory containing the loaded
`pickled.config.yaml` (or XDG path), not the process CWD.
- `PICKLED_CACHE_DIR` overrides the directory and keeps **CWD-relative**
resolution when the env value is relative (shell ergonomics).
- Absolute `cache.dir` values are unchanged.

## Future work

- Per-run budget reset for long-lived MCP servers (today the guard persists
for the process lifetime).
- Programmatic cache invalidation API (delete by key prefix or provider).
- Optional YAML knob for budget reset cadence (per tool call vs per session).
89 changes: 89 additions & 0 deletions docs/decisions/0003-cache-budget-and-model-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# ADR-0003: Cache, budget, and model resolution for leaf MCP and CLI

- **Status:** Accepted
- **Date:** 2026-05-25
- **Deciders:** pickled-spec contributors

## Context

`pickled-core` already ships disk-backed `LLMCache`, a `BudgetGuard`, and
per-provider `default_model` entries in `pickled.config.yaml`. Until this
change, leaf MCP servers and package CLIs called `build_client(...)` without
`cache=`, so cached completions were never reused. No bootstrap path installed
a budget guard, so agent-driven loops had no deterministic cost ceiling.

`complete_prompt` hardcoded `DEFAULT_MODEL = "claude-3-5-sonnet-20241022"`,
which is deprecated upstream. MCP-invoked drafters (`FeatureDrafter`,
`OpenAPIDrafter`, `IaCDrafter`) therefore 404ed even when the YAML named a
current model. The provider's `default_model` was parsed but not stored on the
client or consulted by `complete_prompt`.

## Decision drivers

- Reduce token spend on repeated dogfood runs without changing gate semantics.
- Provide an upper bound on runaway LLM loops in long-lived MCP processes.
- Honor `default_model` from configuration for every one-shot drafter.
- One construction path shared by bdd, schema, and iac (no duplicated factory
parsing in each leaf).
- No new third-party dependencies; existing configs must keep working.
- Relative `cache.dir` should resolve against the loaded YAML directory.

## Considered options

1. **Env vars only** — cache and budget via `PICKLED_*` with no schema change.
Rejected: easy to omit in docs; no checked-in defaults for dogfood.

2. **YAML schema extension + env overrides (chosen)** — optional `cache:` and
`budget:` on `PickledConfig`, env wins on conflict; `build_default_client`
composes cache, budget, and provider; `default_model` threaded through
`build_client` to each provider client; `complete_prompt` resolves model
from the client when not passed explicitly.

3. **Per-leaf YAML keys** — duplicate cache/budget blocks in each package.
Rejected: four copies of the same parsing and drift risk.

## Decision

Extend `PickledConfig` with optional `cache:` and `budget:` blocks and
`source_path` when loading a file. Add `build_default_client` in
`pickled-core` that installs `BudgetGuard`, builds `LLMCache` unless mode is
`off`, and calls `build_client(provider, config=cfg, cache=cache)`.

Each provider client accepts `default_model` (with a package-local default).
`complete_prompt` resolves `model` as: explicit argument, then
`client.default_model`, then module `DEFAULT_MODEL`.

Leaf MCP CLIs and `pickled-bdd` CLI delegate `_build_llm_client()` to
`build_default_client` with package-specific `PICKLED_*_LLM_FACTORY` env vars.
Umbrella `build_server()` paths suppress `click.ClickException` so missing
optional deps or config still register deterministic tools.

## Consequences

**Positive**

- Large reduction in token spend on dogfood reruns when cache is enabled.
- Deterministic cost ceiling when `budget.max_cost_usd` or env cap is set.
- LLM drafters use the configured model; no stale hardcoded model string.
- Four duplicated `_build_llm_client()` implementations collapse to one helper
pattern plus shared bootstrap.

**Negative**

- `PickledConfig.source_path` adds mild API surface growth.
- Long-lived MCP servers share one process-wide budget guard until reset
(documented future work).
- Relative `PICKLED_CACHE_DIR` env override remains CWD-relative by design.

## Path semantics

Relative `cache.dir` in YAML resolves against `source_path.parent` (the
directory containing `pickled.config.yaml`). When `PICKLED_CACHE_DIR` is set,
relative values resolve against the process CWD. Absolute paths are unchanged.

## Future work

- Per-run budget reset for long-lived MCP servers.
- Programmatic cache invalidation API.
- Thread `build_default_client` through rules, data, and diff when those
packages gain LLM-backed MCP tools.
62 changes: 58 additions & 4 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ Each pickled-* package exposes workflows over the [Model Context Protocol](https
so tools like Cursor and Claude Desktop can draft artifacts, run gates, and inspect
run telemetry without bespoke UIs.

LLM-backed tools in **pickled-rules**, **pickled-data**, and **pickled-diff** build
clients via `pickled_core.llm.bootstrap.build_default_client`, so they honor the
`cache:` and `budget:` settings in `pickled.config.yaml` (and env overrides) from
the bootstrap PR.

## Install

From the monorepo root:
Expand All @@ -20,6 +25,36 @@ pip install 'pickled-core[mcp]'

Dependencies: `mcp>=1.27.1`, `fastmcp>=3.3.1,<4.0`.

## Response cache, budget cap, and model

LLM completions are cached on disk so reruns of `draft_*` and `validate_*`
tools do not re-bill the provider. A budget cap can be installed to abort
LLM calls once a cumulative cost ceiling is reached.
The model used by `complete_prompt`-based drafters is taken from the
provider's `default_model` in `pickled.config.yaml`.

```yaml
providers:
anthropic:
type: anthropic
default_model: claude-sonnet-4-5-20250929 # used by every drafter
api_key_env: ANTHROPIC_API_KEY
cache:
dir: .pickled-cache # relative paths resolve
mode: read_write # off | read_write | read_only
budget:
max_cost_usd: "5.00" # omit or null for no cap
```

Env overrides (env wins over YAML):

- `PICKLED_CACHE_DIR` — directory for cached JSON entries (CWD-relative)
- `PICKLED_CACHE_MODE` — `off` disables, `read_only` forbids new writes
- `PICKLED_MAX_COST_USD` — decimal string cap
- `PICKLED_LLM_PROVIDER` — pick a provider when multiple are configured

Add `.pickled-cache/` to `.gitignore` if you keep the default location.

## Stdio hygiene

On **stdio** transport, the MCP server must use **stdout only for JSON-RPC**
Expand Down Expand Up @@ -61,11 +96,29 @@ uv run pickled-data mcp serve --transport stdio
uv run pickled-diff mcp serve --transport stdio
```

`pickled-diff` exposes **`verify_against_oracle`** (deterministic; no LLM). On the
umbrella server it is mounted as **`diff_verify_against_oracle`** (namespace `diff`).

`pickled-bdd serve` remains a **deprecated** alias for `pickled-bdd mcp serve`.

## Tool reference (umbrella prefixes)

| Prefix | Tool | Description |
|--------|------|-------------|
| `rules_` | `list_rules` | List rules from YAML text |
| `rules_` | `check_ruleset_coverage` | Coverage gate over feature texts |
| `rules_` | `draft_ruleset_from_brief` | Draft a YAML rule set from a brief |
| `data_` | `parse_sql_migration` | Parse SQL to AST summary |
| `data_` | `apply_sql_to_sandbox` | Apply SQL in-memory |
| `data_` | `check_migration_drift` | Compare migration schema to YAML |
| `data_` | `draft_sql_migration_from_intent` | Draft SQL DDL from intent |
| `diff_` | `verify_against_oracle` | Differential check (deterministic) |
| `diff_` | `draft_corpus_from_examples` | Expand seed examples into a corpus |
| `iac_` | `draft_terraform_module` | Draft Terraform from a user story |
| `iac_` | `validate_terraform_dir` | Validate Terraform file contents |
| `iac_` | `diff_terraform_plans` | Compare plan JSON |
| `iac_` | `explain_plan_diff` | Summarise plan JSON, flag risky actions |
| `iac_` | `suggest_security_remediation` | Patch hints for Trivy findings |

Other prefixes (`bdd_`, `schema_`) are documented in their package READMEs.

## Cursor configuration

Replace `/ABSOLUTE/PATH/TO/pickled-spec` with your clone path:
Expand Down Expand Up @@ -126,7 +179,8 @@ numbers.
uv run python scripts/smoke_mcp_stdio.py
```

Expect at least a dozen tools from the umbrella list.
Expect at least a dozen tools from the umbrella list (19 with rules/data/diff
draft and iac advisor tools).

## Workspace gates vs MCP tools

Expand Down
24 changes: 4 additions & 20 deletions packages/pickled-bdd/src/pickled_bdd/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

from __future__ import annotations

import importlib
import os
from pathlib import Path
from typing import cast

import click
from pickled_core.llm import LLMClient
Expand Down Expand Up @@ -137,24 +134,11 @@ def serve() -> None:

def _build_llm_client() -> LLMClient:
"""Build an LLM client. Override via PICKLED_BDD_LLM_FACTORY for tests."""
factory = os.environ.get("PICKLED_BDD_LLM_FACTORY")
if factory:
module_name, sep, attr = factory.partition(":")
if not sep:
raise click.ClickException(
"PICKLED_BDD_LLM_FACTORY must be 'module:callable' "
"(e.g. pickled_bdd.testing:build_fake_llm)"
)
module = importlib.import_module(module_name)
builder = getattr(module, attr)
return cast(LLMClient, builder())

from pickled_core.llm.config import ConfigError, load_config
from pickled_core.llm.factory import build_client

provider = os.environ.get("PICKLED_LLM_PROVIDER", "anthropic")
from pickled_core.llm.bootstrap import build_default_client
from pickled_core.llm.config import ConfigError

try:
return build_client(provider, config=load_config())
return build_default_client(factory_env="PICKLED_BDD_LLM_FACTORY")
except ConfigError as exc:
raise click.ClickException(str(exc)) from exc

Expand Down
18 changes: 3 additions & 15 deletions packages/pickled-bdd/src/pickled_bdd/mcp_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
from __future__ import annotations

import contextlib
import importlib
import os
from typing import cast

import click
from fastmcp import FastMCP
Expand All @@ -17,20 +14,11 @@


def _build_llm_client() -> LLMClient:
factory = os.environ.get("PICKLED_BDD_LLM_FACTORY")
if factory:
module_name, sep, attr = factory.partition(":")
if not sep:
raise click.ClickException("PICKLED_BDD_LLM_FACTORY must be 'module:callable'")
module = importlib.import_module(module_name)
return cast(LLMClient, getattr(module, attr)())
from pickled_core.llm.bootstrap import build_default_client
from pickled_core.llm.config import ConfigError

from pickled_core.llm.config import ConfigError, load_config
from pickled_core.llm.factory import build_client

provider = os.environ.get("PICKLED_LLM_PROVIDER", "anthropic")
try:
return build_client(provider, config=load_config())
return build_default_client(factory_env="PICKLED_BDD_LLM_FACTORY")
except ConfigError as exc:
raise click.ClickException(str(exc)) from exc

Expand Down
19 changes: 18 additions & 1 deletion packages/pickled-diff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,24 @@ Domain-specific equivalence (tolerant numerics, AST shapes, and so on) belongs i

`pickled_diff.mcp_tools.register(server)` adds **`verify_against_oracle`** to a
`PickledMCPServer`. The umbrella server mounts this package as namespace **`diff`**
(`diff_verify_against_oracle`). No LLM is required. See [`docs/mcp.md`](../../docs/mcp.md).
(`diff_verify_against_oracle`). See [`docs/mcp.md`](../../docs/mcp.md).

| MCP tool | Description |
|----------|-------------|
| `verify_against_oracle` | Differential check (deterministic) |
| `draft_corpus_from_examples` | Expand seed examples into a corpus |

## Drafting a corpus from seed examples

```bash
pickled-diff draft-corpus \
--seeds path/to/seed_corpus.json \
--target-size 10 \
--notes path/to/notes.txt
```

Use `-` for `--seeds` or `--notes` to read from stdin. LLM cache and budget
settings follow [docs/mcp.md](../../docs/mcp.md).

### `pickled-spec check-all`

Expand Down
Loading
Loading