Forester
Forester is a domain-specific language (DSL) and high-performance Rust runtime designed for building, testing, and executing behavior trees.
It is specifically engineered for two core domains:
- 🧠 AI Agents: Orchestrating LLM-based tools, state management, and fallback loops without unmaintainable Python code or DAG spaghettification.
- 🤖 Robotics & Industrial Systems: Managing deterministic, reactive hardware control loops, with built-in export to ROS Nav2 and simulation support for Webots.
Why Forester?
As systems grow in complexity, state machines and Directed Acyclic Graphs (DAGs) break down when handling fallbacks, retries, and dynamic error recovery. Forester separates decision logic (tree orchestration) from execution logic (actions), enabling modular, maintainable control flow.
Key Differentiators
- Dedicated Orchestration DSL: Write tree logic once in a strongly-typed, functional language featuring higher-order trees, lambdas, and live Blackboard pointers.
- Sync & Async Execution: Non-blocking async execution for I/O and remote calls, alongside predictable sync execution for tight control loops.
- Local & Remote Action Clients: Execute actions natively in Rust (
forester-rs) or remotely via lightweight clients (such as Python’sforester-http-ra-pyfor LangChain/LlamaIndex tools). - Rich Analysis & Simulation: Inspect execution traces, visualize trees, and test orchestration in simulation before deploying to physical hardware or production agents.
- ROS Nav2 & Webots Integration: Export Forester trees directly into ROS Nav2 XML or run against Webots simulation environments.
- Static Validations & Optimizations: Catch tree errors, numeric overflows, and structural flaws at compile time rather than runtime.
Why Behavior Trees?
Behavior trees provide a clean, mathematical abstraction over decision logic:
- Modularity: Small, independent nodes (sequences, fallbacks, decorators) can be composed into deep hierarchical behaviors.
- Reactivity: Ticks evaluate preconditions dynamically, allowing real-time preemptions and fallbacks when environment state changes.
- Separation of Concerns: Tree nodes decide what to do next; external action handlers perform how it gets done.
Learn More About Behavior Trees
- Chris Simpson’s Behavior Trees for AI
- Introduction to Behavior Trees
- State Machines vs. Behavior Trees
Related Ecosystem Projects
- BehaviorTree.CPP — C++ behavior tree framework widely used in ROS2.
- Beehave — Behavior tree AI library for the Godot Engine.
- Bonsai — Pure Rust behavior tree implementation.
Forester Architecture & Components
Forester is structured into four primary subsystems: the Language & Compiler, the Runtime Engine, Analysis & Simulation, and Integrations & Tooling.
1. Language & Compiler
Forester provides a domain-specific language (DSL) tailored for behavior tree orchestration.
- Tree DSL (
.tree): A strongly-typed language for describing execution logic, higher-order trees, lambdas, decorators, and Blackboard memory references. - Static Analysis & Compiler: Parses scripts, verifies type constraints, validates argument bounds, and detects structural errors before runtime.
2. Runtime Engine
The Rust runtime engine manages tree execution and state.
- Engine Core: Ticks nodes reactively, supporting synchronous execution (for fast control loops) and asynchronous execution (for non-blocking I/O).
- Blackboard: High-performance, shared memory store for passing data between tree nodes dynamically.
- Action Keeper: Registers and executes actions (local Rust callbacks or remote RPC handlers).
3. Analysis & Simulation
Tools to inspect, debug, and validate behavior trees during development.
f-treeCLI: Unified command-line tool for compiling, analyzing, and running trees.- Visualization: Generates tree diagrams (Graphviz) to inspect tree structure visually.
- Execution Tracing: Detailed execution telemetry for stepping through ticks and debugging node state.
- Simulator: Executes trees against stubbed action responses to test orchestration logic before linking real hardware or external LLM APIs.
4. Integrations & Tooling
Bridge Forester to external runtimes, robotics engines, and developer editors.
- Remote Action SDKs: Lightweight clients allowing actions to run outside the Rust process:
- Python (
forester-http-ra-py): Execute Python functions and LangChain/LlamaIndex tools seamlessly over HTTP. - Rust (
forester-rs): Native Rust action integration.
- Python (
- ROS Nav2 Exporter: Exports Forester trees directly to ROS2 Nav2 XML format for robotics navigation pipelines.
- Editor Tooling: Syntax highlighting, linting, and Language Server Protocol (LSP) support for IDEs (VS Code, IntelliJ).
Setup & Quick Start
Forester can be used as a CLI tool for simulation/analysis, embedded as a Rust dependency, or integrated remotely from Python.
1. CLI Tool Setup (f-tree)
Install the f-tree command-line utility via cargo:
cargo install f-tree
Verify installation and view available subcommands:
f-tree --help
Common CLI Subcommands
- Simulation (
f-tree sim): Run behavior tree execution against simulated action stubs. - Visualization (
f-tree vis): Generate SVG/Graphviz visual diagrams of.treefiles. - Tree Validation (
f-tree check): Statically validate tree syntax, types, and imports.
2. Rust Project Integration
Add forester-rs to your Cargo.toml:
[dependencies]
forester-rs = "0.2"
Example 1: Loading Trees from the Filesystem
use forester_rs::runtime::builder::ForesterBuilder;
use forester_rs::runtime::action::Action;
use forester_rs::tracer::Tracer;
fn main() {
let mut fb = ForesterBuilder::from_file_system();
fb.main_file("main.tree".to_string());
fb.root("main");
fb.tracer(Tracer::default());
fb.bb_load("db/initial_state.json".to_string());
let mut forester = fb.build().expect("Failed to build Forester runtime");
let result = forester.run().expect("Tree execution failed");
println!("Execution completed with result: {:?}", result);
}
Example 2: In-Memory / Inline DSL Script Execution
use forester_rs::runtime::builder::ForesterBuilder;
fn main() {
let mut fb = ForesterBuilder::from_text();
fb.text(r#"
import "std::actions"
root main sequence {
action_a()
action_b()
}
impl action_a();
impl action_b();
"#.to_string());
let mut forester = fb.build().expect("Failed to parse inline script");
let result = forester.run().expect("Execution failed");
println!("Result: {:?}", result);
}
Example 3: Programmatic Tree Construction
use forester_rs::runtime::builder::ForesterBuilder;
use forester_rs::flow;
fn main() {
let mut fb = ForesterBuilder::from_code();
// Programmatically construct tree nodes using Rust macros
fb.add_rt_node(
flow!(fallback "recovery_root", args!();
action!("check_sensor"),
action!("reset_state")
)
);
let mut forester = fb.build().unwrap();
let result = forester.run().unwrap();
println!("Result: {:?}", result);
}
3. Python Integration (Remote Actions for AI Tools)
For Python-based AI workflows (LangChain, LlamaIndex, custom LLM tools), install the Python remote-action client:
pip install forester-http-ra-py
The Python client connects your Python tools over HTTP to the Forester Rust runtime, executing remote actions seamlessly.
Forester Tree Language (.tree)
The Forester Tree Language is a strongly-typed, functional domain-specific language (DSL) designed to describe behavior tree architecture cleanly without verbosity or repetition.
Unlike traditional XML-based or JSON-based behavior tree formats, Forester treats behavior trees as first-class, composable abstractions.
Why a Dedicated DSL?
Standard behavior tree formats (such as XML trees used in ROS) often suffer from extreme duplication and verbosity. Forester’s DSL solves this by introducing functional primitives:
- Higher-Order Trees (HOT): Pass trees as parameters to other trees to create reusable patterns (e.g., generic retry loops, fallback wrappers, logging decorators).
- Strong Type System: Built-in support for strings, numbers, booleans, arrays, objects, and subtrees, validated at compile time.
- Blackboard Pointers: Pass live references to Blackboard memory into actions, allowing nodes to operate on dynamically updated state (essential for AI agent context).
- Lambdas: Write inline, anonymous subtrees without declaring throwaway named definitions.
Project Structure & File Conventions
Forester projects consist of one or more .tree files organized within a root project directory:
my_project/
├── main.tree # Entry point containing the root tree
├── agent_tools.tree # Custom actions and tool definitions
├── navigation/
│ ├── move.tree # Sub-tree definitions
│ └── recovery.tree # Recovery patterns
Key Conventions
- File Extension: All Forester source files use the
.treeextension. - Root Entry Point: A project must define at least one
rootnode (e.g.,root main sequence { ... }). - Module Imports: Imports resolve relative to the root directory (e.g.,
import "navigation::move").
Language Roadmap & Tooling
To ensure a seamless developer experience, Forester provides language tooling across popular editors:
- Syntax Highlighting & Linting: Editor support for
.treefiles. - Language Server Protocol (LSP): Autocompletion, inline type errors, and navigation support for VS Code, Neovim, and IntelliJ.
Forester Syntax Overview
The syntax of the .tree language is declarative, strongly typed, and modular. A Forester script consists of six primary elements:
- Imports: Module references to include external
.treefiles or built-in stdlib actions. - Root Declarations: The entry point of the behavior tree (
root main ...). - Tree Definitions: Named sub-trees (
sequence,fallback,parallel) with typed parameters. - Action Implementations (
impl): Contract definitions for native or remote actions executed by the runtime. - Invocations & Decorators: Calls to sub-trees and actions, wrapped with built-in decorators (e.g.
retry,inverter,timeout). - Higher-Order Tree Delegates (
..): Invoking sub-trees passed as parameters.
Comprehensive Syntax Example
// 1. Imports
import "std::actions"
import "sensors/battery.tree" {
check_battery => is_battery_ok,
}
// 2. Root Entry Point
root main sequence {
// Check battery or charge
fallback {
is_battery_ok()
charge_robot()
}
// Execute target action with retry decorator
retry(3) execute_task(
target = {"x": 10, "y": 20},
sub_action = place_item([100])
)
}
// 3. Higher-Order Tree Definition
sequence execute_task(target: object, sub_action: tree) {
fallback {
is_target_reachable(target)
approach_target(target)
}
sequence {
savepoint()
sub_action(..) // Delegate invocation of passed sub_action
}
}
// 4. Sub-tree Definition
sequence place_item(location: array) {
validate_location(location)
info_wrapper(drop_payload({"speed": "slow"}))
}
// 5. Decorator Wrapper Pattern
sequence info_wrapper(action: tree) {
log("Starting action execution")
action(..)
log("Action execution completed")
}
// 6. Action Signatures (linking to runtime implementation)
impl charge_robot();
impl approach_target(pos: object);
impl validate_location(coords: array);
impl drop_payload(config: object);
impl log(message: string);
Core Constructs Quick Reference
| Element | Example Syntax | Description |
|---|---|---|
| Root Tree | root main sequence { ... } | Mandatory entry point for execution. |
| Sequence Node | sequence name(params) { ... } | Executes children in order until one fails. |
| Fallback Node | fallback name(params) { ... } | Executes children in order until one succeeds. |
| Action Definition | impl action_name(arg: type); | Defines an action implemented in Rust/Python. |
| Tree Parameter | sub_tree: tree | Pass a sub-tree as a parameter (Higher-Order Tree). |
| Tree Delegate | sub_tree(..) | Invoke a passed sub-tree parameter. |
| Decorator | retry(3) node(...) | Wraps execution with retry/inverter/timeout logic. |
| Blackboard Pointer | &key_name | Pass live reference to shared Blackboard memory. |
Imports & Modularity
Forester allows behavior tree definitions to be split across multiple files and organized into logical sub-modules. Imports make trees modular, reusable, and easy to maintain across large projects.
Imports are typically declared at the top of .tree files.
1. Import Syntax
Complete File Import
Imports all tree definitions and action signatures from the target file:
import "std::actions"
import "navigation/nav.tree"
import "sensors/battery.tree"
Selective Import with Aliasing
Imports specific definitions from a file and renames them using aliases to prevent naming conflicts:
import "robot_a/vision.tree" {
detect_object => detect_robot_a_object,
}
import "robot_b/vision.tree" {
detect_object => detect_robot_b_object,
}
2. Import Paths
Forester supports three types of import paths:
Relative Paths (Recommended)
Relative paths are evaluated relative to the project root directory:
Given the project directory structure:
my_project/
├── main.tree
└── modules/
└── navigation/
└── move.tree
In main.tree, import move.tree using:
import "modules/navigation/move.tree"
Standard Library Imports
Built-in standard library actions and decorators can be imported using standard package specifiers:
import "std::actions"
Absolute Paths
Absolute file paths can be used (primarily for global system modules):
import "/opt/forester/std/common.tree"
3. Resolving Name Conflicts with Aliases
When two separate .tree files define sub-trees with identical names, use alias mappings inside curly braces { } to assign unique local names:
// Import 'check_status' from hardware module
import "hardware/status.tree" {
check_status => check_hardware_status,
}
// Import 'check_status' from LLM agent module
import "agent/status.tree" {
check_status => check_agent_status,
}
root main sequence {
check_hardware_status()
check_agent_status()
}
4. Circular Import Handling
The Forester compiler automatically detects and resolves circular dependency graphs during compilation, raising a clear static compilation error if unresolvable recursive imports occur.
Tree Definitions Overview
A Tree Definition declares a reusable sub-tree or node contract in Forester. Definitions isolate control logic into modular components that can accept typed parameters, store state on the Blackboard, or wrap child trees.
Forester classifies tree definitions into four main categories:
Categories of Definitions
1. Control Flow Nodes
Control flow nodes govern how child nodes are scheduled and executed:
sequence: Ticks children sequentially until one returnsFailureorRunning. ReturnsSuccessif all children succeed.fallback: Ticks children sequentially until one returnsSuccessorRunning. ReturnsFailureif all children fail (often used for fallbacks and recovery).parallel: Ticks children concurrently based on a specified synchronization policy (e.g. success threshold).
Read more in Control Flow Nodes.
2. Root Entry Point (root)
The root definition declares the top-level starting point of the behavior tree. A Forester script must contain at least one root definition:
root main sequence {
check_preconditions()
execute_agent_task()
}
3. Decorator Nodes
Decorators are single-child nodes that modify or control the execution behavior of their child:
retry(N): Automatically re-ticks its child up to N times upon failure.inverter: Flips childSuccesstoFailureand vice versa.timeout(ms): Limits child execution time.repeat(N): Executes child N times sequentially.
Read more in Decorators.
4. Action Nodes (impl)
Actions represent the leaves of the behavior tree where actual work gets executed (e.g. motor movement, REST calls, LLM prompt generation). Action contracts are defined using impl:
impl call_llm_tool(prompt: string, model: string);
impl execute_motor_step(velocity: number);
Read more in Actions.
5. Lambdas & Higher-Order Trees
- Higher-Order Trees (HOT): Definitions that accept other sub-trees as parameters (
sub_tree: tree). - Lambdas: Anonymous inline sub-tree definitions created and executed at the point of invocation.
Read more in Higher-Order Trees & Lambdas.
Control Flow Nodes Overview
Control flow nodes direct the execution path of a behavior tree. They manage how child nodes are scheduled, evaluated, and short-circuited based on child tick results (Success, Failure, or Running).
Forester supports three fundamental control flow nodes:
1. Sequence Nodes (sequence)
- Behavior: Evaluates children sequentially from left to right.
- Short-circuiting:
- Returns
Failureimmediately if any child returnsFailure. - Returns
Runningif a child returnsRunning(halting further evaluation during that tick). - Returns
Successonly when all children returnSuccess.
- Returns
- Use Case: Step-by-step procedures (e.g.
[Check Precondition -> Execute Step 1 -> Execute Step 2]).
Detailed documentation: Sequence Nodes.
2. Fallback / Selector Nodes (fallback)
- Behavior: Evaluates children sequentially from left to right to find a successful path.
- Short-circuiting:
- Returns
Successimmediately if any child returnsSuccess. - Returns
Runningif a child returnsRunning. - Returns
Failureonly when all children returnFailure.
- Returns
- Use Case: Error handling, recovery strategies, and fallback routines (e.g.
[Primary Action -> Secondary Fallback Action -> Alert Operator]).
Detailed documentation: Fallback Nodes.
3. Parallel Nodes (parallel)
- Behavior: Ticks children concurrently during each engine tick.
- Completion Policy: Evaluates overall status based on child results and configured threshold policies (e.g., success threshold or fail-fast rules).
- Use Case: Concurrent tasks (e.g.
[Maintain Balance while Navigating to Goal]).
Detailed documentation: Parallel Nodes.
Summary of Return States
| Node Type | Succeeds When | Fails When | Short-Circuits On |
|---|---|---|---|
sequence | All children return Success | Any child returns Failure | First Failure or Running |
fallback | Any child returns Success | All children return Failure | First Success or Running |
parallel | Reaches success threshold | Reaches failure threshold | Policy dependant |
Sequence Nodes
A Sequence node executes its child nodes in order from left to right as long as each child returns Success. If any child returns Failure or Running, the sequence halts further evaluation and immediately propagates that status up the tree.
In the Forester DSL, sequence nodes are declared using the sequence keyword (or its memory/reactive variants m_sequence and r_sequence).
Standard Sequence (sequence)
Execution Flow & Rules
- Initial Tick: Starts at the first child node.
- Child Success: Moves to the next child. If the last child succeeds, the sequence returns
Success. - Child Running: Halts evaluation and returns
Running. On the next tick, execution resumes at the running child. - Child Failure: Immediately aborts remaining children and returns
Failure. - Reset / Halt: If restarted or aborted, execution starts back from the first child.
Example
import "std::actions"
root main sequence {
store("key_a", "1") // Tick 1: proceed if Success
store("key_b", "2") // Tick 2: proceed if Success
store("key_c", "3") // Tick 3: finish with Success
}
impl store(key: string, value: string);
Flow Diagram
graph TD
Root["root main"] --> Seq["sequence"]
Seq --> A["store (key_a)"]
Seq --> B["store (key_b)"]
Seq --> C["store (key_c)"]
Sequence Variants
Forester provides two specialized sequence variants for state persistence and real-time reactive control loops:
1. Memory Sequence (m_sequence)
An m_sequence remembers which child nodes have already succeeded. When re-ticked (e.g. after a decorator retry or on subsequent ticks), it skips previously succeeded children and resumes execution at the first non-successful child.
root main sequence {
retry(5) m_sequence {
check_preconditions() // Returns Success once
execute_task() // Returns Failure -> retry triggers m_sequence
finish_and_cleanup() // Execution resumes here without re-checking preconditions
}
}
- Memory Reset: Memory persists until the entire sequence finishes with
Successor is explicitly reset.
2. Reactive Sequence (r_sequence)
An r_sequence re-evaluates all preceding children on every tick, even if they succeeded on prior ticks. This ensures that preconditions remain valid while long-running lower nodes execute.
root main r_sequence {
is_battery_ok() // Re-checked on EVERY tick
navigate_to_target() // Returns Running for multiple ticks
perform_docking()
}
- Preemption & Halting: If
is_battery_ok()changes fromSuccesstoFailurewhilenavigate_to_target()isRunning, ther_sequenceimmediately haltsnavigate_to_target()and returnsFailure. - Halting behavior: Halting ensures graceful teardown of active synchronous and flow nodes before propagating state changes.
Fallback (Selector) Nodes
A Fallback (also known as a Selector) node executes its child nodes in order from left to right until one child returns Success. If all children return Failure, the Fallback node returns Failure.
In the Forester DSL, fallback nodes are declared using the fallback keyword (or its reactive variant r_fallback).
Standard Fallback (fallback)
Execution Flow & Rules
- Initial Tick: Starts at the first child node.
- Child Failure: Moves to the next child. If the last child fails, the fallback returns
Failure. - Child Success: Immediately short-circuits remaining children and returns
Success. - Child Running: Halts evaluation and returns
Running. On the next tick, execution resumes at the running child. - Reset / Halt: If restarted or aborted, evaluation resets back to the first child.
Precondition Check Pattern
Fallbacks are commonly used to enforce preconditions before running an action (i.e. [Precondition OR Action]):
import "std::actions"
root main sequence {
// If item is already held, skip move_to_item()
fallback {
is_item_in_hand(item = "battery_pack")
move_to_item(item = "battery_pack")
}
pickup_item(item = "battery_pack")
}
impl is_item_in_hand(item: string);
impl move_to_item(item: string);
impl pickup_item(item: string);
LLM Fallback Pattern (AI Agents)
Fallbacks provide a clean mechanism for multi-model LLM fallbacks without nested try/except blocks:
root main fallback {
call_primary_llm({"model": "gpt-4o"})
call_fallback_llm({"model": "claude-3-5-sonnet"})
alert_human_operator({"reason": "All LLM APIs failed"})
}
impl call_primary_llm(config: object);
impl call_fallback_llm(config: object);
impl alert_human_operator(config: object);
Flow Diagram
graph TD
Root["root main"] --> Fall["fallback"]
Fall --> A["call_primary_llm"]
Fall --> B["call_fallback_llm"]
Fall --> C["alert_human_operator"]
Reactive Fallback (r_fallback)
An r_fallback re-evaluates all preceding children on every tick, even while a lower child is currently Running.
root main r_fallback {
emergency_battery_low() // Checked on EVERY tick
perform_long_task() // Returns Running for multiple ticks
fallback_idle()
}
impl emergency_battery_low();
impl perform_long_task();
impl fallback_idle();
Preemption Behavior
If perform_long_task() is Running and emergency_battery_low() evaluates to Success on a subsequent tick:
r_fallbackimmediately haltsperform_long_task().r_fallbackexecutesemergency_battery_low()and returnsSuccess.- Lower nodes are safely preempted.
Parallel Nodes
A Parallel node provides concurrent execution of its child nodes within a single engine tick. Unlike sequence or fallback nodes (which short-circuit immediately upon receiving a result), a parallel node ticks all active children during every tick pass.
In the Forester DSL, parallel nodes are declared using the parallel keyword.
Execution Flow & Rules
- Concurrent Ticking: During a tick pass, the parallel node iterates through all child nodes and ticks each one sequentially within that single frame.
- Child Processing:
- If a child returns
SuccessorFailure, its status is stored. - If a child returns
Running, it continues running. On subsequent ticks, already completed children (SuccessorFailure) are skipped, whileRunningchildren are re-ticked.
- If a child returns
- Overall State Determination:
Success: Returned when all child nodes evaluate toSuccess.Failure: Returned if any child node returnsFailure(unless a custom threshold policy is configured).Running: Returned if at least one child node remainsRunningand no failure threshold has been breached.
Code Example
import "std::actions"
root main sequence {
// Run concurrent tasks in parallel
parallel {
clean_current_room() // Async action (returns Running)
inspect_environment() // Sync or async action
}
navigate_to_next_room()
}
impl clean_current_room();
impl inspect_environment();
impl navigate_to_next_room();
Key Considerations for Parallel Execution
- Async & Sync Actions: Parallel nodes are ideal for launching multiple non-blocking async tasks simultaneously (e.g. streaming sensor data while running an LLM tool call).
- Non-Reactive Skipping: Once a child completes with
SuccessorFailure, the parallel node skips re-ticking it on subsequent frames until the parent parallel node finishes and resets. - Shared Blackboard Access: When children running in parallel write to the Blackboard, care should be taken to avoid key collisions or race conditions.
Decorators
A Decorator is a specialized single-child node that wraps a child node and modifies its execution behavior or transforms its return state (Success, Failure, or Running).
Every decorator accepts exactly one child node (which can be a single action, a sub-tree, or a block wrapped in { }).
Built-In Decorator Reference
| Decorator | Parameters | Default | Description |
|---|---|---|---|
inverter | None | — | Inverts child Success to Failure, and Failure to Success. (Running is untouched). |
force_success | None | — | Always returns Success regardless of whether the child succeeds or fails. |
force_fail | None | — | Always returns Failure regardless of child outcome. |
repeat(N) | count: number | 0 (Infinite) | Repeats execution of the child $N$ times. If count is 0, repeats infinitely. |
retry(N) | attempts: number | 0 (Infinite) | Re-ticks the child up to $N$ times if it returns Failure. If attempts is 0, retries indefinitely. |
timeout(ms) | limit: number | 1000 | Limits child execution time in milliseconds. Halts child and returns Failure if exceeded. |
delay(ms) | wait: number | 0 | Delays the initial execution of the child for the specified duration in milliseconds. |
Placement of Decorators
Decorators in Forester can be placed in two main ways:
1. In Tree / Root Definitions (Header Decorators)
Decorators can be placed directly in the declaration header of a root node or sub-tree definition:
// Root node wrapped in a repeat decorator
root main_fixed repeat(5) {
execute_cycle()
}
// Sub-tree definition wrapped with a retry decorator
sequence retryable_task retry(3) {
perform_step()
}
2. At Invocation Sites
Decorators can be placed directly before an action invocation, sub-tree call, or inline lambda:
root main sequence {
// Decorator on an action invocation
retry(3) perform_task()
// Decorator on a sequence block
timeout(1000) sequence {
fetch_data()
}
// Decorator on an inline lambda
retry(5) lambda sequence {
fetch_sensor_data()
validate_reading()
}
}
Detailed Examples
1. Inverter (inverter)
Inverts the result of condition checks or actions:
import "std::actions"
root main sequence {
// Succeeds if obstacle_detected() fails
inverter obstacle_detected()
move_forward()
}
impl obstacle_detected();
impl move_forward();
2. Result Overrides (force_success & force_fail)
Enforce specific outcome states regardless of child execution:
root main sequence {
// Attempt cleanup; continue even if cleanup returns Failure
force_success cleanup_temp_files()
proceed_main_task()
}
impl cleanup_temp_files();
impl proceed_main_task();
3. Repeat (repeat)
Executes a child multiple times (or infinitely for continuous control loops):
// Infinite execution loop
root main_idle repeat {
background_health_check()
}
// Fixed 5-cycle loop
root main_fixed repeat(5) {
execute_cycle()
}
impl background_health_check();
impl execute_cycle();
4. Retry (retry)
Automatically retries failing actions (ideal for network calls or LLM tool invocations):
root main sequence {
// Retry up to 5 times if call_llm returns Failure
retry(5) call_llm({"prompt": "analyze_image"})
}
impl call_llm(params: object);
5. Timeout (timeout) & Delay (delay)
Controls timing for async actions:
root main sequence {
// Wait 500ms before starting initial tick
delay(500) initialize_sensors()
// Shut down async action if it runs longer than 3000ms
timeout(3000) fetch_remote_data()
}
impl initialize_sensors();
impl fetch_remote_data();
Actions (impl)
Actions form the leaf nodes of a behavior tree. They represent the actual work performed by external code—such as controlling hardware motors, querying databases, making REST calls, or invoking LLM tools.
In the Forester DSL, actions are declared using the impl keyword.
Action Signatures (impl)
An action contract defines the action name, input parameters, and expected parameter types:
// Action signature declarations
impl navigate_to_pose(target: object);
impl check_battery_level(min_voltage: number);
impl call_llm_tool(prompt: string, model: string);
impl reset_system_state(){}
- Semicolons: A trailing semicolon
;or empty block{}is required after animpldeclaration.
Action Execution Modes
The Forester runtime supports two primary execution mechanisms:
1. Local Actions (Rust Engine)
Implemented directly in Rust using the Action trait and registered with ForesterBuilder:
#![allow(unused)]
fn main() {
use forester_rs::runtime::action::{Action, ActionArgs, TickResult};
struct NavigateToPose;
impl Action for NavigateToPose {
fn tick(&self, args: ActionArgs) -> TickResult {
// Perform navigation logic
TickResult::Success
}
}
}
- Synchronous Actions: Block until execution completes during the current tick.
- Asynchronous Actions: Return
TickResult::Runningimmediately for non-blocking operations, completing on subsequent ticks.
2. Remote Actions (Python / Microservices)
Actions executed in external processes (e.g. Python AI agents using forester-http-ra-py). The Forester engine sends an RPC/HTTP request to the remote action server and handles the response.
Strict Contract Enforcement
The Forester compiler enforces strict type checking and argument count matching between impl signatures and invocation sites:
impl calculate_route(destination: object, max_speed: number);
root main sequence {
// ❌ COMPILE ERROR: Missing required 'max_speed' argument
calculate_route(destination = {"x": 10, "y": 20})
}
Passing Blackboard References (&pointer)
Actions can accept live Blackboard references using the & pointer prefix. This allows nodes to read dynamically updated state without hardcoding values at definition time:
impl analyze_sensor_data(data_ref: string);
root main sequence {
// Pass live Blackboard reference &sensor_reading
analyze_sensor_data(data_ref = &sensor_reading)
}
Built-In Standard Library Actions (std::actions)
Forester provides a built-in standard library of common utility actions, Blackboard operations, and state helpers.
To use standard library actions, import std::actions at the top of your .tree file:
import "std::actions"
root main sequence {
store("session_id", "12345")
equal("session_id", "12345")
}
Selective imports with aliasing can also be used:
import "std::actions" {
store => set_blackboard_key,
fail => throw_failure,
}
Standard Action Reference
1. Terminal / Flow Control Actions
| Action | Arguments | Return State | Description |
|---|---|---|---|
success() | None | Success | Instantly returns Success. Useful as stub or default branch. |
fail(reason) | reason: string | Failure | Instantly fails execution with an explicit error reason. |
fail_empty() | None | Failure | Instantly fails execution without a reason message. |
running() | None | Running | Returns Running. Keeps node active on subsequent ticks. |
sleep(duration) | duration: number | Success | Non-blocking sleep for $N$ milliseconds before returning Success. |
2. Blackboard Memory Actions
| Action | Arguments | Description |
|---|---|---|
store(key, value) | key: string, value: string | Stores a value into the Blackboard under key. |
equal(key, expected) | key: string, expected: string | Compares Blackboard value under key to expected. Returns Success if equal, Failure otherwise. |
store_tick(key) | key: string | Stores the current engine tick number into key. |
lock(key) | key: string | Locks a Blackboard key to prevent modifications by other nodes. |
unlock(key) | key: string | Unlocks a previously locked Blackboard key. |
3. I/O & Network Actions
| Action | Arguments | Description |
|---|---|---|
http_get(url, bb_key) | url: string, bb_key: string | Performs an HTTP GET request to url and stores the response payload into bb_key. |
Code Example
import "std::actions"
root main sequence {
// 1. Initialize Blackboard state
store("agent_status", "initializing")
// 2. Perform work
fallback {
http_get("https://api.example.com/health", "health_response")
fail("Health check API unreachable")
}
// 3. Verify status
equal("agent_status", "initializing")
// 4. Update status to active
store("agent_status", "active")
}
Invocations & Higher-Order Composition
In Forester, an Invocation calls a declared sub-tree definition, built-in decorator, action, or inline lambda within another tree definition.
Invocations are the primary mechanism for composing complex behavior trees out of smaller, modular building blocks.
Basic Invocation Syntax
Invocations support both positional and named argument syntax:
import "std::actions"
// Sub-tree definition with parameters
sequence check_distance(item: object, threshold: number) {
store("target_item", item)
handle_distance(item, threshold)
}
root main sequence {
// Positional argument invocation
check_distance({"x": 10, "y": 20}, 50)
// Named argument invocation
check_distance(
item = {"x": 30, "y": 40},
threshold = 100
)
}
impl handle_distance(item: object, limit: number);
Functional Composition Capabilities
Forester extends standard behavior tree semantics by offering advanced functional composition primitives:
1. Higher-Order Trees (HOT)
Pass sub-trees as parameters (t: tree) to higher-order tree definitions and delegate execution using the t(..) syntax. This allows developers to write generic retry, fallback, or logging wrappers once and reuse them across the codebase.
Read more in Higher-Order Trees.
2. Lambdas (Anonymous Inline Sub-Trees)
Define and instantly invoke inline, anonymous sub-trees (lambda sequence { ... }) without creating throwaway named definitions.
Read more in Lambdas.
Summary of Invocation Types
| Invocation Type | Example Syntax | Description |
|---|---|---|
| Standard Sub-Tree | navigate_to(target) | Invokes a named sub-tree definition. |
| Action Invocation | call_llm(prompt) | Invokes an external impl action. |
| Delegated Tree (HOT) | sub_action(..) | Invokes a sub-tree passed as a parameter. |
| Inline Lambda | lambda sequence { ... } | Creates and executes an anonymous sub-tree inline. |
Higher-Order Trees (HOT)
A Higher-Order Tree is a tree definition that accepts other sub-trees as typed parameters (param: tree) and delegates execution to them using the param(..) syntax.
This is Forester’s most powerful functional abstraction. It eliminates repetitive copy-pasted retry/fallback/logging patterns by letting you write a pattern once and inject the varying behavior as a parameter.
Motivation
Consider a common robotics pattern: check a precondition, and if it fails, run a corrective task. Without HOTs you repeat this fallback structure for every step:
sequence handle(item: object) {
fallback { close_enough(item) approach(item) }
fallback { is_graspable(item) grasp(item) }
fallback { enough_space(item) sequence { move(item) save(item) } }
}
With a Higher-Order Tree you name the pattern once and inject the steps:
// Define the pattern once
fallback precond_or_fix(condition: tree, fix: tree) {
condition(..)
fix(..)
}
// Reuse it without duplication
sequence handle(item: object) {
precond_or_fix(close_enough(item), approach(item))
precond_or_fix(is_graspable(item), grasp(item))
precond_or_fix(enough_space(item), sequence { move(item) save(item) })
}
Syntax
Declare a tree-typed parameter in the definition signature. Invoke it inside the body using param_name(..):
// Generic retry-with-fallback wrapper
fallback retryer(action: tree, on_fail: tree) {
retry(3) action(..)
on_fail(..)
}
root main sequence {
// Pass any tree as action or on_fail
retryer(
call_llm({"model": "gpt-4o"}),
alert_operator()
)
}
impl call_llm(config: object);
impl alert_operator();
Lambdas as HOT Arguments
Inline lambdas can be passed directly as tree arguments, avoiding throwaway named definitions:
root main sequence {
retryer(
lambda sequence {
fetch_data()
validate_data()
},
notify_failure()
)
}
Parameter Scoping Rules
Forester does not perform closure-style parameter capturing. The semantics are:
| Argument type | Resolved when? |
|---|---|
| Static constants (strings, numbers, objects) | Captured at definition site and passed as-is |
Blackboard pointers (&key) | Resolved at the moment of invocation |
This means Blackboard pointer arguments always reflect the live state of the Blackboard at invocation time, not at the point where the HOT definition was written.
Common Reusable Patterns
// Retry-or-notify
fallback retry_or_notify(action: tree, notify: tree) {
retry(3) action(..)
notify(..)
}
// Timed action with fallback
fallback timed(action: tree, limit_ms: number, on_timeout: tree) {
timeout(limit_ms) action(..)
on_timeout(..)
}
// Logged execution
sequence logged(label: string, action: tree) {
log(label)
action(..)
}
impl log(message: string);
Lambdas (Anonymous Inline Sub-Trees)
A Lambda is an anonymous, inline sub-tree that is defined and executed at the point of invocation — no named definition required.
Lambdas are ideal for one-off logic that is too simple to justify a named definition, or for passing inline behavior directly as a Higher-Order Tree argument.
Key Properties
- No name: Lambdas are anonymous — they cannot be referenced from elsewhere.
- No parameters: Lambdas do not accept arguments. They operate on Blackboard state directly.
- Flow nodes only: Lambdas can only contain flow control nodes (
sequence,fallback,parallel, decorators) and action invocations. Action signatures must still be declared withimplelsewhere. - Unique instances: Each lambda definition creates a distinct node in the compiled tree.
Basic Syntax
Any inline sequence, fallback, or parallel block without a name is a lambda:
import "std::actions"
impl job();
root main sequence {
// Lambda: unnamed inline sequence
sequence {
job()
job()
job()
}
// Lambda: unnamed fallback with nested lambdas
fallback {
sequence {
job()
job()
}
// Decorator on a single-child lambda (brackets omitted)
retry(3) job()
}
}
Lambdas as Higher-Order Tree Arguments
Lambdas can be passed directly as tree-typed arguments to Higher-Order Tree definitions. This avoids the need to create throwaway named definitions just to pass a block of logic:
impl savepoint();
impl fetch_data();
impl validate();
impl store_result();
sequence bookmarked(action: tree) {
savepoint()
action(..)
savepoint()
}
root main sequence {
// Pass an inline lambda as the 'action' argument
bookmarked(
sequence {
fetch_data()
validate()
store_result()
}
)
// Named argument syntax
bookmarked(
action = fallback {
fetch_data()
store_result()
}
)
}
When to Use a Lambda vs. a Named Definition
| Situation | Recommendation |
|---|---|
| One-off logic used in a single place | Lambda |
| Logic reused in 2+ places | Named sub-tree definition |
| Passed as a HOT argument inline | Lambda |
| Needs its own parameters | Named sub-tree definition |
Parameters
Terminology
- Parameters are elements of tree definitions.
- Arguments are elements of tree invocations.
// parameters 'a' and 'b'
sequence tree(a:string,b:num){
// arguments 'c', 'd'
job(c = 1, d = "d")
}
Arguments
Therefore, the arguments represent the attachments of the real value to the parameters. The Argument can be one of two types:
- Named argument
- Unnamed argument
impl action(a:string, b:num)
root main sequnce {
// Named Arguments
action(a="a",b:1)
// Unnamed Arguments
action("a",1)
}
There is impossible to mix named and unnamed arguments The following code will have an error during the compilation process.
impl action(a:string, b:num)
root main sequnce {
action("a",b=1 )
}
Types
Number
The numbers are defined with a keyword num
There are 4 possible types of numbers presented:
- Integers(64)
- Floats(64)
- Hex
- Binary
In case of exceeding the maximum value, the error will be raised on the compile time.
impl action(param:num)
root main sequence {
// Integers
action(1)
action(10e2)
action(-1)
action(0)
// Floats
action(0.0)
action(100.0e1)
action(-100.0)
// Hex
action(0x123)
// Binary
action(0b010101)
}
String
The strings are defined with string
impl action(param:string)
root main action(param = "X")
Boolean
The booleans are defined with a keyword bool and has the following parameters:
truefor the positive statementfalsefor the negative statement
impl action(param:bool);
root main action(true)
Arrays
The arrays are defined with keyword array
Arrays can have several aforementioned elements encompassed in one entity.
The arrays have the following syntax:
[defines the start of array]defines the end of array,defines the separator between elements- the rest is defined by the particular elements
It is expected, the arrays are homogeneous and have all elements only one type
The arrays can have a trailing comma as well, [1,]
impl action(elems:array);
root main sequence {
action([1,2,3,4])
action([1.1,0.1])
action(["a","b"])
}
Objects
The objects are defined with keyword object
Objects can have several aforementioned elements encompassed
in one entity with the unique key attached to the every entity
The objects have the following syntax:
{defines the start of object}defines the end of object,defines the separator between elements- “key” defines the name of the element key
- the rest is defined by the particular elements
The objects can have a trailing comma as well, {"a":1,}
impl action(elems:object);
root main sequence {
action({"key":1, "key2":"key"})
action(
{
"array": [1,2,3,4,],
"string":"string",
"num":1,
"pointer": pointer
}
)
}
Tree
The other tree definitions are defined with a keyword tree
**The parameters of this type can be added and defined only in the flow definitions.
impl log(id:string,info:string);
cond check();
cond task();
fallback checked_task(check:tree, task:tree){
check(..)
task(..)
}
sequence logged_task(id:string, task:tree){
log(id,"start task")
task(..)
log(id,"end task")
}
root main sequence {
// invoke the task parameter, passing the invokations with parameters
logged_task(
"1",
// invoke the task, passing the invokations with parameters
checked_task(check = check(), task())
)
}
Pointers
Pointers are identifiers of the objects in the BlackBoard Therefore, they can be used to obtain the value of the cell from bb, in argument invoking.
In the example below, the system expects to find a string value in the cell with a name bb_key.
impl action(value:string);
root main sequence {
// this is a pointer to a cell in bb with an id 'bb_key'
action(bb_key)
}
Any
The any type is a special type that can be used to pass any type of value to the parameter.
The type any can take any message type except call
The intention to provide a simple way to generalize the parameters and pass any type of value to the parameter.
// can take anything except call
impl action(value:any);
root main sequence {
action(1)
action("a")
action([1])
}
Antlr grammar
The grammar bears an introducing character (means it is not used straight in the code for now)
Parser
parser grammar TreeParser;
file
: (definition | importSt)* EOF
;
import_name
: id (EQ_A id)?
;
importCalls
: LBC (import_name (COMMA import_name)* COMMA?)? RBC
;
importSt
: IMPORT string importCalls?
;
definition
: tree_type id params? (calls? | SEMI)
;
call
: invocation
| lambda
;
invocation
: id (args | LPR DOT_DOT RPR)
;
lambda
: tree_type args? calls
;
calls
: LBC call* RBC
| call
;
arg
: id (EQ (message | id | call))?
| message
| call
;
args
: LPR (arg (COMMA arg)* COMMA?)? RPR
;
params
: LPR (param (COMMA param)*)? COMMA? RPR
;
param
: id COLON mes_type
;
message
: string
| num
| bool
| array
| object
;
mes_type
: NUM_T
| ARRAY_T
| OBJECT_T
| STRING_T
| BOOL_T
| TREE_T
;
tree_type
: ROOT
| PARALLEL
| SEQUENCE
| MSEQUENCE
| RSEQUENCE
| FALLBACK
| RFALLBACK
| id // ambigulty
;
object
: LBC (objectPair (COMMA objectPair)* COMMA? )? RBC
;
objectPair
: string COLON message
;
array
: LBR (message (COMMA message)* COMMA? )? RBR
;
bool
: TRUE
| FALSE
;
num
: NUMBER
;
string
: STRING
;
id
: ID
;
Lexer
lexer grammar TreeLexer;
ROOT: 'ROOT';
PARALLEL : 'parallel';
SEQUENCE : 'sequence';
MSEQUENCE : 'm_sequence';
RSEQUENCE : 'r_sequence';
FALLBACK: 'fallback';
RFALLBACK : 'r_fallback';
ARRAY_T: 'array';
NUM_T: 'num';
OBJECT_T: 'object';
STRING_T: 'string';
BOOL_T: 'bool';
TREE_T: 'tree';
IMPORT: 'import';
ID : [-_a-zA-Z]+ (INT | [-_a-zA-Z]+)* ;
COMMA : ',';
COLON : ':';
SEMI : ';';
DOT_DOT : '..';
EQ : '=';
EQ_A : '=>';
LPR : '(';
RPR : ')';
LBC : '{';
RBC : '}';
LBR : '[';
RBR : ']';
TRUE : 'TRUE';
FALSE : 'FALSE';
STRING : '"' (ESC | SAFECODEPOINT)* '"' ;
NUMBER : '-'? INT ('.' [0-9] +)? EXP? ;
Whitespace: [ \t]+ -> skip ;
Newline : ( '\r' '\n'? | '\n') -> skip ;
BlockComment : '/*' .*? '*/' -> skip ;
LineComment : '//' ~[\r\n]* -> skip ;
fragment ESC : '\\' (["\\/bfnrt] | UNICODE) ;
fragment UNICODE : 'u' HEX HEX HEX HEX ;
fragment HEX : [0-9a-fA-F] ;
fragment SAFECODEPOINT : ~ ["\\\u0000-\u001F] ;
fragment INT : '0' | [1-9] [0-9]* ;
fragment EXP : [Ee] [+\-]? [0-9]+ ;
Runtime Engine Overview
The Forester runtime is the execution core that compiles .tree files, builds the behavior tree node graph, and drives the tick-by-tick evaluation loop.
It is built in Rust and is designed for both high-throughput robotics control loops and AI agent orchestration workflows.
Core Subsystems
| Component | Description |
|---|---|
Engine (Forester) | Orchestrates the tick loop, manages node state transitions, and schedules sync/async action execution. |
| Blackboard | In-memory shared key-value store for passing state between tree nodes across ticks. |
| ActionKeeper | Registry that maps action names declared with impl in .tree files to Rust handlers or remote action clients. |
Execution Model
- Synchronous by default: The engine drives a deterministic tick loop. Synchronous actions block until they return
Success,Failure, orRunning. - Async environment: Async actions return
Runningimmediately and are polled on subsequent ticks without blocking the tick loop. - Parallelism:
parallelnodes tick multiple children in a single pass; async actions allow true concurrent I/O within a tick cycle.
Entry Point: ForesterBuilder
The ForesterBuilder is the primary API for constructing and configuring a Forester runtime instance. It provides a safe, fluent interface for:
- Pointing to
.treesource files or inline scripts - Registering action implementations
- Pre-loading Blackboard state from JSON
- Enabling execution tracing
use forester_rs::runtime::builder::ForesterBuilder;
use forester_rs::runtime::action::{Action, ActionArgs, TickResult};
use forester_rs::runtime::action::builtin::data::StoreData;
use forester_rs::tracer::Tracer;
fn main() {
let mut fb = ForesterBuilder::from_file_system();
// Point to the tree entry file
fb.main_file("main.tree".to_string());
// Register built-in and custom actions
fb.register_action("store", Action::sync(StoreData));
fb.register_sync_action("my_action", MyAction);
// Enable execution tracing
fb.tracer(Tracer::default());
// Pre-load Blackboard initial state from JSON
fb.bb_load("db/initial_state.json".to_string());
// Build and run
let mut forester = fb.build().unwrap();
let result = forester.run().unwrap();
println!("Execution result: {:?}", result);
}
Detailed Subsystem Documentation
- Engine internals — tick loop, node lifecycle, and state machine details
- Runtime arguments — configuring engine behavior at startup
- Blackboard — shared memory API and locking semantics
- Actions — registering sync, async, and remote actions
- Trimming — modifying the running tree on the fly
- Daemons — background tasks running alongside the tree
Engine Internals
The Forester engine is the central execution runtime. It drives the tick loop, manages node state, dispatches actions, and coordinates the Blackboard, ActionKeeper, and optional tracer.
Internal Components
| Component | Description |
|---|---|
| Engine Core | Manages the tree node graph, drives the tick loop, and handles state transitions (Running, Success, Failure). |
| Blackboard | In-memory shared key-value store for passing state between nodes. |
| ActionKeeper | Registry mapping impl action names to Rust handlers or remote action clients. |
| Tracer | Optional execution logger that records tick-by-tick node evaluations for debugging and replay. |
Tick Loop
On each tick, the engine traverses the tree from the root node down, evaluating each node according to its type (sequence, fallback, parallel, decorator, or action). The tick loop continues until the root node returns Success or Failure.
Bounded Execution
To prevent infinite loops and enable time-boxed execution (useful for simulation or testing), the tick count can be capped:
#![allow(unused)]
fn main() {
// Run for a maximum of 100 ticks
forester.run_until(Some(100)).unwrap();
// Run until the root returns Success or Failure (unbounded)
forester.run().unwrap();
}
Runtime Environment
The engine uses Tokio as its async runtime to schedule and parallelize async action execution.
By default, the engine creates its own Tokio runtime. If the engine is embedded in an application that already has a Tokio runtime, it can be provided via ForesterBuilder:
use forester_rs::runtime::builder::ForesterBuilder;
use forester_rs::tracer::Tracer;
use forester_rs::runtime::action::Action;
use forester_rs::runtime::action::builtin::data::StoreData;
fn main() {
let mut fb = ForesterBuilder::from_file_system();
fb.main_file("main.tree".to_string());
fb.register_action("store", Action::sync(StoreData));
fb.tracer(Tracer::default());
fb.bb_load("db/initial_state.json".to_string());
let mut forester = fb.build().unwrap();
// Unbounded run
let result = forester.run().unwrap();
println!("Result: {:?}", result);
}
Built-In HTTP Server
The engine optionally exposes an HTTP server during tree execution. This enables external processes — including remote action servers and monitoring tools — to interact with the Blackboard and tracer at runtime.
Enable it by specifying a port in ForesterBuilder:
#![allow(unused)]
fn main() {
fb.http_serv(10000);
}
Note: The HTTP server shuts down automatically when the root tree finishes execution.
HTTP API Endpoints
| Method | Endpoint | Description |
|---|---|---|
GET | / | Health check. Returns Ok. |
GET | /tracer/print | Prints current tracer output. |
POST | /tracer/custom | Appends a custom event to the tracer log (body: CustomEvent JSON). |
GET | /bb/:key | Reads value at key from the Blackboard. |
POST | /bb/:key | Writes a value to key in the Blackboard (body: RtValue JSON). |
GET | /bb/:key/take | Reads and removes key from the Blackboard. |
GET | /bb/:key/lock | Locks key to prevent concurrent writes. |
GET | /bb/:key/unlock | Unlocks key. |
GET | /bb/:key/locked | Returns whether key is currently locked. |
GET | /bb/:key/contains | Returns whether key exists in the Blackboard. |
Runtime Arguments (RtValue)
When a behavior tree action is ticked, its parameters are passed to the Rust handler as runtime argument values (RtValue). These are the runtime representations of the typed static arguments declared in the .tree DSL.
The RtValue Enum
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub enum RtValue {
String(String),
Bool(bool),
Number(RtValueNumber),
Array(Vec<RtValue>),
Object(HashMap<String, RtValue>),
Pointer(BBKey),
}
}
Primitive Types
String,Bool,Number: Direct value types, equivalent to their counterparts in standard languages.
Complex Types
Array: An ordered list ofRtValueelements.Object: A JSON-style key-value map (HashMap<String, RtValue>), useful for structured action configuration.
Pointer (Blackboard Reference)
Pointer(BBKey): A live reference to a Blackboard key. When resolved, the action fetches the current value stored at that Blackboard cell rather than using a static literal.
Pointer Resolution
A Pointer allows passing dynamic, live Blackboard state into an action without hardcoding the value at call time.
import "std::actions"
root main r_sequence {
// Stores the current tick count into bb["tick"]
store_tick("tick")
// equal() receives a Pointer to bb["tick"], not the literal string "tick"
r_fallback {
equal(tick, 10)
running()
}
}
Note:
store_tick("tick")receives"tick"as aString(the cell name to write into). In contrast,equal(tick, 10)receivestickas aPointer(meaning: resolve the value stored at Blackboard key"tick"and compare it to10).
Pointer Indirection Example
Pointers can also point to cells that themselves contain key names (double indirection):
import "std::actions"
root main sequence {
store("x", "tick") // bb["x"] = "tick"
store_tick(x) // x is a Pointer -> writes to bb["x"] which is tick and resolves to bb["tick"]
equal(tick, 10) // tick is a Pointer -> reads bb["tick"] and compares to 10
}
Extracting Argument Values in Rust
Forester provides two methods for reading action arguments in Rust action implementations:
1. Direct Cast: as_<type>()
The fastest approach — directly converts the RtValue to a primitive or complex type. Does not resolve Pointers, so use only when you are certain no Blackboard references will be passed:
#![allow(unused)]
fn main() {
fn handle(v: RtValue) {
let val: Option<String> = v.as_string();
let num: Option<f64> = v.as_float();
}
}
2. Context-Aware Cast: cast(ctx)
Resolves Pointers by looking up the referenced Blackboard key at invocation time. Use this when your action may receive either a literal or a Blackboard reference:
#![allow(unused)]
fn main() {
use forester_rs::runtime::action::{Impl, RtArgs, Tick};
use forester_rs::runtime::context::TreeContextRef;
use forester_rs::runtime::RuntimeError;
impl Impl for CheckEqual {
fn tick(&self, args: RtArgs, ctx: TreeContextRef) -> Tick {
let key = args
.find_or_ith("key".to_string(), 0)
.ok_or(RuntimeError::fail("the 'key' argument is required".to_string()))?;
// cast(ctx) resolves Pointer values against the live Blackboard
let resolved: String = key.cast(ctx.clone()).str()?;
Ok(TickResult::success())
}
}
}
Recommendation: Prefer
cast(ctx)in most action implementations. It handles both literal values and Blackboard pointers transparently.
Blackboard
The Blackboard is Forester’s shared in-memory state store — a key-value store that nodes read from and write to across ticks. It is the primary mechanism for passing data between actions and tree nodes without tight coupling.
In AI agent workflows, the Blackboard acts as the agent’s working context: LLM responses, tool call results, and session state are written here and read by downstream nodes.
Data Format
The Blackboard stores pairs of String keys and BBValue values:
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub enum BBValue {
Locked(RtValue), // Key exists and is locked: read-only, no further writes
Unlocked(RtValue), // Key exists and is freely readable and writable
Taken, // Key slot exists but value has been consumed (taken)
}
}
Key States
| State | Description |
|---|---|
Unlocked(RtValue) | Normal state. The value can be read, overwritten, or taken by any node. |
Locked(RtValue) | The value is protected from modification. Other nodes can still read it, but cannot overwrite or take it. |
Taken | The key slot exists but its value has been consumed by a take operation. |
Accessing the Blackboard from DSL
Use the built-in std::actions to interact with the Blackboard directly from .tree files:
import "std::actions"
root main sequence {
// Write a value
store("agent_mode", "active")
// Lock a key to prevent modification during critical section
lock("agent_mode")
// Read and compare
equal("agent_mode", "active")
// Unlock when done
unlock("agent_mode")
}
Accessing the Blackboard from Rust
In Rust action implementations, the Blackboard is accessed through the TreeContextRef:
#![allow(unused)]
fn main() {
use forester_rs::runtime::context::TreeContextRef;
use forester_rs::runtime::action::{Impl, RtArgs, Tick, TickResult};
impl Impl for MyAction {
fn tick(&self, args: RtArgs, ctx: TreeContextRef) -> Tick {
let mut ctx = ctx.lock()?;
// Read a value from the Blackboard
let mode = ctx.bb().get("agent_mode")?.and_then(|v| v.as_string());
// Write a value to the Blackboard
ctx.bb().put("result", RtValue::String("done".to_string()))?;
Ok(TickResult::success())
}
}
}
Persistence: Load & Dump
The Blackboard supports JSON serialization for pre-loading initial state and dumping snapshots:
| Method | Description |
|---|---|
bb_load(path) | Loads initial Blackboard state from a JSON file at engine startup (via ForesterBuilder). |
dump(path) | Saves a snapshot of the full Blackboard to a JSON file. |
print_dump() | Prints the current Blackboard snapshot to stdout in JSON format. |
text_dump() | Returns the Blackboard snapshot as a JSON string. |
#![allow(unused)]
fn main() {
// Pre-load state at startup
fb.bb_load("db/initial_state.json".to_string());
}
HTTP Access
When the engine’s built-in HTTP server is enabled, the Blackboard is also accessible over HTTP at runtime. See the Engine HTTP API for endpoint details.
Utilities
A set of helper utilities for common Blackboard operations is available in the blackboard::utils module, for example:
blackboard::utils::push_to_arr(key, value, ctx)— Appends a value to an array stored at a given key.
Runtime Actions
Actions are the leaf nodes of the behavior tree — the points where actual work gets done. Every action declared with impl in a .tree file must be bound to a Rust handler (or a remote client) via ForesterBuilder before the tree can run.
Action Types
Forester supports three execution modes for actions:
| Type | Blocking? | Use Case |
|---|---|---|
| Sync | Yes — blocks the tick until done | Fast, CPU-bound, or deterministic operations |
| Async | No — returns Running immediately | I/O-bound operations, LLM calls, network requests |
| Remote | Yes — sends an HTTP request and waits | External processes (Python agents, microservices) |
For heavy or slow operations, prefer async actions to avoid blocking the tick loop.
Action Traits
1. Sync Actions (Impl)
Sync actions block the tick loop until they return a result. They are the only action type that currently supports the halt() callback:
#![allow(unused)]
fn main() {
use forester_rs::runtime::action::{RtArgs, Tick, RtOk};
use forester_rs::runtime::context::TreeContextRef;
pub trait Impl {
fn tick(&self, args: RtArgs, ctx: TreeContextRef) -> Tick;
// Called when a reactive flow node (r_sequence, r_fallback) preempts this action.
// Implement this to clean up resources gracefully.
// Default is a no-op.
fn halt(&self, args: RtArgs, ctx: TreeContextRef) -> RtOk {
Ok(())
}
}
}
Important:
halt()must return as quickly as possible — it must not block execution.
2. Async Actions (ImplAsync)
Async actions are spawned in a separate Tokio task and return Running on the first tick. The engine re-polls them on subsequent ticks until they resolve:
#![allow(unused)]
fn main() {
use forester_rs::runtime::action::{RtArgs, Tick};
use forester_rs::runtime::context::TreeContextRef;
pub trait ImplAsync: Sync + Send {
fn tick(&self, args: RtArgs, ctx: TreeContextRef) -> Tick;
}
}
Note: When the engine runs with a tick limit (
run_until(Some(N))), async actions consume ticks whileRunning. Account for this when setting tick budgets.
3. Remote Actions (ImplRemote)
Remote actions send an HTTP POST request to an external server and wait for the response. This is the mechanism for Python AI tool integration via forester-http-ra-py.
#![allow(unused)]
fn main() {
pub trait ImplRemote: Sync + Send {
fn tick(&self, args: RtArgs, ctx: TreeRemoteContextRef) -> Tick;
}
pub struct TreeRemoteContextRef<'a> {
pub curr_ts: Timestamp, // Current tick timestamp
pub port: u16, // Port of the engine HTTP server (for Blackboard access)
pub env: &'a mut RtEnv, // Runtime env for making HTTP requests
}
}
The default remote action implementation (RemoteHttpAction) handles the HTTP protocol automatically:
#![allow(unused)]
fn main() {
use forester_rs::runtime::action::builtin::remote::RemoteHttpAction;
// Register a remote action pointing to an external Python action server
fb.register_remote_action(
"call_llm_tool",
RemoteHttpAction::new("http://localhost:9000/call_llm", None)
);
}
The engine sends each tick as a RemoteActionRequest:
#![allow(unused)]
fn main() {
pub struct RemoteActionRequest {
pub tick: usize, // Current tick number
pub args: Vec<RtArgument>, // Action arguments from the tree
pub serv_url: String, // Engine HTTP server URL (for Blackboard access)
}
}
The remote server responds with a TickResult (Success, Failure, or Running).
See Remote Action Clients for how to implement the server side in Python or Rust.
Registering Actions
Register sync, async, and remote actions with ForesterBuilder before running the tree:
#![allow(unused)]
fn main() {
use forester_rs::runtime::builder::ForesterBuilder;
use forester_rs::runtime::action::Action;
use forester_rs::runtime::action::builtin::data::StoreData;
use forester_rs::runtime::action::builtin::remote::RemoteHttpAction;
let mut fb = ForesterBuilder::from_file_system();
fb.main_file("main.tree".to_string());
// Sync action (built-in)
fb.register_action("store", Action::sync(StoreData));
// Custom sync action
fb.register_sync_action("my_sensor_check", MySensorCheck);
// Async action
fb.register_async_action("fetch_data", FetchData);
// Remote action (Python server)
fb.register_remote_action(
"call_llm",
RemoteHttpAction::new("http://localhost:9000/call_llm", None)
);
}
Action Statefulness
Actions are intentionally stateless — they cannot hold mutable state between ticks. Persist any inter-tick state on the Blackboard instead:
#![allow(unused)]
fn main() {
impl Impl for CounterAction {
fn tick(&self, args: RtArgs, ctx: TreeContextRef) -> Tick {
let mut ctx = ctx.lock()?;
// Read current count from Blackboard
let count: i64 = ctx.bb().get("count")
.and_then(|v| v.as_int())
.unwrap_or(0);
// Write incremented count back to Blackboard
ctx.bb().put("count", RtValue::Number(RtValueNumber::Int(count + 1)))?;
Ok(TickResult::success())
}
}
}
Built-In Actions
A set of ready-made action implementations for Blackboard operations and HTTP requests is available in:
#![allow(unused)]
fn main() {
use forester_rs::runtime::action::builtin::*;
}
Trimming (Live Tree Modification)
Trimming is Forester’s mechanism for modifying a running behavior tree at runtime — replacing, removing, or substituting nodes in the live execution graph without stopping the engine.
Use Cases
Performance & JIT Optimization
Cache or fold static subtrees that have already completed, reducing redundant evaluation on subsequent ticks. Analogous to JIT compiler transformations in virtual machines.
Adaptive Logic
Dynamically swap decision branches based on runtime signals — Blackboard state, sensor readings, or LLM responses. Enables online reinforcement learning workflows where policy branches update as the tree runs.
Research & Experimentation
Replace specific nodes mid-execution to compare behavioral outcomes under the same environmental conditions without restarting the engine.
Core Components
1. TrimTask
A TrimTask encapsulates a single runtime modification. When registered with the engine, it is invoked on each tick with a snapshot of the current tree state. The task decides whether to apply, skip, or reject itself:
#![allow(unused)]
fn main() {
pub enum TrimTask {
RtTree(Box<dyn RtTreeTrimTask>),
}
}
Register a trim task before running the tree:
#![allow(unused)]
fn main() {
forester.add_trim_task(TrimTask::rt_tree(MyTrimTask));
forester.run_until(Some(100)).unwrap();
}
2. TrimRequest — Task Decision States
Each invocation of RtTreeTrimTask::process() returns a TrimRequest indicating how the engine should proceed:
#![allow(unused)]
fn main() {
pub enum TrimRequest {
Reject, // Permanently cancel this task (another task already made the change,
// or the tree is no longer a valid target)
Skip, // Defer to the next tick (conditions not yet met)
Attempt(RequestBody), // Proceed with the modification
}
}
| State | Description |
|---|---|
Reject | Permanently cancels this task. Use when the modification is no longer valid or applicable. |
Skip | Defers the task to the next tick. Use when waiting for a specific Blackboard value, tick number, or tree state. |
Attempt(body) | Submits the modification for validation and application by the engine. |
3. Validation
Before applying a TrimRequest::Attempt, the engine validates that the nodes targeted for replacement are not currently in a Running state. If they are, the attempt is automatically deferred.
Constraints & Best Practices
No ordering guarantees: There is no guarantee of the order in which multiple trim tasks execute, or the exact tick at which any task will be applied.
- Design trim tasks to be idempotent — safe to apply multiple times without side effects.
- Always validate the incoming tree snapshot inside
process()before constructing theRequestBody. - Use
TrimRequest::Rejectwhen another task has already made the intended change.
Example: Replace a Node After Tick 90
#![allow(unused)]
fn main() {
use forester_rs::runtime::trimmer::task::{RtTreeTrimTask, TrimTask};
use forester_rs::runtime::trimmer::{RequestBody, TreeSnapshot, TrimRequest};
use forester_rs::runtime::rtree::builder::{RtNodeBuilder, RtTreeBuilder};
use forester_rs::runtime::rtree::rnode::RNodeName;
use forester_rs::runtime::args::RtArgs;
use forester_rs::runtime::RtResult;
struct ReplaceFailWithSuccess;
impl RtTreeTrimTask for ReplaceFailWithSuccess {
fn process(&self, snapshot: TreeSnapshot<'_>) -> RtResult<TrimRequest> {
// Wait until tick 90 before applying this modification
if snapshot.tick < 90 {
return Ok(TrimRequest::Skip);
}
let tree = snapshot.tree;
// Find the node named "fail_empty" in the running tree
let target_id = tree
.nodes
.iter()
.find(|(_, node)| {
node.name()
.and_then(|n| n.name().ok())
.filter(|n| n.as_str() == "fail_empty")
.is_some()
})
.map(|(id, _)| id)
.unwrap();
// Build a replacement node: swap fail_empty -> success()
let mut builder = RtTreeBuilder::new_from(tree.max_id() + 1);
builder.set_as_root(action!(node_name!("success")), target_id.clone());
Ok(TrimRequest::attempt(RequestBody::new(
builder,
Default::default(),
)))
}
}
fn run(mut forester: Forester) {
forester.add_trim_task(TrimTask::rt_tree(ReplaceFailWithSuccess));
let result = forester.run_until(Some(100)).unwrap();
println!("Result: {}", result);
}
}
Daemons (Background Processes)
Daemons are long-running background tasks that execute concurrently alongside the behavior tree. They share the same Tokio runtime environment as the engine and have direct access to the Blackboard.
Common uses:
- Sensor polling: Continuously reading hardware or network sensor streams and writing values to the Blackboard.
- Message publishing: Sending telemetry or heartbeat events to external systems.
- AI context maintenance: Streaming LLM token output or monitoring API rate limits in the background.
- Watchdogs: Monitoring tree health or enforcing resource constraints.
Performance note: Daemons run in the same async runtime as the tree. Heavy daemon workloads can directly impact tick loop performance. Keep daemon logic lightweight, or offload expensive work to separate processes.
Daemon Types
| Type | Trait | Stop Mechanism |
|---|---|---|
| Sync | DaemonFn | StopFlag — an AtomicBool that flips to true when the engine requests shutdown |
| Async | AsyncDaemonFn | CancellationToken — a Tokio one-shot cancellation channel |
Implementing Daemons
Sync Daemon
Poll the StopFlag in a loop. When it becomes true, the daemon should exit promptly:
#![allow(unused)]
fn main() {
use forester_rs::runtime::env::daemon::{DaemonFn, DaemonContext, StopFlag};
use std::sync::atomic::Ordering::Relaxed;
struct SensorPollerDaemon;
impl DaemonFn for SensorPollerDaemon {
fn perform(&mut self, ctx: DaemonContext, signal: StopFlag) {
while !signal.load(Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(50));
let mut bb = ctx.bb.lock().unwrap();
let reading = read_sensor(); // your sensor read logic
bb.put("sensor_value".to_string(), RtValue::int(reading)).unwrap();
}
}
}
}
Async Daemon
Use tokio::select! to respond to cancellation alongside your periodic work:
#![allow(unused)]
fn main() {
use forester_rs::runtime::env::daemon::{AsyncDaemonFn, DaemonContext};
use tokio_util::sync::CancellationToken;
use std::pin::Pin;
use std::future::Future;
struct AsyncSensorPollerDaemon;
impl AsyncDaemonFn for AsyncSensorPollerDaemon {
fn prepare(&mut self, ctx: DaemonContext, signal: CancellationToken) -> Pin<Box<dyn Future<Output = ()> + Send>> {
Box::pin(async move {
loop {
tokio::select! {
_ = signal.cancelled() => {
// Gracefully shut down
return;
}
_ = tokio::time::sleep(std::time::Duration::from_millis(10)) => {
let mut bb = ctx.bb.lock().unwrap();
let reading = fetch_remote_sensor().await;
bb.put("sensor_value".to_string(), RtValue::int(reading)).unwrap();
}
}
}
})
}
}
}
Registering Daemons
At Startup via ForesterBuilder
Register daemons before building the engine. Named daemons can be controlled from within the tree using built-in actions:
#![allow(unused)]
fn main() {
use forester_rs::runtime::env::daemon::Daemon;
// Named daemon (controllable from the tree via stop_daemon / daemon_alive)
fb.register_named_daemon("sensor_poller".to_string(), Daemon::sync(SensorPollerDaemon));
// Anonymous daemon (runs for the lifetime of the engine, no tree control)
fb.register_daemon(Daemon::a_sync(AsyncSensorPollerDaemon));
}
At Runtime from Inside an Action
Daemons can also be started dynamically during tree execution from within a sync action:
#![allow(unused)]
fn main() {
use forester_rs::runtime::action::{Impl, RtArgs, Tick, TickResult};
use forester_rs::runtime::context::TreeContextRef;
use forester_rs::runtime::env::daemon::Daemon;
impl Impl for StartPollerAction {
fn tick(&self, args: RtArgs, ctx: TreeContextRef) -> Tick {
let env = ctx.env().lock()?;
env.start_daemon(Daemon::a_sync(AsyncSensorPollerDaemon), ctx.into());
Ok(TickResult::success())
}
}
}
Controlling Daemons from the Tree
Two built-in standard library actions are available to control named daemons from .tree files:
import "std::actions"
root main sequence {
// Start the main task
execute_mission()
// Check if background poller is still running
daemon_alive("sensor_poller")
// Stop the background poller when done
stop_daemon("sensor_poller")
}
| Action | Description |
|---|---|
daemon_alive(name) | Returns Success if the named daemon is still running, Failure otherwise. |
stop_daemon(name) | Sends the stop signal to the named daemon and returns Success. |
Analysis & Observability
Forester provides a suite of tools for inspecting, debugging, and validating behavior trees before and during execution. These tools are critical for building confidence in complex orchestration logic before deploying to production hardware or live AI agent workflows.
1. Visualization
Generate a visual diagram of any .tree file’s structure using Graphviz. Visualizing the tree makes it easy to review control flow, catch structural mistakes, and communicate logic to stakeholders.
f-tree vis --input main.tree --output tree_diagram.svg
Output is in SVG format, readable in any browser or vector graphics tool.
See Visualization for full usage.
2. Execution Tracing
Enable step-by-step logging of tick evaluations — recording which nodes were visited, what states they returned, and how Blackboard values changed across ticks.
Tracing is configured in ForesterBuilder:
#![allow(unused)]
fn main() {
use forester_rs::tracer::Tracer;
fb.tracer(Tracer::default());
}
The trace output can be printed to stdout, written to a file, or queried via the engine’s HTTP API at /tracer/print.
See Tracing for full usage.
3. Simulation
Run the behavior tree with stub action implementations instead of real hardware or external API calls. The simulator lets you define per-action responses (Success, Failure, or Running) and observe how the tree navigates them — without any external dependencies.
f-tree sim --profile sim_profile.json
This is especially valuable for:
- Robotics: Validate control flow before connecting to hardware.
- AI agents: Test fallback chains and retry logic before incurring LLM API costs.
See Simulation for full usage.
4. ROS Nav2 Export
Export Forester trees to ROS Nav2 XML format for direct use in ROS2 navigation pipelines. This allows Forester-authored trees to be dropped into existing Nav2 workflows as a validated behavior plugin.
See ROS Nav2 Export for full usage.
Visualization
Forester can generate a visual diagram of any behavior tree project, rendering the full node graph as an SVG file. This makes it easy to review control flow, spot structural issues, and communicate tree logic to teammates.
Prerequisites
Visualization uses Graphviz under the hood. Install it before running the vis command:
# macOS
brew install graphviz
# Ubuntu / Debian
sudo apt-get install graphviz
# Windows
winget install graphviz
Example Output
Usage
CLI (f-tree vis)
f-tree vis --root project/ --main main.tree --tree main --output viz.svg
| Flag | Default | Description |
|---|---|---|
--root | Current working directory | Path to the project root directory containing .tree files. |
--main | main.tree | The entry .tree file to visualize. |
--tree | First root definition found | Name of the specific root tree to visualize (required if the file has multiple roots). |
--output | <main-filename>.svg | Output SVG file path. |
From Rust
Visualization can also be triggered programmatically from Rust using the ForesterBuilder and Visualizer API:
use forester_rs::tracer::Tracer;
use forester_rs::visualizer::Visualizer;
use forester_rs::runtime::builder::ForesterBuilder;
use std::path::PathBuf;
fn main() {
let mut fb = ForesterBuilder::from_file_system();
fb.main_file("main.tree".to_string());
let svg = Visualizer::build_svg(fb).expect("Visualization failed");
std::fs::write("output.svg", svg).unwrap();
}
Execution Tracing
The Forester tracer records a tick-by-tick log of node evaluations as the behavior tree runs. It captures node IDs, tick results, node state parameters, and any custom messages emitted by actions — making it the primary tool for debugging and post-execution analysis.
Reading Trace Output
Each trace line follows the format:
[tick] node_id : Status(parameters...)
[tick]: The current tick number.- Indent: Reflects nesting depth in the tree.
node_id: Unique integer ID of the node being evaluated.Status(...): Result state (Running,Success,Failure) with relevant parameters (cursor position, child count, key-value pairs, etc.).
Example Trace
[1] 1 : Running(cursor=0,len=1)
[1] 2 : Running(cursor=0,len=3)
[1] 3 : Success(key=x,value=tick)
[1] 2 : Running(cursor=1,len=3)
[1] 4 : Success(name=tick)
[1] 2 : Running(cursor=2,len=3)
[1] 5 : Running(cursor=0,len=2)
[1] 6 : Success(k=a,i=1)
[1] 5 : Running(cursor=1,len=2)
[1] 7 : Running(cursor=0,len=2)
[1] 8 : Failure(key=x,expected=10,reason=1 != 10)
[1] 7 : Running(cursor=1,len=2)
[1] 9 : Running()
[2] next tick
[2] 2 : Running(cursor=0,len=3)
[2] 3 : Success(key=x,value=tick)
...
Enabling the Tracer
Enable tracing in ForesterBuilder before running the engine:
#![allow(unused)]
fn main() {
use forester_rs::tracer::{Tracer, TracerConfiguration};
use forester_rs::runtime::builder::ForesterBuilder;
let mut fb = ForesterBuilder::from_file_system();
fb.main_file("main.tree".to_string());
// Default tracer (stdout output)
fb.tracer(Tracer::default());
// Configured tracer (output to file, custom indent)
fb.tracer(Tracer::create(TracerConfiguration {
indent: 2,
to_file: Some("output/main.trace".to_string()),
time_format: None,
}));
}
Configuration Options
| Option | Type | Description |
|---|---|---|
indent | usize | Number of spaces per nesting level in the trace output. |
to_file | Option<String> | If set, writes the trace to the specified file path instead of stdout. |
time_format | Option<String> | If set, prepends a formatted timestamp to each trace line. |
Custom Trace Messages
Actions can emit custom messages inline within the trace output using ctx.trace(). This is useful for logging intermediate Blackboard values or action-specific diagnostic information:
import "std::actions"
impl tracked_action();
root main repeat(3) {
tracked_action()
}
#![allow(unused)]
fn main() {
use forester_rs::runtime::action::{Impl, RtArgs, Tick, TickResult};
use forester_rs::runtime::context::TreeContextRef;
struct TrackedAction;
impl Impl for TrackedAction {
fn tick(&self, args: RtArgs, ctx: TreeContextRef) -> Tick {
let mut ctx = ctx.lock()?;
// Read current counter from Blackboard
let i = ctx.bb()
.get("counter".to_string())?
.and_then(|v| v.clone().as_int())
.map(|v| v + 1)
.unwrap_or(0);
ctx.bb().put("counter".to_string(), RtValue::int(i))?;
// Emit a custom trace message
ctx.trace(format!("counter = {:?}", i));
Ok(TickResult::success())
}
}
}
This produces inline messages in the trace output:
[1] 2 : Running(len=1)
[1] counter = 0
[1] 3 : Success()
[2] next tick
[2] counter = 1
[2] 3 : Success()
[3] next tick
[3] counter = 2
[3] 3 : Success()
[3] 1 : Success(cursor=0,len=1)
Accessing the Tracer via HTTP
When the engine HTTP server is running, the tracer output can be fetched or appended to at runtime:
# Print current trace
curl http://localhost:10000/tracer/print
# Append a custom event to the trace
curl -X POST http://localhost:10000/tracer/custom \
-H "Content-Type: application/json" \
-d '{"message": "external checkpoint reached"}'
Simulation
Forester provides a simulation environment to execute a behavior tree by replacing actual action implementations with stubs. This allows you to validate tree logic and test execution branches under specific conditions without writing the application code.
Using a simulation profile, you can inject a specific Blackboard state, trace execution changes, and generate visual graphs of the tree.
Preparations
Configuration Profile
Note: All paths in the configuration file can be either absolute or relative to the root folder.
The YAML configuration file contains simulation settings and stub definitions for actions.
Example configuration:
config:
tracer:
file: gen/main.trace
dt_fmt: "%d %H:%M:%S%.3f"
graph: gen/main.svg
bb:
dump: gen/bb.json
max_ticks: 10
actions:
-
name: task
stub: failure
params:
delay: 100
config Section
| Setting | Description | Default | Example |
|---|---|---|---|
tracer.file | File path to write the execution trace. | None (disabled) | gen/main.trace |
tracer.dt_fmt | Datetime format for the trace logs. | None | "%d %H:%M:%S%.3f" |
graph | File path to output the SVG tree visualization. | None (disabled) | gen/main.svg |
bb.dump | File path to dump the final Blackboard state as JSON. | None (disabled) | gen/bb.json |
bb.load | File path of a JSON file used to initialize the Blackboard before execution. | None (disabled) | gen/init_bb.json |
max_ticks | Maximum number of ticks before the simulation forcefully terminates. | 0 (unlimited) | 10 |
http.port | Port for the HTTP server to listen for remote action callbacks. | None (disabled) | 8080 |
actions Section
The actions section is an array mapping action names to their stubbed behavior.
| Setting | Description | Default | Example |
|---|---|---|---|
name | Name of the target action. | Required | task |
stub | Stub behavior (success, failure, random, remote). | Required | success |
params.delay | Execution delay in milliseconds. | 0 | 100 |
params.url | (Remote stub) URL of the remote server. | Required for remote | http://localhost:10000/action |
params.server | (Remote stub) Callback URL the remote action uses to access the Blackboard. | http://localhost | http://localhost:8080 |
Default Profile
A simulation can run without a specific profile. In this case, Forester replaces all unimplemented actions with a success stub. No traces, graphs, or dumps are generated.
Stubs
success: Always returnsSuccess.failure: Always returnsFailure.random: ReturnsSuccessorFailurerandomly.remote: Connects to a remote server and returns the result. Details are in the Remote actions documentation.
Parameters:
- The
success,failure, andrandomstubs accept adelayparameter (in milliseconds). - The
remotestub requires aurlparameter and accepts an optionalserverparameter for Blackboard access.
Process
You can perform a simulation via the CLI or directly in Rust code.
In the Console
Use the f-tree CLI to run a simulation:
f-tree sim --root tree/tests/simulator/smoke/ --profile sim.yaml
CLI Defaults:
--root: If omitted, defaults to the current working directory (<pwd>).--main: If omitted, defaults tomain.tree.--tree: Can be omitted if there is only one root definition in the file.--profile: If omitted, the default success-stub profile is used.
In the Code
Use SimulatorBuilder from the simulator module to configure and run the simulation programmatically.
From the file system:
#![allow(unused)]
fn main() {
fn smoke() {
let mut sb = SimulatorBuilder::new();
let root = PathBuf::from("simulator/smoke");
sb.root(root.clone());
sb.profile(PathBuf::from("sim.yaml"));
let mut fb = ForesterBuilder::from_file_system();
fb.main_file("main.tree".to_string());
fb.root(root);
sb.forester_builder(fb);
let mut sim = sb.build().unwrap();
sim.run().unwrap();
}
}
From raw text:
#![allow(unused)]
fn main() {
fn smoke_from_text() {
let mut sb = SimulatorBuilder::new();
let sim = PathBuf::from("simulator/smoke/sim.yaml");
sb.profile(sim);
let mut fb = ForesterBuilder::from_text();
fb.text(r#"
import "std::actions"
root main sequence {
store("info1", "initial")
retryer(task(config = obj), success())
store("info2", "finish")
}
fallback retryer(t: tree, default: tree) {
retry(5) t(..)
fail("just should fail")
default(..)
}
impl task(config: object);
"#.to_string());
sb.forester_builder(fb);
let mut sim = sb.build().unwrap();
sim.run().unwrap();
}
}
Export to ROS Nav2
ROS in general and ROS Nav2 in particular are highly popular in robotics. They manage critical aspects of robot control, including navigation, localization, mapping, and more.
Forester provides native support for exporting a tree directly to ROS Nav2. The intermediate format is the Nav2 XML format.
The transformation process is straightforward.
Control Nodes
Forester control nodes map directly to Nav2 control nodes:
sequencebecomesPipelineSequencefallbackbecomesRoundRobinr_fallbackbecomesReactiveFallback
If a Forester control node is explicitly named, that name is applied to the resulting Nav2 XML element.
For example:
sequence FollowPathWithFallback {
// ...
}
Becomes:
<PipelineSequence name="FollowPathWithFallback">
</PipelineSequence>
Actions
Forester actions map directly to Nav2 actions.
Every action accepts an implicit name parameter, which dictates the name of the action in the Nav2 tree. This parameter is optional and can be omitted.
For actions that take a subtree as a parameter (like decorators), the parameter must be named sub.
Retry
Retry logic can be represented in two ways, depending on whether you need to name the node in Nav2:
- The
retrydecorator: The number of retries is specified compactly (e.g.,retry(3)), but you cannot assign a name to the node. - The
RecoveryNodeaction: The number of retries is passed as an argument. This is the only way to explicitly assign anameparameter to the retry node.
// Allows you to convey the name to Nav2
RecoveryNode(
number_of_retries = 1,
name = "ComputePathToPose",
sub = ComputePathWithFallback()
)
// Cannot convey a name to Nav2
retry(1) ComputePathWithFallback()
// Everything else functions exactly the same
Example
Forester Tree:
import "ros::nav2"
root MainTree RecoveryNode(number_of_retries = 6, name = "NavigateRecovery", sub = NavigateWithReplanning())
sequence NavigateWithReplanning {
RateController(
hz = 1.0,
sub = RecoveryNode(
number_of_retries = 1,
name = "ComputePathToPose",
sub = retry(1) ComputePathWithFallback()
)
)
retry(1) FollowPathWithFallback()
}
sequence ComputePathWithFallback {
ComputePathToPose(goal = goal, path = path, planner_id = "GridBased")
ComputePathToPoseRecoveryFallback()
}
sequence FollowPathWithFallback {
FollowPath(path = path, controller_id = "FollowPath")
FollowPathRecoveryFallback()
}
r_fallback ComputePathToPoseRecoveryFallback {
GoalUpdated()
ClearEntireCostmap(name = "ClearGlobalCostmap-Context", service_name = "global_costmap/clear_entirely_global_costmap")
}
r_fallback FollowPathRecoveryFallback {
GoalUpdated()
ClearEntireCostmap(name = "ClearLocalCostmap-Context", service_name = "local_costmap/clear_entirely_local_costmap")
}
Transformed Nav2 XML:
<root main_tree_to_execute="MainTree">
<BehaviorTree ID="MainTree">
<RecoveryNode name="NavigateRecovery" number_of_retries="6">
<PipelineSequence name="NavigateWithReplanning">
<RateController hz="1">
<RecoveryNode name="ComputePathToPose" number_of_retries="1">
<RecoveryNode number_of_retries="1">
<PipelineSequence name="ComputePathWithFallback">
<ComputePathToPose goal="{goal}" path="{path}" planner_id="GridBased"/>
<ReactiveFallback name="ComputePathToPoseRecoveryFallback">
<GoalUpdated/>
<ClearEntireCostmap name="ClearGlobalCostmap-Context" service_name="global_costmap/clear_entirely_global_costmap"/>
</ReactiveFallback>
</PipelineSequence>
</RecoveryNode>
</RecoveryNode>
</RateController>
<RecoveryNode number_of_retries="1">
<PipelineSequence name="FollowPathWithFallback">
<FollowPath controller_id="FollowPath" path="{path}"/>
<ReactiveFallback name="FollowPathRecoveryFallback">
<GoalUpdated/>
<ClearEntireCostmap name="ClearLocalCostmap-Context" service_name="local_costmap/clear_entirely_local_costmap"/>
</ReactiveFallback>
</PipelineSequence>
</RecoveryNode>
</PipelineSequence>
</RecoveryNode>
</BehaviorTree>
</root>
Tools
Ensure you are running the latest version of the CLI to use these features:
cargo install f-tree
Headers
To use Nav2 actions with the correct signatures, you must import the ros::nav2 module in your Forester project.
To view the contents and available actions in this file, run:
f-tree -d print-ros-nav2
Exporting via the Console
To export the tree from the command line, run:
f-tree nav2
Exporting via the IntelliJ Plugin
Run the task Export to ROS Nav2 directly within your IDE.
Exporting via Code
#![allow(unused)]
fn main() {
#[test]
fn smoke() {
let mut root_path = test_folder("ros/nav/smoke");
let project = Project::build("main.tree".to_string(), root_path.clone()).unwrap();
let tree = RuntimeTree::build(project).unwrap().tree;
// Define the output file
root_path.push("test.xml");
// Export the tree
tree.to_ros_nav(root_path).unwrap();
}
}
Examples
Examples
To help you get started quickly, we maintain a dedicated repository containing fully functional examples that demonstrate Forester in action across different domains.
You can find the repository here: forester-bt/examples
Implemented Examples
- Basic Simulation: Demonstrates how to set up a tree, define a profile, and run it using the built-in simulator with stubbed actions.
- ROS Nav2 Integration: A robotics orchestration example showing how to structure a tree for navigation and export it to ROS Nav2 XML.
- Webots Robot Control: Demonstrates how to use the Forester-Webots integration layer to control a simulated robot using reactive ticks.
- Remote Actions (Python & Rust): Shows how to connect external logic—such as LLM agent tools or external APIs—using the HTTP remote action clients, keeping the orchestration in Rust while executing logic in Python.
- Higher-Order Trees: Examples of passing subtrees as arguments to create reusable patterns like
retryerand fallback wrappers.
Tools
The Forester ecosystem includes several auxiliary tools and libraries to help you write, analyze, and execute behavior trees.
IntelliJ Plugin
The Forester IntelliJ Plugin provides native IDE support for the .tree language. Features include:
- Syntax highlighting
- Code folding
- Code navigation
- Code formatting
- Code inspections and error highlighting
- Structure view
- Built-in tasks to visualize and simulate the tree directly from the IDE
Remote Action Libraries
Remote action clients allow you to decouple the orchestration engine from the execution of individual actions. This is particularly useful for AI agents where the orchestrator is in Rust, but the tool execution is in Python. Features include:
- The ability to execute specific actions on a remote machine or in a separate process via HTTP, returning the
TickResultto the Forester runtime. - Bidirectional access, allowing the remote action to read from and write to the shared Blackboard.
Available Clients:
CLI (f-tree)
The f-tree command-line interface is the primary utility for testing and manipulating trees without writing boilerplate application code. It supports:
- Simulating trees with stubbed actions using YAML profiles.
- Generating SVG visualizations of the tree structure.
- Exporting trees to the ROS Nav2 XML format.
- Printing built-in standard library headers (e.g.,
ros::nav2,std::actions).
IntelliJ Plugin
Introduction
The Forester IntelliJ Plugin provides native IDE support for writing and testing behavior trees in the .tree language. It does not orchestrate tasks itself; rather, it bridges the IDE environment with the Forester runtime and CLI tools.
This allows you to write type-checked behavior trees, navigate complex hierarchical logic, and run simulations with visual SVG outputs directly from your editor, accelerating the development cycle for robotics and AI agent orchestration.
Installation
- Open your IntelliJ IDE (IDEA, CLion, PyCharm, etc.).
- Go to Settings (or Preferences on macOS) from the main menu.
- Select Plugins from the left-hand menu.
- Click on the Marketplace tab.
- Search for “Forester”.
- Click Install and restart the IDE to activate the plugin.
Features
Syntax Highlighting
Provides specialized syntax highlighting for the .tree language, making keywords, higher-order trees, decorators, and actions easily distinguishable.
Code Folding
Allows you to collapse sections of your behavior trees (such as large sequences or fallbacks), making complex task structures readable and easier to navigate.
Structure View
Displays the hierarchical organization of your behavior trees in the IDE’s Structure tool window. You can quickly see parent-child relationships and jump to specific node definitions.
Task Visualization
Integrates with Forester’s graph generation to visually map out your behavior trees. This outputs interactive or static graphical representations (SVGs) of your execution flows.
Task Simulation
Run and test your behavior trees directly within the IDE using simulation profiles. You can execute stubbed runs to verify the logic and fallback routing without needing the actual application runtime to be active.
Usage
Creating a Run Configuration
- Open the Run/Debug Configurations dialog in your IDE.
- Click the + (Add New Configuration) button and select the Forester configuration type.
- Specify your root folder, main tree file, and simulation profile (if applicable).
Running a Simulation via the Editor
- Open the
.treefile containing your root node. - Click the green Run icon located in the editor gutter next to the
rootkeyword. - The IDE will execute the tree simulation and output the trace logs and visual graphs to your configured output directories.
Links
Library to create Remote Actions using Rust
The Forester provides a http library that alleviates writing the remote http actions. For now, the libraries are available for Rust and Python.
Library to create Remote Actions using Rust
The Forester provides an http library that alleviates writing the remote http actions.
Usage
forester-http = { version = "0.1.0" }
The contract is defined in the following way:
#![allow(unused)]
fn main() {
pub trait ForesterRemoteAction {
fn tick(&self, request: RemoteActionRequest) -> TickResult;
}
}
where RemoteActionRequest is defined as:
#![allow(unused)]
fn main() {
pub struct RemoteActionRequest {
/// current tick
pub tick: usize,
/// the list of arguments from the tree invocation
pub args: Vec<RtArgument>,
/// the address of the server to access to blackboard and other services
pub serv_url: String,
}
}
On the other hand, the library provides a helper API ForesterHttpApi and Client ForesterHttpClient (async reqwest) to access the server.
Example
The code is available in the forester-examples repository.
The gist is the following:
#[tokio::main]
async fn main() {
let routing = Router::new()
.route("/", get(|| async { "OK" }))
.route("/action", post(handler))
.into_make_service_with_connect_info::<SocketAddr>();
axum::Server::bind(&SocketAddr::from(([127, 0, 0, 1], 10000)))
.serve(routing)
.await
.unwrap();
}
/// RemoteActionRequest defines the request from the tree
async fn handler(Json(req): Json<RemoteActionRequest>) -> impl IntoResponse {
let url = req.clone().serv_url;
/// the client to access the server
let client = ForesterHttpClient::new(url);
let trace = client .print_trace(); /// print the trace of the tree
let result = client.put("test".to_string(), json!({"f1":1, "f2":2, "f3":3})).await;
println!("result of putting {:?}", result);
client.lock("test".to_string()).await.unwrap();
(StatusCode::OK, Json::from(RemoteAction.tick(req)))
}
struct RemoteAction;
impl ForesterRemoteAction for RemoteAction {
fn tick(&self, request: RemoteActionRequest) -> TickResult {
println!("tick: {:?}", request);
TickResult::Success
}
}
Library to create Remote Actions using Python
The Forester provides an http library that alleviates writing the remote http actions.
Usage
The latest version can be obtained from the test.pypi.org
pip install -i https://test.pypi.org/simple/ forester-http==0.0.5
The contract is defined in the following way:
from typing import List
class RtArgument:
"""The argument that is sent from the Forester instance
* The name of the argument
* The value of the argument is a json
"""
def __init__(self, name: str, value: str) -> None:
self.name = name
self.value = value
class RemoteActionRequest:
"""The request that is sent from the Forester instance
* It has the current tick and the arguments in the action from tree
"""
def __init__(self, tick: int, args: List[RtArgument], serv_url: str) -> None:
self.tick = tick
self.args = args
self.serv_url = serv_url
On the other hand, the library provides a helper API ForesterHttpApi and Client ForesterHttpClient to access the server.
Example
The code is available in the forester-examples repository.
The gist is the following:
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from forester_http.client import *
class MyServer(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == "/action":
content_length = int(self.headers["Content-Length"])
# get body as json and deserialize it to RemoteActionRequest
body = json.loads(self.rfile.read(content_length))
req = RemoteActionRequest.from_bytes(body.encode("utf-8"))
client = ForesterHttpClient(req.serv_url)
client.put("test", "test")
self.send_response(200)
self.send_header("Content-Type", "application/json;charset=UTF-8")
self.end_headers()
self.wfile.write(json.dumps("Success").encode("utf-8"))
else:
self.send_error(404)
if __name__ == "__main__":
webServer = HTTPServer((hostName, serverPort), MyServer)
print("Server started http://%s:%s" % (hostName, serverPort))
try:
webServer.serve_forever()
except KeyboardInterrupt:
pass
webServer.server_close()
print("Server stopped.")
Console f-tree
The console utility f-tree can be installed using cargo and can be used to simulate and visualize the tree.
The Intellij plugin basically wraps this utility and provides the same functionality.
cargo install f-tree
and then be used with
~ f-tree --help
Usage: f-tree [OPTIONS] <COMMAND>
Commands:
print-std-actions Print the list of std actions from 'import std::actions'
print-ros-nav2 Print the list of ros actions from 'import ros::nav2'
sim Runs simulation. Expects a simulation profile
vis Runs visualization. Output is in svg format.
nav2 Convert to the xml compatable format of nav ros2.
help Print this message or the help of the given subcommand(s)
Options:
-d, --debug Print debug logs
-h, --help Print help
-V, --version Print version