# Proof State Representation and Tactic Application in LeanAgent: A Deep Dive

> Explore proof state representation and tactic application in LeanAgent. Learn how LeanAgent interacts with the Lean proof assistant to apply tactics and manage proof states effectively.

- Repository: [LeanDojo/leanagent](https://github.com/lean-dojo/leanagent)
- Tags: deep-dive
- Published: 2026-03-05

---

**LeanAgent represents proof states as plain-text strings containing the turnstile symbol (⊢) and applies tactics by calling `Dojo.run_tac` to interact with the Lean proof assistant, returning either a new state, completion, or error.**

This article examines how the [lean-dojo/leanagent](https://github.com/lean-dojo/leanagent) repository handles proof state representation and tactic execution during automated theorem proving. Understanding these mechanisms is essential for extending the proof search or debugging tactic failures.

## How LeanAgent Represents Proof States

The proof state representation in LeanAgent is designed to bridge the gap between Lean's native output and the neural generator's input requirements.

### The Plain-Text State Format

At its core, a proof state is stored as a **plain-text string** that always contains the turnstile symbol `⊢` to separate goals. This string-based approach allows the generator model to consume the current proof obligations without parsing complex AST structures.

When Lean produces output, it often prefixes the state with metadata such as `N goals`. LeanAgent strips this prefix to ensure only the essential goal information is stored and displayed to the model.

### Normalizing Raw Lean Output with `format_state`

The [[`common.py`](https://github.com/lean-dojo/leanagent/blob/main/common.py)](https://github.com/lean-dojo/leanagent/blob/main/common.py#L91-L96) file contains the `format_state` function, which cleans raw Lean output by removing the "N goals" prefix:

```python
def format_state(s: str) -> str:
    m = re.match(r"\d+ goals", s)
    if m is not None:
        return s[m.end() :].strip()
    else:
        return s

```

This function guarantees that every stored state begins with the goal separator `⊢` and contains no markup symbols like `«` or `»`. The normalization ensures consistent prompting for the tactic generator regardless of how Lean formats its initial output.

## The `TacticState` Wrapper

LeanAgent leverages the `TacticState` class from the external `lean_dojo` package to encapsulate state information. According to the source code in [[`proof_search.py`](https://github.com/lean-dojo/leanagent/blob/main/proof_search.py)](https://github.com/lean-dojo/leanagent/blob/main/prover/proof_search.py#L212-L215), a `TacticState` object exposes two critical attributes:

- **`pp`**: The human-readable, pretty-printed version of the state used for prompting the generator
- **`unsolved_tactic_state`**: The raw state string fed back to Lean during tactic execution

The prover selects the appropriate representation based on the node type:

```python
if isinstance(search_node.state, TacticState):
    ts = search_node.state.pp                # text shown to the model

else:
    ts = search_node.state.unsolved_tactic_state   # raw Lean state

```

This dual representation allows LeanAgent to maintain both a clean interface for the neural model and a machine-readable format for the proof assistant.

## Applying Tactics Through Lean

Tactic application follows a generate-then-validate pattern, where candidates are produced by a neural model and verified by Lean's kernel.

### Generating Candidate Tactics

The generator model receives the current state (`ts`) along with theorem metadata (name, file path, position) and returns a list of `(tactic, logprob)` pairs. As implemented in the search loop, this occurs via:

```python
suggestions = await self._generate_tactics(ts)

```

The generation logic resides in [`generator/model.py`](https://github.com/lean-dojo/leanagent/blob/main/generator/model.py), which builds prompts embedding the state string as `TACTIC_STATE`.

### Executing Tactics with `Dojo.run_tac`

The actual interaction with Lean occurs through **`Dojo.run_tac`**, called in [[`proof_search.py`](https://github.com/lean-dojo/leanagent/blob/main/proof_search.py)](https://github.com/lean-dojo/leanagent/blob/main/prover/proof_search.py#L265-L272):

```python
response = self.dojo.run_tac(node.state, tactic)

```

This method sends the tactic string to the Lean server for execution against the current proof state. The `run_tac` function is part of the external `lean_dojo` package and handles the low-level communication protocol with the Lean proof assistant.

### Handling Execution Results

The `run_tac` method returns one of several result types that determine the search trajectory:

- **`ProofFinished`**: All goals are solved, indicating a complete proof
- **`LeanError`**: The tactic failed with a compilation or logical error
- **`TimeoutError`**: Execution exceeded the configured time limit
- **`ProofGivenUp`**: The proof was abandoned due to resource constraints
- **`TacticState`**: Successful execution containing the new goal list

When a `TacticState` is returned, the prover creates a new search node with the updated state and cumulative log probability, then optionally enqueues it for further expansion.

## End-to-End Tactic Application Flow

The following example illustrates the complete workflow from initial state to tactic application:

```python

# Initialize Dojo and get the initial Lean goal state

with Dojo(theorem, timeout) as (dojo, init_state):
    root = InternalNode(state=init_state, cumulative_logprob=0.0)

# Extract the printable state string for the model

ts = root.state.pp          # or .unsolved_tactic_state for non-TacticState nodes

# Request candidate tactics from the generator

candidates = generator.generate(state=ts, theorem=theorem)   # list of (tactic, logprob)

# Apply each tactic via Dojo

for tac, logp in candidates:
    resp = dojo.run_tac(root.state, tac)

    if isinstance(resp, ProofFinished):
        # Proof complete - terminate search

        break
    elif isinstance(resp, TacticState):
        # Create new node with updated goals for further search

        new_node = InternalNode(state=resp, cumulative_logprob=root.cumulative_logprob + logp)
        queue.append(new_node)
    else:
        # Handle LeanError, TimeoutError, or ProofGivenUp

        continue

```

You can also inspect states and apply tactics manually for debugging:

```python

# Inspect current goals

print(root.state.pp)                # e.g., "⊢ Nat.succ n = m"

# Apply a tactic manually

new_state = dojo.run_tac(root.state, "apply Nat.succ_eq_add_one")

```

## Summary

- **Proof states** are stored as plain-text strings containing the `⊢` separator, normalized by [`format_state`](https://github.com/lean-dojo/leanagent/blob/main/common.py#L91-L96) to remove Lean's "N goals" prefix.
- **Dual representation** via `TacticState.pp` (human-readable) and `unsolved_tactic_state` (machine-readable) supports both model prompting and Lean communication.
- **Tactic execution** relies on `lean_dojo`'s `Dojo.run_tac` method, which returns structured results including `ProofFinished`, error types, or new `TacticState` objects.
- **Search integration** combines neural generation with Lean verification, creating new nodes only when tactics successfully produce valid successor states.

## Frequently Asked Questions

### What format does LeanAgent use to store proof states internally?

LeanAgent stores proof states as **plain-text strings** that always include the turnstile symbol `⊢` to denote goals. The [`format_state`](https://github.com/lean-dojo/leanagent/blob/main/common.py#L91-L96) function in [`common.py`](https://github.com/lean-dojo/leanagent/blob/main/common.py) ensures these strings are cleaned of Lean's "N goals" prefix and special markup characters before storage or model consumption.

### How does LeanAgent decide which proof state representation to show the generator?

The prover checks the node type in [[`proof_search.py`](https://github.com/lean-dojo/leanagent/blob/main/proof_search.py)](https://github.com/lean-dojo/leanagent/blob/main/prover/proof_search.py#L212-L215). For `TacticState` objects, it uses the `pp` (pretty-print) attribute for human-readable prompts. For other node types, it falls back to `unsolved_tactic_state`, which provides the raw format required by Lean's API.

### What happens when a tactic fails during proof search?

When `Dojo.run_tac` encounters a failure, it returns either a `LeanError` (compilation failure), `TimeoutError` (execution timeout), or `ProofGivenUp` (resource exhaustion). The prover treats these as terminal nodes that do not expand the search tree, allowing the best-first search to explore alternative tactic sequences instead.

### Can I manually apply tactics to a specific proof state outside the search loop?

Yes. You can directly call `dojo.run_tac(state, tactic_string)` where `state` is either a `TacticState` object or the initial state from a `Dojo` context. The method returns a new state, completion marker, or error, allowing interactive debugging or custom proof automation scripts independent of the main search algorithm.