How to create a BehaviorTree
In this tutorial series, most of the time Actions will just print some information on console, but keep in mind that real "production" code would probably do something more complicated.
The source code of this tutorial is example/tutorial_1.py.
How to create your own Action
First, you have to wrote your function (async or sync) as normal, like this:
def approach_object(name: str):
print(f"approach_object: {name}")
def check_battery():
print("battery ok")
async def say_hello(name: str):
print(f"Hello: {name}")
At this point, this is not (yet) a behavior action. To define an action, you have to use action function:
import async_btree as bt
approach_house_object_action = bt.action(target=approach_object, name="house")
check_battery_action = bt.action(target=check_battery)
say_hello_john = bt.action(target=say_hello, name="John")
With a class like this one:
class GripperInterface:
def __init__():
self._open = False
def open(self):
print("GripperInterface Open")
self._open = True
def close(self):
print("GripperInterface Close")
self._open = False
We can define action for these functions: - GripperInterface.open - GripperInterface.close
Create a tree dynamically
We will build a sequence of actions like this one: - say hello - check battery - open gripper - approach object - close gripper
To do that, we need to use sequence methods.
gripper = GripperInterface()
b_tree = bt.sequence(children= [
bt.action(target=say_hello, name="John"),
bt.action(target=check_battery),
bt.action(target=gripper.open),
bt.action(target=approach_object, name="house"),
bt.action(target=gripper.close)
])
Run it — simplest form:
bt.run(b_tree)
Or with an explicit backend:
bt.run(b_tree, backend="trio")
bt.run(b_tree, backend="asyncio+uvloop")
When you need to run the same tree multiple times in the same context, use BTreeRunner:
with bt.BTreeRunner() as runner:
runner.run(b_tree)
And you should see:
Hello: John
Why we did not see other action ? It's because our first action did not return a success (something truthy).
So we could add a return True, on each our function, like this:
def approach_object(name: str):
print(f"approach_object: {name}")
return True
Or we could rewrote our behavior tree with specific status:
b_tree = bt.sequence(children= [
bt.always_success(child=bt.action(target=say_hello, name="John")),
bt.always_success(child=bt.action(target=check_battery)),
bt.always_success(child=bt.action(target=gripper.open)),
bt.always_success(child=bt.action(target=approach_object, name="house")),
bt.always_success(child=bt.action(target=gripper.close))
])
If we running it again:
Hello: John
battery ok
GripperInterface Open
approach_object: house
GripperInterface Close
As you could see: - we use a single instance of GripperInterface - we have hard coded name on our action function
We can also define a function like this:
def check_again_battery():
print("battery dbl check")
# you should return a success
return bt.SUCCESS
and wrote our behavior tree :
b_tree = bt.sequence(
children=[
bt.always_success(child=bt.action(target=say_hello, name="John")),
bt.action(target=check_battery),
check_again_battery, # this will be encapsulated at runtime
bt.always_success(child=bt.action(target=gripper.open)),
bt.always_success(child=bt.action(target=approach_object, name="house")),
bt.always_success(child=bt.action(target=gripper.close)),
]
)
check_again_battery will be encapsulated at runtime.
If we running it again:
Hello: John
battery ok
battery dbl check
GripperInterface Open
approach_object: house
GripperInterface Close
In a real use case, we should find a way to avoid this:
- wrote a factory function for a specific case
- either by using ContextVar (from contextvars import ContextVar)
You could see a sample in this source is example/tutorial_2_decisions.py.
Running trees — bt.run() vs BTreeRunner
bt.run() is the simplest entry point: one call, one result, asyncio by default.
result = bt.run(b_tree)
result = bt.run(b_tree, backend="trio")
result = bt.run(b_tree, backend="asyncio+uvloop")
Use BTreeRunner when you need to run the same tree — or multiple trees — several times from synchronous code, all sharing the same base context snapshot:
with bt.BTreeRunner(backend="asyncio") as runner:
result1 = runner.run(tree_tick)
result2 = runner.run(tree_tick)
result3 = runner.run(tree_tick)
The context is captured once at __enter__ (snapshot of the caller's ContextVar state). Each runner.run() call starts from that same snapshot — mutations inside a tick do not carry over to the next tick. See example/tutorial_3_context.py for a detailed walkthrough.
For more advanced topics:
-
ContextVar isolation and propagation — how to pass data into a tree, why mutations don't escape, and how
BTreeRunnerkeeps a stable base context across ticks: example/tutorial_3_context.py -
Exception handling —
ControlFlowException,@ignore_exception, exception propagation throughparalleletask groups: example/tutorial_4_exceptions.py -
Routing with switch — dispatch to different subtrees based on a runtime key; handle unknown cases with a default branch: example/tutorial_5_switch.py
Routing with switch
switch evaluates a condition to get a key, looks it up in a cases dict, and runs the matching child. If no case matches, it runs the default child (if provided) or returns FAILURE.
from contextvars import ContextVar
import async_btree as bt
mode: ContextVar[str] = ContextVar("mode", default="idle")
async def get_mode() -> str:
return mode.get()
async def handle_idle() -> bool:
print("Robot is idle.")
return bt.SUCCESS
async def handle_patrol() -> bool:
print("Robot is patrolling.")
return bt.SUCCESS
async def unknown_mode() -> bool:
print(f"Unknown mode: {mode.get()!r}")
return bt.FAILURE
router = bt.switch(
condition=get_mode,
cases={
"idle": handle_idle,
"patrol": handle_patrol,
},
default=unknown_mode,
)
The tree representation shows the known case keys and the default branch:
--> switch:
case_keys: ['idle', 'patrol']
--(default)--> unknown_mode:
Because bt.run() captures the current ContextVar state at call time, set the mode before each call:
for m in ["idle", "patrol", "recharge"]:
mode.set(m)
bt.run(router)
Robot is idle.
Robot is patrolling.
Unknown mode: 'recharge'
See example/tutorial_5_switch.py for the full example.