PlanSys2 design
PlanSys2 is made of four ROS 2 lifecycle nodes:
Domain Expert (
domain_expert): the PDDL domain: types, constants, predicates, functions, derived predicates, actions, processes and events.Problem Expert (
problem_expert): the knowledge of the world: instances, predicates, functions with their values, goal, metric and constraints. It also simulates the PDDL+ dynamics and applies the timed facts.Planner (
planner): computes plans with plan solver plugins (Kairos by default).Executor (
executor): executes a plan as a behavior tree, asking the action performers (the nodes that implement each action) to do each action.
Every name on this page is relative: a namespace given to PlanSys2 applies to all of them. Applications use them through the client libraries (C++ and Python), which hide the services and actions.
Interfaces at a glance
Everything that enters and leaves PlanSys2. The details of each one are in the section of its node.
Action |
Type |
Server |
|---|---|---|
|
|
executor |
Topic |
Type |
Published by |
Subscribed by |
|---|---|---|---|
|
|
domain expert |
problem expert, executor, domain expert clients |
|
|
problem expert |
problem expert clients |
|
|
problem expert |
problem expert clients |
|
|
problem expert |
executor, tools |
|
|
executor |
tools |
|
|
executor |
(applications) |
|
|
executor |
tools |
|
|
executor |
tools |
|
|
executor |
(viewers) |
|
|
executor, performers |
executor, performers, tools |
|
|
performers |
tools |
Services: the domain expert offers domain_expert/*, the problem expert
problem_expert/*, the planner planner/* and the executor executor/*, listed below.
Each node also has the standard lifecycle services (<node>/get_state,
<node>/change_state…), which the lifecycle manager uses.
Domain Expert
It reads the PDDL domain and makes it available to the rest. The domain can be changed at runtime.
Parameters
Name |
Type |
Default |
Meaning |
|---|---|---|---|
|
string |
|
PDDL domain files, separated by |
|
bool |
|
Validate the domain with the planner ( |
A domain with something PlanSys2 cannot represent fails when it is loaded, with a message that says what. See PDDL support.
Services
All of type plansys2_msgs/srv/...:
Service |
Type |
Returns |
|---|---|---|
|
|
The name of the domain. |
|
|
The types. |
|
|
The constants of a type. |
|
|
The predicates. |
|
|
A predicate. |
|
|
The functions. |
|
|
A function. |
|
|
The derived predicates. |
|
|
A derived predicate. |
|
|
The instantaneous actions. |
|
|
An instantaneous action. |
|
|
The durative actions. |
|
|
A durative action. |
|
|
The PDDL+ processes. |
|
|
A process, as an action. |
|
|
The PDDL+ events. |
|
|
An event, as an action. |
|
|
The domain as PDDL text. |
|
|
Replaces the domain (see below). |
change_domain is rejected if the node is not active, the domain is empty, or another change
is in progress. The new domain is validated and offered first to the problem expert
(problem_expert/update_domain), which adapts the problem to it: only if it accepts, the
domain changes and is published. With no problem expert running, the domain changes anyway; if
the problem expert does not answer within 5 s, the change is rejected. A plan in execution is
aborted when the domain changes.
Topics
domain_expert/domain(std_msgs/msg/String, published, depth 100, transient local): the domain, when the node activates and after each change.
It calls problem_expert/update_domain, and planner/validate_domain when
validate_using_planner_node is true.
Problem Expert
It keeps the knowledge of the world: instances, grounded predicates, functions with their values (numbers or objects), goal, metric and constraints. Every change is published.
While active, every 50 ms it applies the timed initial literals and fluents that are due, and simulates the PDDL+ dynamics: the active processes and the continuous effects of running actions change the functions for the time passed, and then the events whose conditions hold happen.
Parameters
Name |
Type |
Default |
Meaning |
|---|---|---|---|
|
string |
|
The domain, as for the domain expert. Required. |
|
string |
|
A PDDL problem loaded when the node configures. Optional; the node fails to configure if it cannot read or load it. |
|
bool |
|
Simulate processes, events and continuous effects. |
|
string[] |
|
Names of functions that perception updates: the simulation does not change them. |
|
string |
|
A file where the node keeps its problem (written at most every 100 ms after a change).
Started again, the node loads it when it configures, instead of |
Services
All of type plansys2_msgs/srv/...:
Service |
Type |
Does |
|---|---|---|
|
|
Adds a PDDL problem (instances, init, goal, metric, constraints). |
|
|
The problem as PDDL text, with the time it was read. |
|
|
Removes instances, predicates, functions, goal, metric and constraints, and the timed facts and continuous effects. |
|
|
Adds an instance. |
|
|
Removes an instance, and what refers to it. |
|
|
An instance. |
|
|
All instances. |
|
|
Adds a predicate. |
|
|
Removes a predicate. |
|
|
Whether a predicate holds. |
|
|
A predicate. |
|
|
All predicates. |
|
|
Adds a function with its value. |
|
|
Changes the value of a function. |
|
|
Removes a function. |
|
|
Whether a function exists. |
|
|
A function and its value. |
|
|
All functions and their values. |
|
|
Sets the goal. |
|
|
The goal. |
|
|
Removes the goal. |
|
|
Whether a goal holds now. |
|
|
Sets the metric. |
|
|
The metric. |
|
|
Removes the metric. |
|
|
Registers or removes the continuous effects of a running action (used by the executor). |
|
|
Adapts the problem to a new domain, or rejects it (used by the domain expert). |
Topics
Topic |
Type, QoS |
Meaning |
|---|---|---|
|
|
The problem as PDDL text, after each change. |
|
|
Something changed. |
|
|
The whole knowledge, after each change. |
|
|
A new domain: the problem is adapted to it. |
Planner
It computes plans for a domain and a problem with its plan solver plugins, in parallel, and
returns the plans found in time, shortest first. Each plan carries the time the planner got its
problem (Plan.stamp), which the executor uses to anchor timed plans.
A task goes only to the plugins that support the PDDL requirements it uses; if none does, the answer says what is missing. See Plugins and Configuring Multiple Planners.
Parameters
Name |
Type |
Default |
Meaning |
|---|---|---|---|
|
string[] |
|
Ids of the plan solvers. Empty: Kairos, with id |
|
string |
The plugin of the solver |
|
|
double |
15.0 |
Seconds for all the solvers; the ones still running then are cancelled. |
Parameters of the Kairos plugin (prefixed by its id, e.g. KAIROS.anytime):
Name |
Type |
Default |
Meaning |
|---|---|---|---|
|
double |
0.0 |
If positive, a time limit for the search, always below the planner’s timeout. |
|
bool |
|
Keep improving the first plan found. |
|
double |
0.1 |
Anytime: stop after these seconds without a better plan. |
|
string |
|
Search preset of Kairos. |
|
string |
|
|
|
int |
0 |
Memory limit for Kairos. |
|
int |
1 |
Search threads. |
|
string |
|
|
|
string |
|
If set, Kairos writes the task it gets there. |
|
bool |
|
Run the |
|
string |
|
The |
Parameters of the POPF plugin (prefixed by its id, e.g. POPF.arguments):
Name |
Type |
Default |
Meaning |
|---|---|---|---|
|
string |
|
Extra arguments for |
|
string |
the system’s temporary directory |
Each run writes its domain, problem and plan in a folder |
|
bool |
|
Keep that folder after the run. |
Services
Offered once the node is configured:
Service |
Type |
Returns |
|---|---|---|
|
|
The best (shortest) plan, or why there is none. |
|
|
Every plan found, shortest first. |
|
|
Whether a solver accepts the domain. |
It has no topics.
Executor
It executes a plan: it turns it into a behavior tree and runs it, asking an action performer for each action, checking the requirements of each action before and while it runs, applying its effects to the problem expert, and checking the goal and the constraints.
Parameters
Name |
Type |
Default |
Meaning |
|---|---|---|---|
|
string |
|
How the plan becomes a behavior tree: |
|
bool |
|
Run every plan timed (see below). Plans that need it run timed anyway. |
|
double |
30.0 |
Seconds an action waits for its requirements before failing (0: no limit). |
|
bool |
|
When a plan fails, is cancelled or is replaced, undo the at start effects of the actions that started and did not end. |
|
double |
30.0 |
Seconds a plan waits for a component that is not active (gone, or started again) before it fails and says which one (0: no limit). |
|
double |
5.0 |
PDDL+: seconds after the end of the plan for the goal to hold. |
|
bool |
|
Write the execution trace in the log (it is always published). |
|
int |
3 |
Decimals of the plan time in the action ids, e.g. |
|
bool |
|
STN, not timed: an action starts as soon as what it needs happened; |
|
double |
5.0 |
Hz of the feedback, |
|
string[] |
|
Actions that fail if they last too long (with a BT template that includes
|
|
double |
0.0 |
How much longer than planned those actions may last, in %. |
|
string |
(installed) |
Behavior tree template of an action (Simple and Sequential builders). |
|
string |
(installed) |
Templates of the start and the end of an action (STN builder). |
|
bool |
|
Legend in the DOT graph. |
|
bool |
|
Print the plan graph. |
|
bool |
|
Publish the behavior tree for Groot2. |
|
int |
1800 |
Port for Groot2. |
Action
execute_plan (plansys2_msgs/action/ExecutePlan): the goal is a plan; the feedback and
the result have the state of each action (ActionExecutionInfo); the result says
SUCCESS, FAILURE or PREEMPT.
Goals are rejected unless the executor is active.
An empty plan succeeds at once.
A goal sent while a plan runs replaces it: the previous goal is aborted with
PREEMPTand the plan is repaired (see below). A cancelled goal ends canceled, withFAILURE.A change of the domain aborts the plan.
Services
Service |
Type |
Returns |
|---|---|---|
|
|
The plan being executed (fails if none). |
|
|
What remains of it (fails if none). |
|
|
The parts of the goal, in the order the plan achieves them. |
|
|
The problem to replan from while a plan runs: the current one, with the at start effects of the running actions undone, so the new plan can keep them. |
Topics
Topic |
Type, QoS |
Meaning |
|---|---|---|
|
|
The plan, once when it starts. |
|
|
What remains, at |
|
|
The state of each action, at |
|
|
What the executor does and why (see Reading an execution). |
|
|
The plan graph in DOT: once when the tree is built, then at |
|
|
The protocol with the action performers (see below). |
|
|
A change of the domain aborts the plan. |
|
|
The constraints are checked again when the knowledge changes. |
It uses the services of the domain expert, the problem expert and the planner.
Behavior tree builders
Once a plan is obtained, the executor turns it into a behavior tree. Each action becomes a subtree that waits for what the action needs, asks a performer for it, applies its effects and checks its requirements while it runs:
The builder decides what each action waits for. It builds a graph of the dependencies of the plan, pairing what an action needs with the action that provides it before in the plan:
STNBTBuilder (the default) works with the start and the end of each action separately, so an
action can start when what it needs happened, also if it is the start of another action.
SimpleBTBuilder waits for whole actions; SequentialBTBuilder runs them in the order of
the plan.
Timed execution
A plan runs timed when its domain has processes or events, an action has continuous effects,
the plan has an end (a PDDL+ plan), or the problem has constraints with times (also any plan,
with timed_execution): each action starts at its time in the plan, not before, and a
durative action lasts its planned duration. Timed initial literals and fluents alone do not
make a plan timed: an action that needs one waits until the problem expert applies it.
The plan counts from when its problem was read (
Plan.stamp), so timed facts and plan share the clock.An action that starts late shifts the rest of the plan with it: the planned intervals hold.
Repairing a plan
A new goal for execute_plan while a plan runs does not start from scratch. The executor:
Builds the behavior tree of the new plan.
Keeps the running actions that the new plan starts with (same action at time 0): they go on without interruption, their continuous effects included. In timed execution, their performer is told their new duration: the time they have run plus what the new plan gives them (
ActionExecution.UPDATE).Stops the others, undoes their at start effects and stops their continuous effects.
Keeps the history of the constraint monitor, if the new problem has the same constraints.
Takes as done the actions that already ended and that the new plan still has.
To replan while a plan runs, ask the problem to plan from to the executor
(executor/get_replanning_problem, ExecutorClient::get_replanning_problem()): it is the
current problem with the at start effects of the running actions undone, so a plan from it can
keep them. The trace says which actions went on and which were dropped (REPLAN).
Several planners give alternative plans for the same task (PlannerClient::getPlanArray());
an application decides when to replace the running plan. These mechanisms are described in
Peña-Narváez et al., “LLM-Assisted Plan Execution for Robots in Dynamic Environments” (Robotics, 2026), with a reference implementation
in paper_plansys2_LLMs.
Constraints
The hard trajectory constraints of the problem (always, sometime, within,
at-most-once, sometime-before…) are checked while the plan runs: when the knowledge
changes, at their deadlines, and when the plan ends. A violation fails the plan and says which constraint
(VIOLATION in the trace). Preferences are only for the planner.
Action performers
An action performer is the node that does an action of the plan. It inherits from
plansys2::ActionExecutorClient (C++) or plansys2_support_py.ActionExecutorClient
(Python) and implements do_work(), which is called periodically while it runs the action.
Several performers can do the same action (one per robot, for instance): the executor gives
each action to one of them.
Protocol
The executor and the performers talk on actions_hub (plansys2_msgs/msg/ActionExecution).
Each request has its own id, carried by every message about it.
REQUEST (executor): who can do this action, with these arguments? It carries the planned duration.
RESPONSE (performers): the free performers that can do it answer, and wait up to 5 s for an answer.
CONFIRM (executor) to the first one, which activates and starts calling
do_work(); REJECT to the others, which are free again. A free performer is asked again at once; with no answer, the request is repeated every second.FEEDBACK (performer): progress and status, from
send_feedback().FINISH (performer): success or failure, from
finish().CANCEL (executor): stop the action, for instance when the plan is cancelled.
UPDATE (executor): in timed execution, a repaired plan gives the running action another duration (
get_planned_duration()returns it).
Each side watches the other, once a second: the executor fails the action if its performer is missing three checks in a row, and a performer stops if the node that asked for the action is. A request that no performer takes within 30 s fails the action (“no performer took it in 30 s”). A performer that finishes with failure without progress (it could not start) has its action offered again, up to three times.
Parameters
Name |
Type |
Default |
Meaning |
|---|---|---|---|
|
string |
|
The PDDL action it does (case insensitive). |
|
string[] |
|
Only requests whose arguments match, position by position (an empty string matches anything; fewer values than arguments compare the first ones). |
|
double |
5.0 |
Hz of |
Note
In a multirobot application, an action usually has the robot as an argument. Each robot runs
the same performer with specialized_arguments: ["robot1"], ["robot2"]…, and only
gets its own actions.
Topics
actions_hub(plansys2_msgs/msg/ActionExecution, published and subscribed, depth 100, reliable): the protocol.performers_status(plansys2_msgs/msg/ActionPerformerStatus, published, depth 100, reliable): its state every second (not ready, ready, running, failure), for the tools.
API
In C++ (plansys2_executor/ActionExecutorClient.hpp):
// Constructor: the node name
ActionExecutorClient(const std::string & node_name);
// For the class that inherits (protected)
virtual void do_work(); // To implement: called at `rate` while running
const std::vector<std::string> & get_arguments() const; // The arguments of the action
double get_planned_duration() const; // The duration the plan gives it (0 if instantaneous)
const std::string get_action_name() const;
void send_feedback(float completion, const std::string & status = "");
void finish(bool success, float completion, const std::string & status = "");
A performer whose work is a behavior tree is a plansys2::BTAction
(plansys2_bt_actions): see Implement actions as Behavior Trees.
In Python (plansys2_support_py.ActionExecutorClient), the constructor is
ActionExecutorClient(node_name, rate) (rate is the period of do_work() in seconds,
despite its name; the rate parameter, in Hz, overrides it) and the methods are do_work(), get_planned_duration(),
send_feedback(completion, status) and finish(success, completion, status).
Client libraries
Applications use PlanSys2 through clients that hide the services and actions. A client creates no
node: it works on the node of the application, given to its constructor, and its names are
relative to the namespace of that node. Inside PlanSys2, each component has a single node too, and its
clients work on it: PlanSys2 has four nodes, domain_expert, problem_expert, planner and
executor.
// In a node of the application (rclcpp::Node or rclcpp_lifecycle::LifecycleNode)
domain_expert_ = std::make_shared<plansys2::DomainExpertClient>(*this);
// Or with the shared pointer of a node
auto problem_expert = std::make_shared<plansys2::ProblemExpertClient>(node);
A client brings its own answers: its service and action clients and its subscriptions go in a
callback group of its own, which the executor of the node does not take, and the client serves
that group, and only it, while it waits for an answer; feedback, results and the problem
published meanwhile come at the next call. So the application spins its node as it likes, or
not at all: a call works from main before any spin, and from a callback of any executor,
single threaded too. The client does not own the node: once the node is gone, its calls get
no answer.
auto node = std::make_shared<MyController>(); // its clients take *this
node->run(); // calls the clients: nothing to spin
// Or with callbacks of its own: step() in a timer, the calls in it
rclcpp::spin(node);
As any blocking call, a call from a callback keeps its thread while it waits; the Migrating to 4.0.0
guide shows how a controller with subscriptions of its own spins its node. Inside PlanSys2, only
plansys2_node (and, in the distributed bringup, the main of each node) spins, with a
MultiThreadedExecutor per node.
Every call of a client waits until its component is active, however long it takes while ROS
runs, and goes on as soon as it is: an application may start before PlanSys2, or while it starts,
and needs no wait of its own. While it waits, the client says so every few seconds. Once seen
active, the component is not asked again, until one of its services goes or stops answering:
then the next call waits for it again, so the application also goes on after PlanSys2 restarts.
wait_active() is the same wait alone, for code that must not start before (the greenhouse
example, whose world evolves from the moment its problem is loaded). Call the clients from a
thread of their own if the application must not block.
A request without answer is sent up to three times in all (planning requests, twice), each time with a new client: some middlewares lose every answer to a client whose connection was set up badly while many processes started at once, and waiting longer on the same client does not help. Only requests that can be repeated are sent again: queries, and changes that leave the same state when repeated.
C++
plansys2::DomainExpertClient (plansys2_domain_expert/DomainExpertClient.hpp): getName,
getTypes, getConstants, getPredicates, getPredicate, getFunctions,
getFunction, getDerivedPredicates, getDerivedPredicate,
getDerivedResolutionGraph, getActions, getAction, getDurativeActions,
getDurativeAction, getProcesses, getProcess, getEvents, getEvent,
getDomain, changeDomain.
plansys2::ProblemExpertClient (plansys2_problem_expert/ProblemExpertClient.hpp):
instances:
getInstances,tryGetInstances,addInstance,removeInstance,getInstance;predicates:
getPredicates,tryGetPredicates,addPredicate,removePredicate,existPredicate,tryExistPredicate,getPredicate;functions:
getFunctions,addFunction,removeFunction,existFunction,tryExistFunction,updateFunction,getFunction;goal and metric:
getGoal,setGoal,isGoalSatisfied,clearGoal,getMetric,setMetric,clearMetric;the whole problem:
clearKnowledge,getProblem,getProblemWithTimestamp,addProblem,getUpdateTime.
The try* methods tell “nothing” from “no answer”.
plansys2::PlannerClient (plansys2_planner/PlannerClient.hpp): getPlan(domain, problem)
returns the best plan, getPlanArray(domain, problem) all of them. It waits for the answer as
long as the planner plans (the planner’s plan_solver_timeout) and a margin of 5 s; its own
plan_solver_timeout parameter, if positive, sets that time instead.
plansys2::ExecutorClient (plansys2_executor/ExecutorClient.hpp):
start_plan_execution(plan): sends the plan (replaces the running one, if any).execute_and_check_plan(): call it periodically; false when the plan ended.getFeedBack(),getResult(): the state of the actions; the result once ended.cancel_plan_execution().get_plan(),get_remaining_plan(),getOrderedSubGoals(),get_replanning_problem().
Python
plansys2_support_py has the same clients: DomainExpertClient, ProblemExpertClient,
PlannerClient and ExecutorClient, with snake case methods named after the services
(get_domain_types(), add_problem_instance(), get_plan(),
start_plan_execution()…), plus validate_domain() in PlannerClient and
get_replanning_problem(), get_feedback() and get_result() in ExecutorClient.
The queries of processes and events are only in C++.
They also take the node of the application (DomainExpertClient(node)) and bring their own
answers. rclpy executors take whole nodes, not callback groups, so each Python client has a node
of its own, <node>_<component>_client in the namespace of the node of the application, which
it serves while it waits.
Running PlanSys2
plansys2_bringup starts the four nodes and brings them to active:
Monolithic (
plansys2_bringup_launch_monolithic.py): one process,plansys2_node, with the four nodes, each with an executor of its own.Distributed (
plansys2_bringup_launch_distributed.py): one process per node, and thelifecycle_managernode that configures and activates them, and then keeps watching them.
Both take these launch arguments:
Argument |
Meaning |
|---|---|
|
The PDDL domain (required); several files separated by |
|
A PDDL problem to load at start (optional). |
|
Namespace of every node, topic and service. |
|
Parameters of the nodes; by default |
|
The BT builder of the executor ( |
|
Behavior tree templates of the executor. |
|
Start a process again, 1 s later, if it ends ( |
|
Where the problem expert keeps its problem; by default a file of this launch in the temporary folder, so a new launch does not take the problem of another. Empty: not kept. |
The nodes are configured in the order planner, domain expert, problem expert, executor, and
activated in the order domain expert, problem expert, planner, executor; each transition
waits up to startup_timeout seconds (default 5). plansys2_node can run with real-time
priority, and stops if the system does not allow it. In the monolithic bringup, both are launch
arguments (use_real_time:=true, startup_timeout:=10), passed to plansys2_node as
--use-real-time true and --startup-timeout 10: it has no node of its own to read them
from. In the distributed one, startup_timeout is a parameter of lifecycle_manager.
When something dies, the bringups bring it back. A process of the four nodes that ends starts
again (respawn), and the problem expert comes back with the problem it had (state_file). In
the distributed bringup, lifecycle_manager keeps watching the four nodes once they are
active: it brings up a node that starts again, and if one is gone for longer than a grace
time, it deactivates the executor, so that the plan fails, until the node is back. Its
parameters:
Name |
Type |
Default |
Meaning |
|---|---|---|---|
|
int |
5 |
Seconds each transition of the startup waits for its answer, and again for the new state. |
|
bool |
|
Keep watching the nodes once they are active. If the startup fails, it keeps trying to bring them up instead of exiting. |
|
double |
0.5 |
Seconds between two looks at the nodes. |
|
double |
3.0 |
Seconds without an answer before a node is taken as gone. |
|
double |
10.0 |
Seconds since a node last answered before the executor is deactivated. |
In the monolithic bringup the four nodes are one process: started again, it brings itself up.
Messages
The main messages of plansys2_msgs:
Plan:items(PlanItem:time,action,duration),stamp(when its problem was read),has_end/end(end of a PDDL+ plan),has_cost/cost.Knowledge:instances,predicates,functions(with their values, e.g.(= (battery r1) 10.5)),goal,metric,constraints.ActionExecution: the performer protocol (REQUEST,RESPONSE,CONFIRM,REJECT,FEEDBACK,FINISH,CANCEL,UPDATE), withnode_id,request_id,action,arguments,success,completion,statusandplanned_duration.ActionExecutionInfo: the state of an action of the plan (NOT_EXECUTED,EXECUTING,FAILED,SUCCEEDED,CANCELLED), its full name, times, duration, completion and message.ExecutionEvent: an event of the execution trace (see Reading an execution).ActionPerformerStatus: the state of a performer.