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:
- Behavior trees for AI: How they work by Chris Simpson
- Introduction to BTs
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 SUCCESS ↔ FAILURE |
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.