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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
branches: [master, "milestone-*", "phase-*"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include main in the push trigger

In this checkout the integration branch is main (the recent merge commits are on main, and there is no local master branch), so this workflow will not run on post-merge or direct pushes to the default branch; it only runs for master, milestone-*, and phase-*. Please add main here or remove the branch filter so CI actually protects the branch changes land on.

Useful? React with 👍 / 👎.

pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install (dev extras)
run: pip install -e ".[dev]"
- name: Run test suite (includes the BYOM example smoke test)
run: python -m pytest -q
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,14 @@ the [Master Checklist](CHECKLIST.md), and the [claims registry](docs/research/cl
```bash
python -m pytest -q # 42 passing
python examples/recovery_demo.py # crash mid-task, then recover via re-grounding
python examples/byom_recovery.py # BYOM: add crash-recovery to YOUR agent (mock model, offline)
python benchmarks/recovery_matrix.py # the baseline×failure-step benchmark (C1, C3)
```

**Adding recovery to your own agent?** See the BYOM guide —
[docs/guide/recovery-in-your-agent.md](docs/guide/recovery-in-your-agent.md) — for the
primitives path, the `Agent` loop, exactly-once effects, and a local-model (Ollama) run.

The harness is **never hardcoded** (ADR-0007): model provider, tools, tasks, sandbox, storage, and
policies are all injected through [`cairn.app.build_harness`](src/cairn/app.py). The concrete task and
scripted model live only in [`examples/`](examples/) and [`tests/`](tests/), never in the library.
Expand Down
66 changes: 66 additions & 0 deletions docs/guide/public-api-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Public API reference

Everything below is re-exported from the top-level `cairn` package (`from cairn import ...`).
Cairn is **0.x**; this surface is stabilizing but not frozen until v1.0 (held — see the
[claims registry](../research/claims-registry.md)).

## Types

| Name | What it is |
|---|---|
| `Action` | One proposed step: `Action(kind, code="", result="")`. `kind` is `"code"` or `"finish"`. |
| `StepRecord` | An executed step: `StepRecord(step, action, returncode=0, stdout="", stderr="")`. The unit of `history`. |
| `Checkpoint` | A durable checkpoint (the "cairn"). Transparent alias of the internal `ContinuationState`. |
| `Regrounded` | Result of `recover()`: `.history`, `.resolutions`, `.plan` (`plan is None` ⇒ no checkpoint). |
| `Resolution` | What recovery did to one effect: `.key`, `.tool_class`, `.action` (`"redo"`/`"skip"`/`"escalate"`), `.detail`. |
| `ResumePlan` | The reconciled resume plan (torn-step + danger window) produced during `recover()`. |
| `AgentRun` | Outcome of `Agent.run`/`.resume`: `.finished`, `.steps`, `.checkpoints`, `.history`, `.final_state`, `.resumed`, `.recovery_tax`, `.resolutions`. |

## Contracts (Protocols you implement or accept)

| Name | Method surface |
|---|---|
| `World` | `snapshot() -> str`, `restore(snap_id) -> None`, `digest() -> dict`. (The `Agent` loop also needs `execute(code, cwd=None) -> ExecResult`.) |
| `CheckpointStore` | `checkpoint(state, snap_id, effect_offset) -> str`, `load_latest() -> tuple | None`. |
| `EffectLedger` | `append_effect(intent, key, tool_class, step)`, `complete_effect(key)`, `current_offset() -> int`, `list_effects_since(offset)`. |

## Effect safety

| Name | What it is |
|---|---|
| `EffectfulTool` | An injected irreversible effect: `EffectfulTool(name, tool_class, run=None, verify=None)`. |
| `EscalationRequired` | Raised when a `never-retry` effect is found in the danger window (unless `escalate=False`). |

## Reference implementations (bundled)

| Name | What it is |
|---|---|
| `Workspace` | A local-filesystem `World` (also executable). `Workspace(workspace_dir, snapshots_dir, sandbox=None, interpreter=None)`. |
| `FileCheckpointStore` | File-backed `CheckpointStore`. `FileCheckpointStore(ckpt_dir)`. |
| `FileEffectLedger` | Append-only file-backed `EffectLedger`. `FileEffectLedger(path, ledger_id="default")`. |

## Primitives (bring your own loop)

**`checkpoint(goal, history, world, store, ledger, *, step, model_version="", harness_version="") -> Checkpoint`**
Distill `history` into a cairn, snapshot the world, and persist atomically. Returns the durable
`Checkpoint`.

**`recover(world, store, ledger, *, effect_tools=None, escalate=True) -> Regrounded`**
The full RGR protocol: load latest checkpoint → `world.restore` → re-observe → reconcile the torn
step → resolve the effect danger window (exactly-once) → re-ground a minimal history. No durable
checkpoint ⇒ empty history (start fresh). The goal is carried in the checkpoint, not passed here.

**`regrounded_history(plan_steps) -> list[StepRecord]`**
Low-level helper: reconstruct a minimal history from a checkpoint's *done* steps (faithful
summaries, ~80 chars each — re-grounding, not byte-exact replay). Used internally by `recover()`.

## Opt-in loop (batteries-included)

**`Agent(model, world, *, store, ledger, effect_tools=None, max_steps=20, escalate=True, model_version="byom", harness_version="")`**
- `run(goal) -> AgentRun` — drive from scratch, checkpointing each executed step.
- `resume(goal) -> AgentRun` — re-ground via `recover()` and continue; falls back to `run` when
there is no checkpoint.

## Version

`cairn.__version__` — the package version (currently `0.x`).
140 changes: 140 additions & 0 deletions docs/guide/recovery-in-your-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Recovery in your own agent (BYOM)

Cairn is a **bring-your-own-model** recovery library. You keep your model, your keys, and your
tools; Cairn adds **crash-recovery** to your agent through *Re-grounding Recovery (RGR)* — on
resume it loads the last checkpoint, restores your world, re-observes reality, reconciles the
step that was in flight, resolves any half-finished side-effect **exactly once**, and re-grounds
a compact history so your model continues instead of starting over.

> **Status (honest scope).** Cairn is **0.x** and not published. In the deterministic reference
> harness, RGR beats cold restart and the effect WAL yields zero duplicate effects. The headline
> claim (RGR beats cold restart on a *live* model, "C1") is **not yet confirmed** — it awaits a
> powered live-LLM study. This library exists partly so you can reproduce the evidence on *your*
> model. See [`docs/research/claims-registry.md`](../research/claims-registry.md).

## Install

Cairn isn't on PyPI (0.x). Use it from a checkout:

```bash
git clone https://github.com/STiFLeR7/Cairn && cd Cairn
pip install -e ".[dev]" # or: PYTHONPATH=src python your_script.py
```

## The two seams you bring

- **A `Model`** — any object with `propose(goal, history) -> Action`. Cairn ships
`ScriptableMockModel` for tests/examples; a real local adapter (Ollama) is shown below.
- **A `World`** — the observable, snapshottable thing your agent acts on. Cairn ships
`Workspace` (a local filesystem workspace). For the `Agent` loop the World must also be
*executable* (`execute(code) -> ExecResult`); `Workspace` is.

Recovery infrastructure (`FileCheckpointStore`, `FileEffectLedger`) is bundled — you don't
implement it.

## Path A — keep your own loop (primitives)

Call `checkpoint(...)` after each step; call `recover(...)` after a crash and continue from the
history it returns:

```python
from cairn import (Action, StepRecord, Workspace,
FileCheckpointStore, FileEffectLedger, checkpoint, recover)

world = Workspace("ws", "snaps")
store = FileCheckpointStore("ck")
ledger = FileEffectLedger("effects.jsonl", "run")

history = []
for step, action in enumerate(your_actions):
world.execute(action.code) # apply with YOUR tools
history.append(StepRecord(step=step, action=action, returncode=0))
checkpoint(goal, history, world, store, ledger, step=step)

# ... process dies mid-run ...

rg = recover(world, store, ledger) # load -> restore -> re-observe -> reconcile
history = list(rg.history) # re-grounded prior steps (summaries, not replay)
# continue your loop from `history`
```

`recover()` with **no checkpoint** returns an empty history (start fresh). The `goal` is *not* a
parameter to `recover` — it is carried inside the loaded checkpoint.

## Path B — batteries-included (`Agent`)

Same mechanism, none of the loop:

```python
from cairn import Agent, Workspace, FileCheckpointStore, FileEffectLedger

agent = Agent(your_model, Workspace("ws", "snaps"),
store=FileCheckpointStore("ck"),
ledger=FileEffectLedger("effects.jsonl", "run"))

run = agent.run(goal) # checkpoints every executed step
# ... crash ...
run = agent.resume(goal) # re-grounds via recover(), then finishes
print(run.resumed, run.recovery_tax, run.finished)
```

`recovery_tax` is the number of steps executed *after* resume — the cost of recovery vs. a cold
restart that would redo everything. `Agent` has no task oracle: it reports whether the model
finished and hands back the full history + last checkpoint so you apply your own success test.

## Side-effects, exactly once

An irreversible effect (send an email, charge a card) must survive a crash *between* "it
happened" and "we recorded that it happened". Wrap it as an `EffectfulTool` and register its
intent in the ledger **before** acting; on resume, `recover(effect_tools=...)` resolves the
danger window:

```python
from cairn import EffectfulTool

ledger.append_effect("send_report", "send-1", "check-before-retry", step=step) # INTENT first
do_the_send() # then the effect
# crash before we can mark it complete ...

tool = EffectfulTool("send_report", "check-before-retry",
run=do_the_send,
verify=lambda: report_already_sent()) # True iff it already happened
rg = recover(world, store, ledger, effect_tools={"send-1": tool})
# verify() -> True => Resolution(action="skip"): the effect is NOT re-executed (no duplicate)
```

Tool classes: `safe-to-retry` (redo), `check-before-retry` (verify then redo-or-skip),
`never-retry` (escalate — a human decides). See
[`docs/design/effect-safety-protocol.md`](../design/effect-safety-protocol.md).

## Run it

The full, runnable version of all three snippets is
[`examples/byom_recovery.py`](../../examples/byom_recovery.py):

```bash
python examples/byom_recovery.py
```

It uses a deterministic mock model — no API key, no network — and is exercised in CI.

## Use a real local model (Ollama)

`examples/byom_recovery.py` includes `_ollama_model()`, a stdlib-only adapter that satisfies the
`Model` seam by calling a local [Ollama](https://ollama.com) server. It is **opt-in** and never
runs in CI (no network there). To try it:

```bash
ollama serve & # in another shell
ollama pull llama3.2
CAIRN_OLLAMA=1 python examples/byom_recovery.py
```

Swapping `ScriptableMockModel` for `_ollama_model()` is the whole point of BYOM: the recovery
machinery is identical — only the model changed. Reproduce the recovery-vs-cold-restart
comparison on your own model and report what you find.

## What's next

- Full names and signatures: [public API reference](public-api-reference.md).
- Why re-grounding (not replay): [`docs/concepts/recovery-fidelity.md`](../concepts/recovery-fidelity.md).
Loading
Loading