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

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 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#L91-L96) file contains the format_state function, which cleans raw Lean output by removing the "N goals" prefix:

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/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:

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:

suggestions = await self._generate_tactics(ts)

The generation logic resides in 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/prover/proof_search.py#L265-L272):

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:


# 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:


# 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 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 function in 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/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.

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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →