feat(llm-plan-executor): add end-to-end planner launch and operator guide
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Defaults for `ros2 launch iiwa_llm_plan_executor planner.launch.py`.
|
||||
# Qwen3 is called outside this project; this file only configures Webots and
|
||||
# the two new nodes.
|
||||
start_delay_sec: 3.0
|
||||
scene_max_age_sec: 2.0
|
||||
max_steps: 512
|
||||
scene_freshness_sec: 2.0
|
||||
close_inspection_objects:
|
||||
- cap
|
||||
top_camera_topic: /d455_top/image_raw
|
||||
hand_camera_topic: /camera/color
|
||||
frame_timeout_sec: 2.0
|
||||
trace_dir: ~/.ros/iiwa_llm_plan_executor/runs
|
||||
planning_group: iiwa_arm
|
||||
pose_link: tcp
|
||||
@@ -0,0 +1,102 @@
|
||||
# Executing external Qwen3 graphs in Webots
|
||||
|
||||
This package accepts a **ready-made** JSON graph. It does not call Qwen3, does
|
||||
not evaluate the model and does not compare runs with a reference sequence.
|
||||
The experiment number selects only the initial Webots scene.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
source /opt/ros/jazzy/setup.bash
|
||||
source install/setup.bash
|
||||
ros2 launch iiwa_llm_plan_executor planner.launch.py experiment_id:=20
|
||||
```
|
||||
|
||||
The launch starts the standard `iiwa_bringup` simulation, the writable Grant
|
||||
supervisor (`mode=planner`), the scene context and the graph executor. It
|
||||
never starts `reference_runner` and never starts a second `iiwa_web` server:
|
||||
the web server already comes from `iiwa_bringup` when `web.enabled` is true in
|
||||
`cobot-setting.yaml`. For a ROS-only run set `web.enabled: false` in your
|
||||
settings before launch.
|
||||
|
||||
Useful arguments: `rviz:=true`, `config:=/path/planner.yaml`.
|
||||
`simulate` must remain `true`; the physical FRI robot is not connected.
|
||||
|
||||
## Read the scene and submit a graph
|
||||
|
||||
Wait until the launch log shows the scene is ready (the snapshot is only
|
||||
served after `/iiwa_experement/scene_ready`).
|
||||
|
||||
ROS:
|
||||
|
||||
```bash
|
||||
ros2 service call /iiwa_scene_context/get_scene std_srvs/srv/Trigger {}
|
||||
ros2 topic echo /iiwa_scene_context/scene_json --qos-durability transient_local
|
||||
```
|
||||
|
||||
Give `scene_json` and your instruction to Qwen3 outside this project. Submit
|
||||
the answer, the `scene_revision` from the same snapshot and a client-chosen
|
||||
`run_id`:
|
||||
|
||||
```bash
|
||||
ros2 service call /iiwa_llm_plan_executor/submit webots_ros2_msgs/srv/SetString \
|
||||
"{value: '{\"run_id\":\"demo-1\",\"scene_revision\":1,\"response\":{\"status\":\"ready\",\"entry\":\"detect\",\"plan\":[...]}}'}"
|
||||
|
||||
ros2 topic echo /iiwa_llm_plan_executor/plan --qos-durability transient_local
|
||||
ros2 topic echo /iiwa_llm_plan_executor/status
|
||||
```
|
||||
|
||||
The accepted graph is published **unchanged** in
|
||||
`/iiwa_llm_plan_executor/plan` before the first movement. A rejected graph
|
||||
never appears there; read the reason from
|
||||
`/iiwa_llm_plan_executor/status` or:
|
||||
|
||||
```bash
|
||||
ros2 service call /iiwa_llm_plan_executor/get_status iiwa_msgs/srv/GetRunStatus \
|
||||
"{run_id: demo-1}"
|
||||
ros2 service call /iiwa_llm_plan_executor/cancel webots_ros2_msgs/srv/SetString \
|
||||
"{value: '{\"run_id\":\"demo-1\"}'}"
|
||||
```
|
||||
|
||||
HTTP (existing `iiwa_web` server; default port 8007):
|
||||
|
||||
```bash
|
||||
curl http://localhost:8007/planner/scene
|
||||
curl -X POST http://localhost:8007/planner/runs \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"run_id":"demo-1","scene_revision":1,"response":"<Qwen JSON string>"}'
|
||||
curl http://localhost:8007/planner/runs/demo-1
|
||||
curl -X POST http://localhost:8007/planner/runs/demo-1/cancel
|
||||
```
|
||||
|
||||
The same four operations are MCP tools built from the same router
|
||||
(`get_scene_planner_scene_get`, `submit_run_planner_runs_post`,
|
||||
`get_run_planner_runs`, `cancel_run_planner_runs`).
|
||||
|
||||
## Nodes and terminals
|
||||
|
||||
Only one graph is active per robot. Repeating the same `run_id` is
|
||||
idempotent; a different `run_id` while active is rejected (`busy`).
|
||||
|
||||
Status events contain the run id, node id, skill, result event, the chosen
|
||||
next node, the scene revision, the mounted tool, the held object and, at the
|
||||
end, the terminal. Terminals:
|
||||
|
||||
* `completed` — the submitted graph and its checked postconditions finished.
|
||||
* `replan_required` — the graph stopped and a fresh snapshot was emitted; the
|
||||
external client decides whether to call Qwen3 again.
|
||||
* `operator_required` — a human decision is needed; the robot is stopped.
|
||||
* `failed` / `canceled` — safety stop (stale state, limits, collision, cancel,
|
||||
step limit); the grasp state is preserved in the status.
|
||||
|
||||
The JSONL log with every event is written to
|
||||
`~/.ros/iiwa_llm_plan_executor/runs/<run_id>.jsonl`.
|
||||
|
||||
## Safety scope
|
||||
|
||||
The executor checks J7 (±90° around `work`) and the downward TCP tilt (≤10°)
|
||||
on **every** planned point, seeds IK from current joints, enforces these as
|
||||
MoveIt path constraints, checks scene/joint freshness and verifies the final
|
||||
pose. It never falls back to an unconstrained `MoveToPose` goal. This is a
|
||||
Webots verification, not a physical-robot validation and not a Qwen3 quality
|
||||
evaluation.
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Launch one Webots scene with the scene context and the graph executor.
|
||||
|
||||
The experiment only selects the initial scene. The reference runner is never
|
||||
started in this mode; the graph arrives from outside through
|
||||
``/iiwa_llm_plan_executor/submit``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from ament_index_python.packages import get_package_share_directory
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription, OpaqueFunction
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
|
||||
from launch_ros.actions import Node
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
|
||||
from iiwa_experement.runtime_config import (
|
||||
validate_experiment_id,
|
||||
validate_mode,
|
||||
)
|
||||
|
||||
|
||||
EXPERIMENT_LAUNCH = ('launch', 'experiment.launch.py')
|
||||
PLANNER_MODE = 'planner'
|
||||
DEFAULT_CONFIG = {
|
||||
'start_delay_sec': 3.0,
|
||||
'scene_max_age_sec': 2.0,
|
||||
'max_steps': 512,
|
||||
'scene_freshness_sec': 2.0,
|
||||
'close_inspection_objects': ['cap'],
|
||||
'top_camera_topic': '/d455_top/image_raw',
|
||||
'hand_camera_topic': '/camera/color',
|
||||
'frame_timeout_sec': 2.0,
|
||||
'trace_dir': '~/.ros/iiwa_llm_plan_executor/runs',
|
||||
'planning_group': 'iiwa_arm',
|
||||
'pose_link': 'tcp',
|
||||
}
|
||||
|
||||
|
||||
def load_config(path: Path | str | None) -> dict:
|
||||
data = dict(DEFAULT_CONFIG)
|
||||
if path is not None:
|
||||
source = Path(path)
|
||||
if source.is_file():
|
||||
loaded = yaml.safe_load(source.read_text(encoding='utf-8')) or {}
|
||||
if isinstance(loaded, dict):
|
||||
data.update(loaded)
|
||||
return data
|
||||
|
||||
|
||||
def validate_arguments(
|
||||
experiment_id_raw: str,
|
||||
mode_raw: str,
|
||||
simulate_raw: str,
|
||||
) -> tuple[int, str]:
|
||||
"""Validate everything before Webots is started."""
|
||||
experiment_id = validate_experiment_id(experiment_id_raw)
|
||||
mode = validate_mode(mode_raw)
|
||||
if mode != PLANNER_MODE:
|
||||
raise ValueError(
|
||||
f'mode must be {PLANNER_MODE!r} for the graph executor, got {mode!r}'
|
||||
)
|
||||
simulate = str(simulate_raw).strip().lower()
|
||||
if simulate not in ('true', '1', 'yes'):
|
||||
raise ValueError(
|
||||
'this executor stage only supports Webots (simulate:=true); '
|
||||
'the physical FRI robot is not connected'
|
||||
)
|
||||
return experiment_id, mode
|
||||
|
||||
|
||||
def runtime_actions(
|
||||
*,
|
||||
experiment_id: int,
|
||||
scene_config: str,
|
||||
base_setting: str,
|
||||
rviz: str,
|
||||
config: dict,
|
||||
) -> list:
|
||||
experiment = IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(PathJoinSubstitution([
|
||||
FindPackageShare('iiwa_experement'), *EXPERIMENT_LAUNCH,
|
||||
])),
|
||||
launch_arguments={
|
||||
'experiment_id': str(experiment_id),
|
||||
'mode': PLANNER_MODE,
|
||||
'scene_config': str(scene_config),
|
||||
'base_setting': str(base_setting),
|
||||
'rviz': str(rviz),
|
||||
}.items(),
|
||||
)
|
||||
scene_context = Node(
|
||||
package='iiwa_scene_context',
|
||||
executable='scene_context',
|
||||
name='iiwa_scene_context',
|
||||
output='screen',
|
||||
parameters=[{
|
||||
'experiment_id': int(experiment_id),
|
||||
'scene_config': str(scene_config),
|
||||
'freshness_sec': float(config.get('scene_freshness_sec', 2.0)),
|
||||
'use_sim_time': True,
|
||||
}],
|
||||
)
|
||||
executor = Node(
|
||||
package='iiwa_llm_plan_executor',
|
||||
executable='llm_plan_executor',
|
||||
name='iiwa_llm_plan_executor',
|
||||
output='screen',
|
||||
parameters=[{
|
||||
'experiment_id': int(experiment_id),
|
||||
'scene_config': str(scene_config),
|
||||
'start_delay_sec': float(config.get('start_delay_sec', 3.0)),
|
||||
'scene_max_age_sec': float(config.get('scene_max_age_sec', 2.0)),
|
||||
'max_steps': int(config.get('max_steps', 512)),
|
||||
'close_inspection_objects': list(
|
||||
config.get('close_inspection_objects', ['cap'])
|
||||
),
|
||||
'top_camera_topic': str(
|
||||
config.get('top_camera_topic', '/d455_top/image_raw')
|
||||
),
|
||||
'hand_camera_topic': str(
|
||||
config.get('hand_camera_topic', '/camera/color')
|
||||
),
|
||||
'frame_timeout_sec': float(config.get('frame_timeout_sec', 2.0)),
|
||||
'trace_dir': str(
|
||||
config.get('trace_dir', '~/.ros/iiwa_llm_plan_executor/runs')
|
||||
),
|
||||
'planning_group': str(config.get('planning_group', 'iiwa_arm')),
|
||||
'pose_link': str(config.get('pose_link', 'tcp')),
|
||||
'use_sim_time': True,
|
||||
}],
|
||||
)
|
||||
return [experiment, scene_context, executor]
|
||||
|
||||
|
||||
def _setup(context, *args, **kwargs):
|
||||
del args, kwargs
|
||||
raw_id = LaunchConfiguration('experiment_id').perform(context)
|
||||
raw_mode = LaunchConfiguration('mode').perform(context)
|
||||
raw_simulate = LaunchConfiguration('simulate').perform(context)
|
||||
try:
|
||||
experiment_id, _ = validate_arguments(raw_id, raw_mode, raw_simulate)
|
||||
except ValueError as error:
|
||||
raise RuntimeError(f'Invalid planner launch arguments: {error}') from error
|
||||
config_path = LaunchConfiguration('config').perform(context)
|
||||
config = load_config(config_path)
|
||||
print(
|
||||
f'[iiwa_llm_plan_executor] experiment {experiment_id} planner mode: '
|
||||
f'no reference sequence; waiting for an external graph'
|
||||
)
|
||||
return runtime_actions(
|
||||
experiment_id=experiment_id,
|
||||
scene_config=str(LaunchConfiguration('scene_config').perform(context)),
|
||||
base_setting=str(LaunchConfiguration('base_setting').perform(context)),
|
||||
rviz=str(LaunchConfiguration('rviz').perform(context)).lower(),
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
return LaunchDescription([
|
||||
DeclareLaunchArgument(
|
||||
'experiment_id',
|
||||
default_value='',
|
||||
description='Experiment 1..20; selects only the initial scene.',
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
'mode',
|
||||
default_value='planner',
|
||||
description='Must remain planner for externally supplied graphs.',
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
'simulate',
|
||||
default_value='true',
|
||||
description='Must remain true; the physical robot is not connected.',
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
'rviz',
|
||||
default_value='false',
|
||||
description='true = start RViz together with Webots.',
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
'scene_config',
|
||||
default_value=PathJoinSubstitution([
|
||||
FindPackageShare('iiwa_experement'), 'config', 'scenes.yaml',
|
||||
]),
|
||||
description='Scenario manifest used to build the initial scene.',
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
'base_setting',
|
||||
default_value=PathJoinSubstitution([
|
||||
FindPackageShare('iiwa_config'), 'config', 'setting.yaml',
|
||||
]),
|
||||
description='Base settings for the temporary setting.yaml.',
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
'config',
|
||||
default_value=PathJoinSubstitution([
|
||||
FindPackageShare('iiwa_llm_plan_executor'),
|
||||
'config',
|
||||
'planner.yaml',
|
||||
]),
|
||||
description='Executor defaults: delays, limits, cameras, logging.',
|
||||
),
|
||||
OpaqueFunction(function=_setup),
|
||||
])
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Launch validation and wiring for the planner scene."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription, OpaqueFunction
|
||||
from launch_ros.actions import Node
|
||||
|
||||
from iiwa_experement.runtime_config import EXPERIMENT_IDS, validate_experiment_id
|
||||
|
||||
|
||||
PACKAGE = Path(__file__).resolve().parents[1]
|
||||
LAUNCH = PACKAGE / 'launch' / 'planner.launch.py'
|
||||
CONFIG = PACKAGE / 'config' / 'planner.yaml'
|
||||
|
||||
|
||||
def load_launch_module():
|
||||
spec = importlib.util.spec_from_file_location('planner_launch', LAUNCH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def flat_parameters(node: Node):
|
||||
def flatten(value):
|
||||
if hasattr(value, 'text'):
|
||||
return [value.text]
|
||||
if isinstance(value, (list, tuple)):
|
||||
result = []
|
||||
for item in value:
|
||||
result.extend(flatten(item))
|
||||
return result
|
||||
return [value]
|
||||
|
||||
values = []
|
||||
for parameter_set in node._Node__parameters:
|
||||
for value in parameter_set.values():
|
||||
values.extend(flatten(value))
|
||||
return values
|
||||
|
||||
|
||||
def test_valid_experiment_ids_and_planner_mode():
|
||||
module = load_launch_module()
|
||||
for experiment_id in EXPERIMENT_IDS:
|
||||
parsed, mode = module.validate_arguments(
|
||||
str(experiment_id), 'planner', 'true',
|
||||
)
|
||||
assert parsed == experiment_id
|
||||
assert mode == 'planner'
|
||||
assert validate_experiment_id(' 20 ') == 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw', ['', '0', '21', 'abc', '1.0'])
|
||||
def test_invalid_experiment_ids_fail_before_webots(raw):
|
||||
module = load_launch_module()
|
||||
with pytest.raises(ValueError):
|
||||
module.validate_arguments(raw, 'planner', 'true')
|
||||
|
||||
|
||||
def test_unknown_mode_or_physical_robot_is_rejected_before_webots():
|
||||
module = load_launch_module()
|
||||
with pytest.raises(ValueError, match='mode'):
|
||||
module.validate_arguments('20', 'execute', 'true')
|
||||
with pytest.raises(ValueError, match='simulate'):
|
||||
module.validate_arguments('20', 'planner', 'false')
|
||||
|
||||
|
||||
def test_runtime_actions_start_scene_context_and_executor_only():
|
||||
module = load_launch_module()
|
||||
config = module.load_config(CONFIG)
|
||||
actions = module.runtime_actions(
|
||||
experiment_id=20,
|
||||
scene_config='/tmp/scenes.yaml',
|
||||
base_setting='/tmp/setting.yaml',
|
||||
rviz='false',
|
||||
config=config,
|
||||
)
|
||||
includes = [action for action in actions if isinstance(action, IncludeLaunchDescription)]
|
||||
nodes = [action for action in actions if isinstance(action, Node)]
|
||||
assert len(includes) == 1
|
||||
assert len(nodes) == 2
|
||||
arguments = dict(includes[0].launch_arguments)
|
||||
assert arguments['experiment_id'] == '20'
|
||||
assert arguments['mode'] == 'planner'
|
||||
assert arguments['scene_config'] == '/tmp/scenes.yaml'
|
||||
assert arguments['base_setting'] == '/tmp/setting.yaml'
|
||||
|
||||
packages = {node.node_package for node in nodes}
|
||||
executables = {node.node_executable for node in nodes}
|
||||
assert packages == {'iiwa_scene_context', 'iiwa_llm_plan_executor'}
|
||||
assert executables == {'scene_context', 'llm_plan_executor'}
|
||||
assert 'reference_runner' not in executables
|
||||
assert 'iiwa_web' not in packages
|
||||
|
||||
scene_node = next(node for node in nodes if node.node_executable == 'scene_context')
|
||||
executor_node = next(
|
||||
node for node in nodes if node.node_executable == 'llm_plan_executor'
|
||||
)
|
||||
scene_values = flat_parameters(scene_node)
|
||||
executor_values = flat_parameters(executor_node)
|
||||
assert 20 in scene_values
|
||||
assert 20 in executor_values
|
||||
assert 3.0 in executor_values
|
||||
assert 512 in executor_values
|
||||
assert True in executor_values
|
||||
assert any('iiwa_arm' in str(value) for value in executor_values)
|
||||
|
||||
|
||||
def test_config_contains_the_documented_defaults():
|
||||
module = load_launch_module()
|
||||
config = module.load_config(CONFIG)
|
||||
assert config['start_delay_sec'] == 3.0
|
||||
assert config['scene_max_age_sec'] == 2.0
|
||||
assert config['max_steps'] == 512
|
||||
assert config['close_inspection_objects'] == ['cap']
|
||||
assert config['trace_dir'].endswith('iiwa_llm_plan_executor/runs')
|
||||
|
||||
|
||||
def test_launch_description_declares_all_arguments_and_validates_in_opaque():
|
||||
module = load_launch_module()
|
||||
description = module.generate_launch_description()
|
||||
arguments = {
|
||||
entity.name: entity.default_value
|
||||
for entity in description.entities
|
||||
if isinstance(entity, DeclareLaunchArgument)
|
||||
}
|
||||
assert set(arguments) == {
|
||||
'experiment_id', 'mode', 'simulate', 'rviz', 'scene_config',
|
||||
'base_setting', 'config',
|
||||
}
|
||||
assert [item.text for item in arguments['experiment_id']] == ['']
|
||||
assert [item.text for item in arguments['mode']] == ['planner']
|
||||
assert [item.text for item in arguments['simulate']] == ['true']
|
||||
assert len([
|
||||
entity for entity in description.entities if isinstance(entity, OpaqueFunction)
|
||||
]) == 1
|
||||
Reference in New Issue
Block a user