# Snapshot: Proactive Context Collapse for LLM Agents

## 1) Problem

Swival has reactive context controls (compaction, turn dropping, proactive
checkpoints), but the agent cannot proactively collapse exploration into conclusions
at the moment it learns them. A 15K-token investigation might produce a 500-token
insight — the reasoning got you there, but only the destination matters now.

## 2) Product Decision

Ship a **separate tool** rather than extending `think`.

- `think` = structured reasoning (accumulates state)
- `snapshot` = context compression (collapses state)
- Clearer safety rules, easier testing, distinct mental model

## 3) API

Actions: `save`, `restore`, `cancel`, `status`.

```text
snapshot action=save label="investigating auth module"
snapshot action=restore summary="Auth uses JWT in auth/jwt.py:42. Redis sessions, 24h TTL."
snapshot action=restore summary="..." force=true
snapshot action=cancel
snapshot action=status
```

### Action semantics

- `save`: set an explicit checkpoint (narrow scope). Resets dirty state.
- `restore`:
  - If explicit checkpoint is active, collapse from that checkpoint.
  - Otherwise, collapse from the latest implicit checkpoint.
- `cancel`: clear explicit checkpoint only. Implicit checkpoints unaffected.
- `status`: report explicit checkpoint, implicit boundary info, dirty state.

### Parameters and limits

| Param | Required for | Limit |
|---|---|---|
| `label` | `save` | 100 chars |
| `summary` | `restore` | 4000 chars |
| `force` | `restore` (optional) | boolean, default false |

### Core constraints

- One explicit scope at a time (no nesting in v1)
- Empty scope on restore (zero intermediate turns) returns warning
- `cancel` clears explicit scope only

## 4) Adoption Strategy

A tool only works if the LLM uses it. This section is the most important.

### The friction problem

A strict `save` -> `restore` ceremony requires the LLM to predict it's about to
explore, call save preemptively, do the work, then remember to restore. In practice,
LLMs dive straight into reading files. By the time a nudge fires, there's no
checkpoint to restore to.

### Solution: implicit checkpoints

The system silently creates checkpoints. The LLM only needs one action: `restore`.

**Implicit checkpoints are created at:**

1. Every user message boundary
2. Immediately after a successful `restore`
3. Conversation start and `/clear` reset

**Restore resolution order:**

1. If an explicit checkpoint is active (via `save`), collapse from there.
2. Otherwise, collapse from the latest implicit checkpoint.

The default workflow becomes:

1. User asks a question.
2. Agent explores (reads files, greps, thinks).
3. Agent reaches a conclusion.
4. Agent calls `snapshot restore summary="..."` — one action, no prior setup.
5. Everything since the user's message collapses into the summary.

`save` remains available for narrower scoping, but it's never required.

### Implicit checkpoint implementation

Don't store mutable message indices (fragile under compaction). Instead:

- At restore time, scan backwards for the most recent user-role message or
  last restore boundary (via `last_restore_tool_call_id`), whichever is newer.
- This avoids index drift when compaction or prior restores mutate the message list.

### Dirty state resets

Dirty state resets at every scope boundary to prevent stale dirtiness from leaking:

- At each implicit checkpoint (new user message, post-restore, reset)
- On explicit `save`

Each natural scope starts clean.

### Few-shot prompt example

LLMs learn from examples, not descriptions. Add to the system prompt:

```text
## Context Management — snapshot tool

When you've been reading files and reached a conclusion, call `snapshot restore`
to replace the exploration with your summary. This frees context for the work ahead.

Example — without snapshot:
  You read 8 files and grep logs to debug auth failures.
  All 8 file reads (~12K tokens) stay in context as dead weight.

Example — with snapshot:
  You read 8 files and grep logs to debug auth failures.
  You call: snapshot action=restore summary="Root cause: missing null check
  in auth/parser.py:142. parse_token() receives None on trailing whitespace.
  Fix: guard with `if token is None: return default_token`."
  Result: 12K tokens of reads collapse to ~200 tokens. You continue with
  a clean context.

When to use:
- After reading multiple files to understand a subsystem
- After debugging sessions (logs, traces, source)
- After trying multiple approaches to find one that works
- When switching from investigation to implementation

Write thorough summaries: file paths, function names, line numbers, decisions.
Your future self only has the summary.

Optional: call `snapshot save label="..."` first for a narrower scope.
If you wrote files or ran commands in the scope, add force=true.
```

### Nudge triggers

Inject a one-shot nudge when:

1. **5+ consecutive read-only turns** without write/edit/command
2. **Immediately after a context-overflow retry** (compaction just fired)
3. **Transition from research to mutation** (agent calls edit_file/write_file
   after a streak of reads)

Nudge text:

```text
Tip: You've done a lot of reading. Consider calling `snapshot restore summary="..."`
to collapse your investigation into a summary and free context.
```

One nudge per streak. Don't repeat every turn.

### Adoption KPI

Track `snapshot_restore_adoption_rate` = restores / eligible read-heavy streaks.
Target: >50% before enabling default-on in Phase 3.

## 5) Safety Model

Default policy: only collapse **read-only** scopes.

### Read-only allowlist

`read_file`, `list_files`, `grep`, `glob`, `fetch_url`, `think`, `todo`, `snapshot`

### Dirtying tools

Everything else, including:
- `write_file`, `edit_file`, `delete_file`, `run_command`
- Unknown MCP tools (conservative default: treat as dirty)

### Dirty scope behavior

- `restore` returns an error listing `dirty_tools` unless `force=true`
- Error message is prescriptive: "Scope is dirty (edit_file, run_command). Call
  `snapshot restore force=true` to override, or `snapshot cancel` to keep context."
- `dirty_tools` stored as `set[str]` to avoid duplicates

This prevents the most dangerous failure mode: the agent forgetting its own edits.

## 6) Collapse Semantics

On successful `restore`:

1. Resolve start checkpoint:
   - Explicit if active (via `explicit_begin_tool_call_id`)
   - Else implicit (backward scan for last user message or last restore boundary,
     whichever is newer)
2. Find current `restore` turn.
3. Replace all turns in the interval with a single synthetic assistant message:

```text
[snapshot: <label-or-auto>]
<summary>
(collapsed N turns, saved ~K tokens)
```

4. Record history entry, clear explicit checkpoint, reset dirty state.
5. Store `last_restore_tool_call_id` for future implicit boundary resolution.

### Guards and invariants

- **Softened restore isolation.** If `restore` appears with other tool calls in the
  same turn, execute restore first and return a warning ("use restore alone for
  predictable behavior"). Only hard-fail if combining would break message structure.
- **No orphaned tool_call_ids.** The replacement is a plain assistant content message
  with no tool_calls field.
- **Compacted checkpoint.** If the checkpoint marker was removed by compaction,
  return explicit error suggesting `cancel`.

## 7) Completed Snapshot History

Past summaries are high-value distilled knowledge. They must survive aggressive
compaction.

- `SnapshotState.history` stores completed snapshots (capped at 10).
- `inject_into_prompt()` renders them for system prompt injection, same pattern as
  ThinkingState.
- Per-summary cap: 1200 chars. Total injection budget: ~1500-2000 tokens.
- Even if compaction drops the recap message from the message list, the injected
  history preserves the knowledge.

## 8) Data Model

New module: `swival/snapshot.py`

```python
class SnapshotState:
    # Explicit scope (set by save, cleared by restore/cancel)
    explicit_active: bool
    explicit_label: str | None
    explicit_begin_tool_call_id: str | None

    # Implicit scope resolved at restore-time from boundaries,
    # not from stored mutable indices.
    last_restore_tool_call_id: str | None

    # Dirty tracking (resets at every scope boundary)
    dirty: bool
    dirty_tools: set[str]

    # Completed history (survives compaction via prompt injection)
    history: list[dict]  # capped at 10

    # Metrics
    stats: dict  # saves, restores, cancels, blocked, force_restores, tokens_saved
```

History entry fields:
- `label`, `summary`, `scope_type` (explicit/implicit)
- `turns_collapsed`, `tokens_before`, `tokens_after`, `tokens_saved`
- `dirty_at_restore`, `forced_restore`

Methods:
- `process(action, label, summary, force, messages, tool_call_id)` — dispatch
- `save(label, tool_call_id)` — set explicit checkpoint, reset dirty
- `restore(summary, messages, force)` — resolve checkpoint, mutate messages, record
- `cancel()` — clear explicit scope
- `status()` — report explicit/implicit state, dirty info
- `mark_dirty(tool_name)` — called by agent loop for non-allowlisted tools
- `reset_dirty()` — called at implicit checkpoint boundaries
- `inject_into_prompt()` — render history for system prompt
- `reset()` — full reset for `/clear`

## 9) Integration Points

### `swival/tools.py`

- Add `snapshot` tool schema to `TOOLS`
- Add dispatch branch routing to `snapshot_state.process()`
- Accept `force` parameter on restore

### `swival/agent.py`

- Instantiate `SnapshotState` alongside `ThinkingState` and `TodoState`
- Reset dirty state at user message boundaries (implicit checkpoint)
- Reset dirty state on explicit `save`
- Mark dirty after non-allowlisted tool calls during active scope
- Support restore-first soft guard when mixed tool calls appear
- Pin explicit `save` marker turns during compaction
- Score recap turns high (+5) in compaction heuristics
- Inject snapshot history into system prompt / compaction recap
- Nudge logic: track consecutive read-only turns, post-overflow, research→mutation

### `swival/session.py`

- Add `snapshot_state` to Session
- Pass into `run_agent_loop` kwargs
- Call `snapshot_state.reset()` on `Session.reset()` and REPL `/clear`

### `swival/report.py`

Add metrics:
- `snapshot_saves`, `snapshot_restores`, `snapshot_cancels`
- `snapshot_blocked_restores`, `snapshot_force_restores`
- `snapshot_tokens_saved_est`
- `snapshot_eligible_read_streaks`, `snapshot_restore_adoption_rate`

### System prompt and docs

- Add few-shot guidance (section 4 above) to `system_prompt.txt`
- Document implicit checkpoint behavior and tool API in `docs.md/tools.md`

## 10) Interaction with Existing Systems

**Compaction.** Proactive (snapshot) and reactive (compaction) coexist:
- Compaction during active scope: allowed. Explicit save marker is pinned. Implicit
  checkpoint resolves dynamically. Restore collapses whatever remains.
- Recap messages resist compaction via high score. If dropped, injected history
  preserves the knowledge.

**ThinkingState.** Independent. Thoughts persist in their own state. If the agent
thinks during a scope, thought messages get collapsed but ThinkingState.history
retains them.

**TodoState.** Independent. Disk-persisted, unaffected by message manipulation.

**Proactive summaries.** Snapshots are the agent-driven version. Could eventually
replace or augment auto-summarize-every-10-turns. Out of scope for v1.

## 11) Tests

New: `tests/test_snapshot.py`

Core:
- restore without save (implicit checkpoint from user message)
- implicit boundary resolution works after compaction and previous restores
- save narrows scope vs implicit
- save + restore + new implicit checkpoint created
- cancel clears explicit only
- status reports both explicit and implicit state

Safety:
- dirty scope blocks restore, lists dirty_tools
- force=true overrides dirty block
- read-only allowlist tools don't dirty scope (including glob, todo)
- dirty resets at implicit boundaries (user message, post-restore)
- dirty resets on explicit save
- unknown MCP tools treated as dirty

Collapse:
- synthetic recap message format correct
- no orphaned tool_call_ids
- token count decreases
- empty scope warning
- mixed-tool-call restore executes with warning

History:
- completed snapshots recorded with correct fields (including scope_type)
- history cap enforced (>10 drops oldest)
- inject_into_prompt respects char/token budget
- history survives compaction

Compaction interaction:
- explicit save marker pinned
- implicit checkpoint resolves correctly after compaction
- recap turns resist compaction scoring

Existing test updates:
- `test_tools.py`: schema validation, dispatch routing
- `test_compact.py`: recap retention, valid message structure
- `test_report.py`: adoption and efficiency metrics
- `test_session.py`: state lifecycle, reset behavior

Hard invariants:
1. No orphaned `tool_call_id` after restore.
2. `estimate_tokens()` decreases in representative flows.
3. Behavior unchanged when snapshot tool is never used.

## 12) Rollout

**Phase 1: MVP behind flag.**
`--snapshot` CLI flag or `snapshot = true` in config. Ship the tool with implicit
checkpoints from day one. Collect report metrics.

**Phase 2: Nudges and few-shot.**
Enable nudge triggers. Add few-shot example to system prompt. Tune nudge threshold
based on adoption rate data.

**Phase 3: Default-on.**
Enable by default when:
- `snapshot_restore_adoption_rate` > 50%
- No increase in message-validation errors
- Neutral or improved task success rate

## 13) File Changes Summary

| File | Change |
|---|---|
| `swival/snapshot.py` | New module: SnapshotState class |
| `swival/tools.py` | Add snapshot tool definition and dispatch |
| `swival/agent.py` | Implicit checkpoints, dirty tracking, nudging, pinning, history injection |
| `swival/session.py` | Wire snapshot_state, reset |
| `swival/report.py` | Adoption and efficiency metrics |
| `swival/system_prompt.txt` | Few-shot guidance and tool description |
| `docs.md/tools.md` | Tool API docs with implicit checkpoint behavior |
| `tests/test_snapshot.py` | New comprehensive test file |
| `tests/test_tools.py` | Schema + dispatch |
| `tests/test_compact.py` | Recap retention, checkpoint robustness |
| `tests/test_report.py` | Adoption metrics |
| `tests/test_session.py` | Lifecycle, reset |
