besok

Forester: Remote Actions to the Rescue!

Forester

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.

One of Forester’s features is the ability to run tasks remotely. Tasks can be executed on different machines — even if they are not on the same network. This is useful for a variety of applications, such as:

Forester remote actions are the mechanism the framework uses to run tasks remotely. A remote action is a task defined in f-tree but executed on a remote machine. The remote machine must have the Forester framework installed and be able to communicate with the local machine.

This article provides an example of how to use it in practice.

Structure

The communication protocol is HTTP REST with JSON data passing through. It is fairly convenient and easy to trace, though the performance leaves something to be desired.

The engine provides a specific type of Action — namely Action::Remote. Details can be found in the docs.

The library uses reqwest to perform a blocking POST request to the service, expecting a specific contract to be fulfilled.

On the engine side, Forester provides (needs to be instantiated separately) an integrated HTTP server with a specific API that gives access to the Blackboard and Tracer.

The sequence of steps is:

Example

For simplicity, let’s assume the following case.

We have an AMR and need to implement a simple algorithm for moving from A (current position) to B using a map.

We pick the following sequence of steps and conditions (omitting the real implementation details, obviously):

Given a flag in the Blackboard:

Instructions:

It boils down to the following f-tree:

import "std::actions"

impl move_to(flag:string);
impl calculate(guard:string);

root main
    if_else(
        test = test("calculated"), // built-in action
        then = move_to("calculated"),
        else = calculate_path()
    )



r_sequence calculate_path
    if_else(
            locked("calculated"),
            running(),
            keep_run(calculate("calculated"))
    )

// just a wrapper to pass running
r_sequence keep_run(action:tree) {
    action(..)
    running()
}

// utility imitates if-else
r_fallback if_else(test:tree, then:tree, else:tree){
    r_sequence {
        test (..)
        then (..)
    }
    else (..)
}

Visualization of the remote actions tree

We need to implement two actions:

The real implementations of moving and planning are beyond the scope of this article, so we’ll stick to stubs. Also, the path coordinates are assumed to be passed implicitly.

First, the engine part:

fn main() {
    turn_on_logs();

    let mut root = root();
    let mut fb = forester_builder(&mut root);

    fb.register_remote_action(
        "calculate",
        RemoteHttpAction::new("http://localhost:10000/calculate".to_string()),
    );

    fb.register_remote_action(
        "move_to",
        RemoteHttpAction::new("http://localhost:10001/move_to".to_string()),
    );

    fb.http_serv(9000);

    let mut forester = fb.build().unwrap();

    println!("{:?}", forester.run());
}

It starts but fails instantly since the actions aren’t running. Note: unlike other actions, remote actions fail at runtime if the implementation is missing.

Rust implementation of the planner:

#[tokio::main]
async fn main() {
    let routing = Router::new()
        .route("/", get(|| async { "OK" }))
        .route("/calculate", post(handler))
        .into_make_service::<SocketAddr>();

    axum::Server::bind(&SocketAddr::from(([127, 0, 0, 1], 10000)))
        .serve(routing)
        .await
        .unwrap();
}

async fn handler(Json(req): Json<RemoteActionRequest>) -> impl IntoResponse {
    let client = ForesterHttpClient::new(req.serv_url.clone());
    client
        .put("calculated".to_string(), json!(true))
        .await
        .unwrap();

    client.lock("calculated".to_string()).await.unwrap();
    // imitation of the sync planning process
    tokio::time::sleep(Duration::from_millis(500)).await;

    client.unlock("calculated".to_string()).await.unwrap();

    client
        .new_trace_event(req.tick, "Calculated".to_string())
        .await
        .unwrap();

    (StatusCode::OK, Json::from(TickResult::Success))
}

Python implementation for move_to:

hostName = "localhost"
serverPort = 10001


class MyServer(BaseHTTPRequestHandler):

    def do_POST(self):
        if self.path == "/move_to":
            body = self.rfile.read(int(self.headers["Content-Length"]))
            req = RemoteActionRequest.from_bytes(body)

            self.send_response(200)
            self.send_header("Content-Type", "application/json;charset=UTF-8")
            self.end_headers()

            # pretend that we need to recalculate a route
            if req.tick == 5:
                client = ForesterHttpClient(req.serv_url)
                client.put("calculated", False)
                client.new_trace_event(req.tick, "Bump!. Recalculate")

            # at this point we arrive to the destination point
            if req.tick > 10:
                self.wfile.write(json.dumps("Success").encode("utf-8"))
            else:
                self.wfile.write(json.dumps("Running").encode("utf-8"))

        else:
            self.send_error(404)

Tracing is simple as well (insignificant parts omitted):

24 23:12:32.264 [1]  1 : Running(cursor=0,len=1)
24 23:12:32.266 [1]    2 : Running(cursor=0,len=2)
24 23:12:32.267 [1]      3 : Running(cursor=0,len=2)
24 23:12:32.267 [1]        5 : Failure(key=calculated,reason=false != true)
24 23:12:32.268 [1]      3 : Failure(cursor=0,len=2,reason=false != true)
24 23:12:32.269 [1]    2 : Running(cursor=1,len=2)
...
24 23:12:32.272 [1]          9 : Running(cursor=0,len=2)
24 23:12:33.523 [1]            custom: Calculating
24 23:12:34.658 [1]            custom: Calculated
24 23:12:34.661 [1]            12 : Success(guard=calculated)
24 23:12:34.661 [1]          9 : Running(cursor=1,len=2)
...
24 23:12:35.598 [5]      3 : Running(cursor=1,len=2,reason=false != true)
24 23:12:39.999 [5]        custom: Bump!. Recalculate
24 23:12:40.001 [5]        6 : Running(flag=calculated)
24 23:12:40.001 [5]      3 : Running(cursor=1,len=2,reason=false != true)
24 23:12:40.001 [5]    2 : Running(cursor=0,len=2)
24 23:12:40.002 [5]  1 : Running(cursor=0,len=1)
24 23:12:40.002 [6]  next tick
...
24 23:12:41.248 [6]            custom: Calculating
24 23:12:42.381 [6]            custom: Calculated
...
24 23:12:43.942 [11]  1 : Running(cursor=0,len=1)
24 23:12:43.942 [11]  1 : Success(cursor=0,len=1)

Conclusion

Forester remote actions are a powerful way to distribute tasks across multiple machines. They can be used to improve performance, run tasks on machines that are not accessible locally, scale applications to handle large workloads, or use different technology stacks together.

#Rust #Behavior-Trees #Orchestration #Robotics