All tutorials
Track 13·State & Control

Analyze a plan's structure

Read the primitive calls and read_only flags with agent.analyze_plan. Find out which mutating primitives a plan would touch before you run it.

intermediate10 min
Video coming soon
Browse this tutorial's folder in tutorials-pygithub.com/OpenSymbolicAI/tutorials-py/tree/main/13-analyze-a-plan

Track 12 ran a plan you already had. Before you run one, you might want to know what it would do. agent.analyze_plan(plan) tells you. It parses the plan and reports every primitive call in it: the method name, whether that method is read-only, and the arguments. Nothing runs and the model is not called. It is pure inspection of the plan text.

The question it answers: which mutating primitives does this plan touch?

1. A bank account, with reads and writes#

A bank account is a good example because it has a real read/write split. deposit and withdraw change the balance, so they are declared read_only=False. can_afford only looks, so it is read_only=True.

python
# account.py
from opensymbolicai.blueprints import PlanExecute
from opensymbolicai.core import primitive


class Account(PlanExecute):
    @primitive(read_only=False)
    def deposit(self, balance: float, amount: float) -> float:
        """Add money to the balance."""
        return balance + amount

    @primitive(read_only=False)
    def withdraw(self, balance: float, amount: float) -> float:
        """Take money out of the balance."""
        return balance - amount

    @primitive(read_only=True)
    def can_afford(self, balance: float, price: float) -> bool:
        """Check whether the balance covers a price."""
        return balance >= price

2. Analyze two plans#

Write two plans by hand: one that only checks the balance, one that changes it. Analyze each.

python
# main.py
READ_ONLY_PLAN = """ok = can_afford(100, 30)
big = can_afford(100, 250)"""

MUTATING_PLAN = """balance = deposit(100, 50)
balance = withdraw(balance, 30)
ok = can_afford(balance, 200)"""

analysis = agent.analyze_plan(READ_ONLY_PLAN)

for call in analysis.calls:  # each is a PrimitiveCall
    flag = "read-only" if call.read_only else "mutating"
    print(f"{call.method_name}: {flag}  args={call.args}")

print("has_mutations:", analysis.has_mutations)
bash
uv run main.py

Output:

text
--- read-only plan ---
  can_afford: read-only  args={'arg0': 100, 'arg1': 30}
  can_afford: read-only  args={'arg0': 100, 'arg1': 250}
has_mutations: False
mutating primitives: []

--- plan that moves money ---
  deposit: mutating  args={'arg0': 100, 'arg1': 50}
  withdraw: mutating  args={'arg0': 'balance', 'arg1': 30}
  can_afford: read-only  args={'arg0': 'balance', 'arg1': 200}
has_mutations: True
mutating primitives: ['deposit', 'withdraw']

What you're looking at#

analyze_plan returns a PlanAnalysis. Its .calls are the primitive calls in order, each a PrimitiveCall with:

  • method_name: the name of the primitive called
  • read_only: whether that primitive is read-only (from its decorator)
  • args: the arguments it was given

Two convenience properties sit on top:

  • has_mutations: True when any call is not read-only
  • method_names: just the names in order

The first plan only calls can_afford, a read-only method, so has_mutations is False. The second calls deposit and withdraw before checking, so has_mutations is True and the mutating primitives are right there.

Use analyze_plan to build a gate: check whether a plan has mutations before deciding whether to run it.