Agent Behavior Trees

In artificial intelligence for robotics and video games, controlling agent behavior is a central challenge. Traditionally, developers relied on Finite State Machines (FSMs). While FSMs are simple to implement, they suffer from a major design flaw: as the number of states and transitions grows, the state-transition graph quickly becomes a tangled web (often called “spaghetti code”), making it incredibly difficult to modify or extend.

Behavior Trees (BTs) emerged as a powerful alternative. Instead of states, BTs organize an agent’s decision-making process into a hierarchical tree of nodes. Ticking the root node of the tree evaluates its children recursively, selecting actions based on environmental conditions.

BTs are:

  • Highly Modular: Sub-trees can be designed and tested independently.
  • Composable: New behaviors can be inserted by adding branches without modifying existing ones.
  • Readable: The hierarchical structure mirrors human-like decision pathways (e.g., “if battery is low, charge; otherwise, if dust is detected, clean; otherwise, patrol”).

Prolog is exceptionally well-suited for implementing Behavior Trees. Its native pattern matching, recursive evaluation, and dynamic databases make it possible to build a complete behavior tree interpreter in under 100 lines of code.

Architecture diagram for the Agent Behavior Trees example
Figure 30. Architecture diagram for the Agent Behavior Trees example

Behavior Tree Node Types

Our interpreter supports four core node types:

  1. Sequence (sequence([Children])): Ticks children sequentially. If any child fails, the sequence fails immediately. If all succeed, the sequence succeeds. (Analogous to an logical AND).
  2. Selector (selector([Children])): Ticks children sequentially. If any child succeeds, the selector succeeds immediately. If all fail, the selector fails. (Analogous to a logical OR, used for fallback actions).
  3. Condition (condition(Name)): Evaluates a query. Returns success if the query is true, and failure otherwise.
  4. Action (action(Name)): Executes an active task, returning success, failure, or running (if the action requires multiple ticks to complete).
* * *

Implementing the BT Engine in Prolog

The core engine uses recursive pattern matching to traverse the tree nodes. We define multifile hooks user_condition/1 and user_action/2 to allow external agent modules to define their specific logic.

Here is the implementation in source-code/agent_behavior_trees/prolog/behavior_trees.pl:

 1 :- module(behavior_trees, [
 2     tick/2,
 3     define_tree/2
 4 ]).
 5 
 6 /** <module> Behavior Trees Engine
 7  *
 8  * A simple, lightweight, and elegant
 9  * Behavior Tree implementation in Prolog.
10  * Supports:
11  *   - sequence([Children])
12  *   - selector([Children]) (fallback)
13  *   - condition(Name)
14  *   - action(Name)
15  */
16 
17 :- dynamic tree_definition/2.
18 
19 % Define a tree name and its root node
20 define_tree(Name, RootNode) :-
21     retractall(tree_definition(Name, _)),
22     assertz(tree_definition(Name, RootNode)).
23 
24 % Tick the entire tree by name
25 tick(TreeName, Status) :-
26     tree_definition(TreeName, RootNode),
27     !,
28     tick_node(RootNode, Status).
29 
30 % --- Node Tick Implementation ---
31 
32 % 1. Sequence Node: Ticks children in order. 
33 %    - If a child fails, sequence fails.
34 %    - If a child returns 'running', sequence returns 'running'.
35 %    - If all children succeed, sequence succeeds.
36 tick_node(sequence(Children), Status) :-
37     tick_sequence(Children, Status).
38 
39 % 2. Selector Node (Fallback): Ticks children in order.
40 %    - If a child succeeds, selector succeeds.
41 %    - If a child returns 'running', selector returns 'running'.
42 %    - If all children fail, selector fails.
43 tick_node(selector(Children), Status) :-
44     tick_selector(Children, Status).
45 
46 % 3. Condition Node: Evaluates a user-defined condition predicate.
47 %    - Returns 'success' if the condition is true.
48 %    - Returns 'failure' if the condition is false.
49 tick_node(condition(CondName), Status) :-
50     (   call_condition(CondName)
51     ->  Status = success
52     ;   Status = failure
53     ).
54 
55 % 4. Action Node: Executes a user-defined action predicate.
56 %    - The action predicate must bind its
57 %      status argument
58 %      (success, failure, or running).
59 tick_node(action(ActionName), Status) :-
60     call_action(ActionName, Status).
61 
62 % --- Helper Predicates for Composites ---
63 
64 % Sequence traversal
65 tick_sequence([], success).
66 tick_sequence([Child|Rest], Status) :-
67     tick_node(Child, ChildStatus),
68     (   ChildStatus = success
69     ->  tick_sequence(Rest, Status)
70     ;   Status = ChildStatus  % either 'failure' or 'running'
71     ).
72 
73 % Selector traversal
74 tick_selector([], failure).
75 tick_selector([Child|Rest], Status) :-
76     tick_node(Child, ChildStatus),
77     (   ChildStatus = failure
78     ->  tick_selector(Rest, Status)
79     ;   Status = ChildStatus  % either 'success' or 'running'
80     ).
81 
82 % --- Interface to Hook Action & Condition Predicates ---
83 % User-defined actions and conditions are
84 % expected to be defined in user modules
85 % or hooked into the following dynamic/multifile predicates:
86 :- multifile user_condition/1.
87 :- multifile user_action/2.
88 
89 call_condition(CondName) :-
90     user_condition(CondName).
91 
92 call_action(ActionName, Status) :-
* * *

Defining a Robot Agent

Now, we use our engine to program a cleaning robot. The robot has a battery level and a room dust state. We define its behavior tree as follows:

  • High Priority Branch: If the battery is low, recharge.
  • Medium Priority Branch: If dust is present, clean the room.
  • Fallback Branch: Patrol the rooms, occasionally generating new dust.

Here is the implementation in source-code/agent_behavior_trees/prolog/robot_agent.pl:

  1 :- module(robot_agent, [
  2     run_simulation/1
  3 ]).
  4 
  5 :- use_module(behavior_trees).
  6 
  7 % Import multifile hooks
  8 :- multifile behavior_trees:user_condition/1.
  9 :- multifile behavior_trees:user_action/2.
 10 
 11 % Dynamic state representing the robot's environment and internals
 12 :- dynamic battery/1.
 13 :- dynamic dusty/1.
 14 
 15 % --- Define Conditions ---
 16 
 17 % Battery is low if it's strictly below 30%
 18 behavior_trees:user_condition(battery_low) :-
 19     battery(Level),
 20     Level < 30,
 21     format('[Cond] Battery is low: ~w%~n', [Level]).
 22 
 23 % Dust is present if dusty(true) is asserted
 24 behavior_trees:user_condition(dust_present) :-
 25     dusty(true),
 26     format('[Cond] Dust detected in room!~n').
 27 
 28 % --- Define Actions ---
 29 
 30 % Recharge Action
 31 behavior_trees:user_action(recharge, success) :-
 32     battery(Level),
 33     NewLevel is min(100, Level + 50),
 34     retractall(battery(_)),
 35     assertz(battery(NewLevel)),
 36     format('[Action] Charging... Battery increased from ~w% to ~w%~n',
 37         [Level, NewLevel]).
 38 
 39 % Clean Action
 40 behavior_trees:user_action(clean, success) :-
 41     battery(Level),
 42     NewLevel is max(0, Level - 10),
 43     retractall(battery(_)),
 44     assertz(battery(NewLevel)),
 45     retractall(dusty(_)),
 46     assertz(dusty(false)),
 47     format('[Action] Cleaning dust... Battery at ~w%~n', [NewLevel]).
 48 
 49 % Patrol Action
 50 behavior_trees:user_action(patrol, success) :-
 51     battery(Level),
 52     NewLevel is max(0, Level - 15),
 53     retractall(battery(_)),
 54     assertz(battery(NewLevel)),
 55     % Randomly generate new dust on patrol to keep the simulation
 56     % interesting
 57     maybe_generate_dust,
 58     format('[Action] Patrolling rooms... Battery at ~w%~n', [NewLevel]).
 59 
 60 % Helper to randomly generate dust
 61 maybe_generate_dust :-
 62     random(0, 10, R),
 63     (   R > 6
 64     ->  retractall(dusty(_)),
 65         assertz(dusty(true)),
 66         format('[Env] Dust has settled in the room.~n')
 67     ;   true
 68     ).
 69 
 70 % --- Initialize the Behavior Tree ---
 71 
 72 :- define_tree(cleaning_robot,
 73     selector([
 74         sequence([
 75             condition(battery_low),
 76             action(recharge)
 77         ]),
 78         sequence([
 79             condition(dust_present),
 80             action(clean)
 81         ]),
 82         action(patrol)
 83     ])
 84 ).
 85 
 86 % --- Run Simulation ---
 87 
 88 % Run the behavior tree for N ticks
 89 run_simulation(Ticks) :-
 90     % Initialize state: full battery, no dust
 91     retractall(battery(_)),
 92     retractall(dusty(_)),
 93     assertz(battery(100)),
 94     assertz(dusty(false)),
 95     format('--- Starting Robot Simulation with BT ---~n'),
 96     run_ticks(1, Ticks).
 97 
 98 run_ticks(Current, Max) :-
 99     Current > Max,
100     !,
101     format('--- Simulation Completed ---~n').
102 run_ticks(Current, Max) :-
103     format('~n[Tick ~w]~n', [Current]),
104     battery(Level),
105     dusty(D),
106     format('[State] Battery: ~w%, Dusty: ~w~n', [Level, D]),
107     tick(cleaning_robot, Status),
108     format('[BT] Root execution status: ~w~n', [Status]),
109     Next is Current + 1,
110     run_ticks(Next, Max).
* * *

Simulating the Agent

To run the simulation, execute the script from your terminal using SWI-Prolog:

1 $ swipl -g "run_simulation(10), halt." -t "halt(1)" prolog/robot_agent.pl

A sample execution trace shows the agent checking conditions and carrying out appropriate actions depending on battery levels and dynamic dust generation:

 1 --- Starting Robot Simulation with BT ---
 2 
 3 [Tick 1]
 4 [State] Battery: 100%, Dusty: false
 5 [Action] Patrolling rooms... Battery at 85%
 6 [BT] Root execution status: success
 7 
 8 [Tick 2]
 9 [State] Battery: 85%, Dusty: false
10 [Action] Patrolling rooms... Battery at 70%
11 [Env] Dust has settled in the room.
12 [BT] Root execution status: success
13 
14 [Tick 3]
15 [State] Battery: 70%, Dusty: true
16 [Cond] Dust detected in room!
17 [Action] Cleaning dust... Battery at 60%
18 [BT] Root execution status: success
19 
20 [Tick 4]
21 [State] Battery: 60%, Dusty: false
22 [Action] Patrolling rooms... Battery at 45%
23 [BT] Root execution status: success
24 
25 [Tick 5]
26 [State] Battery: 45%, Dusty: false
27 [Action] Patrolling rooms... Battery at 30%
28 [BT] Root execution status: success
29 
30 [Tick 6]
31 [State] Battery: 30%, Dusty: false
32 [Action] Patrolling rooms... Battery at 15%
33 [BT] Root execution status: success
34 
35 [Tick 7]
36 [State] Battery: 15%, Dusty: false
37 [Cond] Battery is low: 15%
38 [Action] Charging... Battery increased from 15% to 65%
39 [BT] Root execution status: success

Notice how at Tick 3, dust is detected, causing the selector to bypass the patrol action and execute the clean action instead. At Tick 7, the low battery triggers the recharge branch, ignoring dust and patrol entirely.

* * *

Key Design Decisions

Dynamic database for state. The robot’s variables (like battery/1 and dusty/1) are kept as dynamic facts (:- dynamic ...). In Prolog, asserting and retracting facts mimics a global memory store, allowing conditions and actions to read and modify state without having to pass a state dictionary through the tree’s recursive helper functions.

Logical structures as DSL. Behavior trees are traditionally declared using JSON, XML, or specialized editor graphs. In Prolog, we can represent trees directly as structured terms, such as selector([sequence([condition(A), action(B)]), action(C)]). This provides an elegant domain-specific language (DSL) with zero parsing overhead, which can be modified directly within the source code.