Skip to content

async-btree: Key Points

What's a behavior tree?

Unlike a Finite State Machine, a Behaviour Tree is a tree of hierarchical nodes that controls the flow of decision and the execution of "tasks" or, as we will call them further, "Actions". -- behaviortree

If you're new (or not) to behavior tree, you could spend some time on this few links:

Few implementation libraries:

  • task_behavior_engine A behavior tree based task engine written in Python
  • pi_trees a Python/ROS library for implementing Behavior Trees
  • pr_behavior_tree A simple python behavior tree library based on coroutines
  • btsk Behavior Tree Starter Kit
  • behave A behavior tree implementation in Python

Why another library?

SIMPLICITY

When you study behavior tree implementations — reactive nodes, dynamic changes, runtime execution — at some point you're building something that mimics an eval/apply evaluator or a compiler, with a complex hierarchical set of classes.

All the complexity comes from internal state management: trees of blackboards to avoid global variables, multithreading issues, callbacks...

This breaks the simplicity and beauty of the initial design.

What's actually useful about behavior trees:

  • clarity of expression
  • node tree representation
  • possibility to reuse behavior
  • external measures to dynamically change behavior — a first step toward observable patterns

Having used OOP for years (very long time), I prefer the power of functional programming: add metadata on a semantic construction, deal with closures, use functions as parameters or return values.

And a last reason, more personal: explore Python expressivity.

So how?

This module uses coroutines and their mechanisms to manage the execution flow.

By this way:

  • we reuse simple language idioms to manage state, parameters, etc.
  • no design constraint on action implementation
  • most language building blocks can be reused

You can build expressions like this:

async def a_func():
    """A great function"""
    return "a"

async def b_decorator(child_value, other=""):
    """A great decorator..."""
    return f"b{child_value}{other}"

with BTreeRunner() as runner:
    assert runner.run(decorate(a_func, b_decorator)) == "ba"

Note that decorate(a_func, b_decorator) is not an async function — only actions and conditions are async functions.

Key design decisions

Status via truthy/falsy. To mimic NodeStatus (success, failure, running), return values carry truthy/falsy meaning. ControlFlowException wraps standard exceptions to give them a falsy meaning. By default, exceptions are raised normally until you catch them or decorate with ignore_exception.

Blackboard pattern? With Python 3, please... simply use contextvars.

Abstract tree. Functions from async-btree build an abstract tree for you. The node_metadata decorator adds basic information: function name, parameters, and children relationships. This tree can be retrieved and stringified with analyze and stringify_analyze.

my_func = alias(child=repeat_while(child=action(hello), condition=success_until_zero), name="btree_1")
print(stringify_analyze(analyze(my_func)))
 --> btree_1:
     --(child)--> repeat_while:
         --(condition)--> success_until_zero:
         --(child)--> action:
                      target: hello

No configuration files. No XML, no JSON, no YAML. You don't need an extra level of abstraction to declare a composition of functions. If you write your functions in Python, write compositions in Python. (Remember that you don't need XML to do SQL — just write good SQL...)

Core primitives

Leaves

Primitive Role
action Wrap sync or async function as BT node; exceptions become ControlFlowException
condition Wrap sync or async predicate; result coerced to SUCCESS/FAILURE

Control flow

Primitive Role
sequence Run children in order; stop early once enough succeed or too many fail (success_threshold)
fallback / selector OR — run children in order, stop on first success
decision If/else — evaluate success_tree or failure_tree based on condition
condition_guard Run child only if condition is truthy; return SUCCESS otherwise
repeat_while Loop child while condition is truthy
repeat_until Loop child until condition becomes truthy
do_while Run child at least once, then repeat while condition is truthy
repeat_n Run child exactly N times
random_selector Fallback with children shuffled on every tick
switch Route to a child based on return value of condition
parallele Run children concurrently; succeed if enough succeed (success_threshold)
parallel_race Run children concurrently; first to finish wins, others cancelled

Decorators

Primitive Role
decorate Apply a decorator function to child output
alias Name a subtree
ignore_exception Turn exceptions into falsy ControlFlowException
always_success / always_failure Force return semantics
inverter Flip SUCCESSFAILURE
is_success / is_failure Assert child result polarity
retry Retry child up to N times on failure
retry_until_success / retry_until_failed Retry until result flips
timeout_after Return FAILURE if child exceeds deadline
cooldown Skip child if called again before delay has elapsed
delay Wait N seconds before running child

You should not use this until you're ready to think about what you're doing :)

Note about 'curio' and 'async' framework

Since I've started this project in 2020, the Python landscape has changed a lot.

We use async functions as the underlying mechanism to manage the execution flow, and the async framework was (still) a real concern. About this topic you should read this amazing blog post by Nathaniel J. Smith.

David Beazley worked on the curio framework: "Curio is a coroutine-based library for concurrent Python systems programming using async/await. It provides standard programming abstractions such as tasks, sockets, files, locks, and queues as well as some advanced features such as support for structured concurrency. It works on Unix and Windows and has zero dependencies. You'll find it to be familiar, small, fast, and fun."

As curio says:

Don't Use Curio if You're Allergic to Curio

Personally, after some time testing and reading curio's code, I'm pretty addicted.

The primary goal of Curio was education and exploration related to asynchronous programming in Python. After ten years, David Beazley decided to abandon the Curio project. No further maintenance is expected.

Even if I'm sad to not have seen this work included in the Python standard library, for the sanity of the current project, we have to change our async backend to anyio. This framework, actively maintained, gives us support for asyncio, asyncio + uvloop, and trio.