besok

Intro

Behavior trees are a mathematical model used to execute complex flows. Forester is an orchestration framework that operates on top of behavior trees, providing a clean and simple way to run tasks that implement the behavior tree concept out of the box.

Although the core concept under the hood is behavior trees, the framework also provides a DSL called f-tree, which compiles into the trees.

This article sheds some light on why and how f-tree can be useful, especially when it comes to code deduplication.

The language itself is fairly simple and can be explored quickly.

A few features that motivated the creation of the language:

That last point is the interesting one. Let’s look at an example. The examples here are synthetic, but they demonstrate the reasoning behind specific language components.

Example

Let’s model a case where an AMR needs to do a job but must first ensure its charge level stays consistently above a minimum.

The process needs to fulfill a set of conditions:

There’s not much to do, but the steps require checking various conditions at each step — some of which overlap. On top of that, the common logic needs to interact with external events like stop_flag and others.

F-tree aims to solve this with higher-order tree definitions — tree definitions that can accept other tree definition instances as arguments and execute them.

The syntax is:

// the parameter type is tree
sequence some_tree(action:tree){
    // the form name(..) denotes the invocation of the tree
    action(..)
}

This lets us build high-level abstractions that conceal low-level logic inside. That brings several advantages:

On the flip side, it needs to be supported and maintained. Everything is a trade-off.

In our case, we can introduce the following definitions:

// Takes a target action and tries to execute it
// at least the given number of attempts,
// making a delay between attempts.
r_fallback retry_with_delay( attempts:num, trg:tree){
    retry(attempts) fallback {
        trg(..)
        wait()
    }
    // this wait is needed if we want to wait in any case.
    wait()
}

// Simple if-else.
// Since the fallback takes the condition and is sort of inverted,
// we swap the else and then trees.
r_sequence if_else(test:tree, then:tree, else:tree){
    r_fallback{
        test(..)
        else(..)
    }
    then(..)
}

// Wrapping with this definition, we can check the stop flag
// and avoid executing the given action if the flag is true (success).
r_fallback with_flag(action:tree){
    stop_flag()
    action(..)
}

With these small higher-order tree definitions, we can build a simple charging script:

// every component is reactive, so conditions are checked
// on every new tick

// charger_station is a json with coordinates
r_fallback to_charger(charger_station:object){
    // try 10 times
    retry_with_delay(10,
        // check for hazards and wait if any nearby,
        // otherwise head to the station
        r_sequence {
            if_else(
                is_hazard_nearby(),
                wait(),
                retry_with_delay(10, move_to(charger_station))
            )
            // recheck that we are at the right place,
            // and if not, go there
            if_else(
                on_target(charger_station),
                charge(),
                retry_with_delay(10, move_to(charger_station))
            )
        }
    )
    // if we reach here we need to reload again
    running()
}

The main definition:

root main r_fallback {
   // if the batteries are low, go charge; otherwise do the job
   if_else(
        test = battery_low(),
        then = with_flag(to_charger({"x":10,"y":10})),
        else = do_job()
    )
}

Sources

import "std::actions"

cond is_hazard_nearby();
cond stop_flag();
cond battery_low();
cond on_target(target:object);

impl do_job();
impl wait();
impl move_to(target:object);
impl charge();

r_fallback retry_with_delay( attempts:num,trg:tree){
    retry(attempts) fallback {
        trg(..)
        wait()
        fail_empty()
    }
    wait()
}

r_sequence if_else(test:tree, then:tree, else:tree){
    r_fallback{
        test(..)
        else(..)
    }
    then(..)
}


r_fallback with_flag(action:tree){
    stop_flag()
    action(..)
}


r_fallback to_charger(charger_station:object){
    retry_with_delay(10,
        r_sequence {
            if_else(
                is_hazard_nearby(),
                wait(),
                retry_with_delay(10, move_to(charger_station))
            )
            if_else(
                on_target(charger_station),
                charge(),
                retry_with_delay(10, move_to(charger_station))
            )
        }
    )
    running()
}


root main r_fallback {
    if_else(
        test = battery_low(),
        // don't forget about the common stop flag
        then = with_flag(to_charger({"x":10,"y":10})),
        else = do_job()
    )
}

Visualization

Visualization of the higher-order trees

Conclusion

The approach is simple but can bring benefits in terms of quality and readability (or it may not — time will tell). I find it worth trying, and I believe it can ease the creation of complex trees.

#Rust #Behavior-Trees #Dsl #Orchestration