lang2sim 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lang2sim/__init__.py +47 -0
- lang2sim/agent/__init__.py +5 -0
- lang2sim/agent/nemotron_client.py +109 -0
- lang2sim/agent/react_loop.py +848 -0
- lang2sim/agent/tools.py +998 -0
- lang2sim/cli/app.py +183 -0
- lang2sim/cli/screens/__init__.py +8 -0
- lang2sim/cli/screens/command_palette_modal.py +146 -0
- lang2sim/cli/screens/landing_screen.py +96 -0
- lang2sim/cli/screens/sessions_modal.py +236 -0
- lang2sim/cli/screens/workspace_screen.py +709 -0
- lang2sim/cli/widgets/__init__.py +16 -0
- lang2sim/cli/widgets/action_pill.py +67 -0
- lang2sim/cli/widgets/command_card.py +265 -0
- lang2sim/cli/widgets/plan_card.py +106 -0
- lang2sim/cli/widgets/prompt_card.py +96 -0
- lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
- lang2sim/cli/widgets/thought_box.py +64 -0
- lang2sim/compiler/__init__.py +15 -0
- lang2sim/compiler/sdf_compiler.py +65 -0
- lang2sim/compiler/sir_generator.py +46 -0
- lang2sim/compiler/sir_validator.py +87 -0
- lang2sim/core/__init__.py +50 -0
- lang2sim/core/config.py +116 -0
- lang2sim/core/models.py +279 -0
- lang2sim/core/session_manager.py +189 -0
- lang2sim/perturbation/__init__.py +50 -0
- lang2sim/perturbation/_compat.py +19 -0
- lang2sim/perturbation/gazebo_bridge.py +520 -0
- lang2sim/perturbation/mcp_server.py +129 -0
- lang2sim/perturbation/routers/__init__.py +19 -0
- lang2sim/perturbation/routers/world.py +110 -0
- lang2sim/perturbation/sdf_snippets.py +112 -0
- lang2sim/runtime/__init__.py +7 -0
- lang2sim/runtime/control_api/__init__.py +7 -0
- lang2sim/runtime/control_api/client.py +187 -0
- lang2sim/runtime/control_api/server.py +504 -0
- lang2sim/runtime/display.py +141 -0
- lang2sim/runtime/docker_runner.py +286 -0
- lang2sim/runtime/doctor.py +220 -0
- lang2sim/runtime/hardware_probe.py +144 -0
- lang2sim/runtime/health_monitor.py +66 -0
- lang2sim/runtime/launch_builder.py +137 -0
- lang2sim/runtime/nav2_dispatcher.py +215 -0
- lang2sim/runtime/platform.py +137 -0
- lang2sim/runtime/ros_bridge.py +183 -0
- lang2sim/runtime/run_folder.py +86 -0
- lang2sim/runtime/self_healing.py +387 -0
- lang2sim/runtime/session.py +136 -0
- lang2sim/runtime/world_check.py +197 -0
- lang2sim/telemetry/__init__.py +85 -0
- lang2sim/telemetry/nqi_scorer.py +645 -0
- lang2sim/telemetry/ros_subscribers.py +489 -0
- lang2sim/telemetry/trajectory.py +258 -0
- lang2sim/telemetry/vision.py +477 -0
- lang2sim/world_gen/__init__.py +16 -0
- lang2sim/world_gen/catalog_loader.py +83 -0
- lang2sim/world_gen/map_slicer.py +131 -0
- lang2sim/world_gen/shapely_solver.py +256 -0
- lang2sim-2.0.0.dist-info/METADATA +53 -0
- lang2sim-2.0.0.dist-info/RECORD +65 -0
- lang2sim-2.0.0.dist-info/WHEEL +5 -0
- lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
- lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
- lang2sim-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Autonomous ReAct Supervisor Engine
|
|
3
|
+
File: lang2sim/agent/react_loop.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Dict, Any, Optional, AsyncGenerator, Callable, Tuple
|
|
13
|
+
|
|
14
|
+
from .nemotron_client import NemotronClient
|
|
15
|
+
from .tools import TOOL_REGISTRY, MUTATING_TOOLS, execute_tool
|
|
16
|
+
from ..core.session_manager import (
|
|
17
|
+
session_manager,
|
|
18
|
+
ChatSession,
|
|
19
|
+
ChatMessage,
|
|
20
|
+
ToolCallRecord,
|
|
21
|
+
ArtifactRecord,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
SUPERVISOR_SYSTEM_PROMPT = """You are an expert robotics engineer and simulation copilot for Gazebo and ROS 2.
|
|
25
|
+
You pair-program with the user to build, simulate, test, and debug mobile robot scenarios.
|
|
26
|
+
|
|
27
|
+
COMMUNICATION STYLE:
|
|
28
|
+
- Talk like a sharp, friendly, human engineer pair-programming at the same desk.
|
|
29
|
+
- Keep responses concise, punchy, and practical. No robotic fluff, no canned corporate intros.
|
|
30
|
+
- On simple greetings like "hi" or "hello", reply naturally and casually (e.g. "Hey! What world or robot scenario are we setting up today?"). NEVER recite your resume, list your features, or dump an unsolicited bulleted catalog of your capabilities.
|
|
31
|
+
- When executing tasks, let the actions and results speak. Briefly explain what you're doing, diagnose issues honestly, and share actionable takeaways.
|
|
32
|
+
|
|
33
|
+
REASONING GUIDELINES:
|
|
34
|
+
Before answering or taking action, wrap your internal chain-of-thought in <thought>...</thought> tags:
|
|
35
|
+
<thought>
|
|
36
|
+
1. Understand what the user wants.
|
|
37
|
+
2. Outline the exact next action or question.
|
|
38
|
+
</thought>
|
|
39
|
+
|
|
40
|
+
STAGE-WISE PLANNING:
|
|
41
|
+
For multi-step engineering tasks (e.g. building an entire world, compiling maps, launching, and navigating), output a clean execution plan at the start:
|
|
42
|
+
<plan>
|
|
43
|
+
- Step 1: Spatial layout synthesis
|
|
44
|
+
- Step 2: Compile SDF world file
|
|
45
|
+
- Step 3: Generate Zero-SLAM 2D occupancy grid
|
|
46
|
+
- Step 4: Launch Gazebo & ROS 2 stack
|
|
47
|
+
- Step 5: Drive navigation mission & verify telemetry
|
|
48
|
+
</plan>
|
|
49
|
+
|
|
50
|
+
TOOLS & ACTIONS:
|
|
51
|
+
You have real terminal execution and robotics analysis tools:
|
|
52
|
+
- search_catalog(query="tree", category="all"): Searches open-source 3D blocks (tree, brick_wall, warehouse_rack, pallet, box, barrel, cone, desk, chair, door) and curated SDF worlds with descriptions.
|
|
53
|
+
- solve_spatial_layout(dimensions={"width": 14, "height": 10}, objects=[...]): Computes collision-free OBB placements and corridor clearance grounded in physical catalog.
|
|
54
|
+
- compile_world_sdf(layout_spec={...}): Compiles layout into verified SDF world with 3D orbit camera and injected plugins.
|
|
55
|
+
- generate_zero_slam_map(resolution=0.05): Slices 3D geometry into 2D YAML/PGM occupancy grid.
|
|
56
|
+
- launch_simulation_stack(world_path="world.l2s.sdf"): Launches Gazebo GUI and ROS 2 Nav2 stack.
|
|
57
|
+
- inspect_topic_health(topic_name="/scan"): Checks publisher count, rate, and topic activity.
|
|
58
|
+
- query_robot_telemetry(): Reads live kinematics, position, velocity, and clearance.
|
|
59
|
+
- compute_navigation_benchmark(adversarial_stress=False): Computes Navigation Quality Index (NQI) benchmark table and saves benchmark_report.json.
|
|
60
|
+
- spawn_dynamic_entity(model_name="barrel", x=2.0, y=1.0): Spawns runtime obstacle.
|
|
61
|
+
- set_ambient_lighting(luminance=0.15): Dynamically changes ambient lighting.
|
|
62
|
+
- reset_simulation(mode="all"): Instantaneous simulation state reset.
|
|
63
|
+
- auto_repair_tf(parent_frame="map", child_frame="odom"): Broadcasts static transform connecting frames.
|
|
64
|
+
- run_command(command="..."): Executes shell, ROS 2, Docker, or Gazebo commands.
|
|
65
|
+
- view_file(path="...", start_line=1, end_line=50): Inspects local files.
|
|
66
|
+
- write_file(path="...", content="..."): Writes code or configuration files.
|
|
67
|
+
- replace_file_content(path="...", target="...", replacement="..."): Edits file content.
|
|
68
|
+
|
|
69
|
+
CONTAINER & ROBOT SIMULATION RUNTIME:
|
|
70
|
+
The simulation runs inside Docker container `lang2sim-sim` (ROS 2 Jazzy, Gazebo Harmonic, TurtleBot3 Waffle, Nav2), orchestrated by the Part 3 runtime and exposed via the HTTP Control API at `http://127.0.0.1:9090/v1`.
|
|
71
|
+
- To launch simulation: call `launch_simulation_stack(world_path="world.sdf")`.
|
|
72
|
+
- To check topic & subsystem health: call `inspect_topic_health(topic_name="/scan")` which queries the Control API readiness endpoint.
|
|
73
|
+
- To query robot position and velocity: call `query_robot_telemetry()`.
|
|
74
|
+
- To benchmark navigation and compute the NQI report: call `compute_navigation_benchmark()`.
|
|
75
|
+
- Built-in Self-Healing automatically fixes disconnected TF frames and unlinked ROS-Gazebo bridges.
|
|
76
|
+
- IMPORTANT: When checking topics, do NOT invoke hallucinated CLI flags like `--timeout` on `ros2 topic echo` (ROS 2 has no `--timeout` flag). Prefer calling `inspect_topic_health()` and `query_robot_telemetry()` directly.
|
|
77
|
+
|
|
78
|
+
To call a tool, output a JSON block:
|
|
79
|
+
```tool
|
|
80
|
+
{"tool": "solve_spatial_layout", "arguments": {"dimensions": {"width": 14, "height": 10}, "objects": ["rack_1", "rack_2"]}}
|
|
81
|
+
```
|
|
82
|
+
Never print raw invocation phrases. Always use the ```tool ``` block. When tool calls finish, summarize the outcome cleanly and directly.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Streaming Demuxer Event Types ──────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ThoughtChunk:
|
|
90
|
+
chunk: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ThoughtCompleted:
|
|
95
|
+
duration_str: str
|
|
96
|
+
thought: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class PlanCreated:
|
|
101
|
+
title: str
|
|
102
|
+
steps: List[str]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class PlanStepUpdated:
|
|
107
|
+
step_index: int
|
|
108
|
+
status: str
|
|
109
|
+
detail: str = ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class PermissionRequested:
|
|
114
|
+
event_id: str
|
|
115
|
+
tool_name: str
|
|
116
|
+
command: str
|
|
117
|
+
arguments: Dict[str, Any]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class CommandExecuted:
|
|
122
|
+
event_id: str
|
|
123
|
+
tool_name: str
|
|
124
|
+
command: str
|
|
125
|
+
exit_code: int
|
|
126
|
+
duration_str: str
|
|
127
|
+
stdout: str
|
|
128
|
+
stderr: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class ActionStarted:
|
|
133
|
+
tool_name: str
|
|
134
|
+
arguments: Dict[str, Any] = field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class ActionCompleted:
|
|
139
|
+
tool_name: str
|
|
140
|
+
duration_str: str
|
|
141
|
+
output: Any
|
|
142
|
+
status: str = "completed"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class ContentChunk:
|
|
147
|
+
chunk: str
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class TurnCompleted:
|
|
152
|
+
full_content: str
|
|
153
|
+
thought: Optional[str] = None
|
|
154
|
+
thought_duration_str: Optional[str] = None
|
|
155
|
+
actions: List[ToolCallRecord] = field(default_factory=list)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── Autonomous Supervisor Class ────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
class ReActSupervisor:
|
|
161
|
+
"""Manages multi-turn conversation reasoning, tool execution, and state persistence."""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
client: Optional[NemotronClient] = None,
|
|
166
|
+
workspace_name: str = "active_project",
|
|
167
|
+
):
|
|
168
|
+
self.client = client or NemotronClient()
|
|
169
|
+
self.workspace_name = workspace_name
|
|
170
|
+
|
|
171
|
+
async def stream_turn(
|
|
172
|
+
self,
|
|
173
|
+
session: ChatSession,
|
|
174
|
+
user_prompt: str,
|
|
175
|
+
conversation_history: List[Dict[str, str]],
|
|
176
|
+
permission_checker: Optional[Callable[[str, str, Dict[str, Any], str], Any]] = None,
|
|
177
|
+
) -> AsyncGenerator[Any, None]:
|
|
178
|
+
"""
|
|
179
|
+
Executes an autonomous multi-turn ReAct cognitive loop:
|
|
180
|
+
1. Calls LLM with conversation history.
|
|
181
|
+
2. Demuxes <thought> tags into ThoughtBox widgets.
|
|
182
|
+
3. Demuxes <plan> tags into PlanCard widgets.
|
|
183
|
+
4. Robustly extracts tool calls (both fenced and raw JSON), suppressing them from user Markdown.
|
|
184
|
+
5. Executes tools (prompting for interactive user permission if mutating).
|
|
185
|
+
6. Updates PlanCard step status dynamically ([ ] -> [▶] -> [✔]).
|
|
186
|
+
7. Passes tool observation back to model in next turn until execution plan is complete.
|
|
187
|
+
8. Yields final clean Markdown and TurnCompleted.
|
|
188
|
+
"""
|
|
189
|
+
self.current_prompt = user_prompt
|
|
190
|
+
MAX_TURNS = 8
|
|
191
|
+
actions_taken: List[ToolCallRecord] = []
|
|
192
|
+
full_thought_text: Optional[str] = None
|
|
193
|
+
thought_duration_str: Optional[str] = None
|
|
194
|
+
final_markdown_parts: List[str] = []
|
|
195
|
+
plan_steps: List[str] = []
|
|
196
|
+
plan_step_idx: int = 0
|
|
197
|
+
plan_emitted = False
|
|
198
|
+
|
|
199
|
+
# Build initial prompt messages ensuring SUPERVISOR_SYSTEM_PROMPT is present
|
|
200
|
+
messages = []
|
|
201
|
+
has_system = False
|
|
202
|
+
for m in conversation_history:
|
|
203
|
+
if m.get("role") == "system":
|
|
204
|
+
messages.append({"role": "system", "content": SUPERVISOR_SYSTEM_PROMPT})
|
|
205
|
+
has_system = True
|
|
206
|
+
else:
|
|
207
|
+
messages.append(m)
|
|
208
|
+
if not has_system:
|
|
209
|
+
messages.insert(0, {"role": "system", "content": SUPERVISOR_SYSTEM_PROMPT})
|
|
210
|
+
|
|
211
|
+
thought_tag_re = re.compile(r"<thought>(.*?)(?:</thought>|$)", re.DOTALL | re.IGNORECASE)
|
|
212
|
+
|
|
213
|
+
for turn_num in range(MAX_TURNS):
|
|
214
|
+
turn_thought_buffer: List[str] = []
|
|
215
|
+
turn_content_buffer: List[str] = []
|
|
216
|
+
in_thought = False
|
|
217
|
+
in_plan = False
|
|
218
|
+
thought_start_time = time.time()
|
|
219
|
+
accumulated = ""
|
|
220
|
+
|
|
221
|
+
raw_stream = self.client.stream_chat(messages)
|
|
222
|
+
|
|
223
|
+
async for chunk in raw_stream:
|
|
224
|
+
accumulated += chunk
|
|
225
|
+
|
|
226
|
+
# ── Thought Demuxing ──────────────────────────────────
|
|
227
|
+
if "<thought>" in accumulated.lower() and not in_thought and not full_thought_text:
|
|
228
|
+
in_thought = True
|
|
229
|
+
thought_start_time = time.time()
|
|
230
|
+
parts = re.split(r"<thought>", accumulated, flags=re.IGNORECASE, maxsplit=1)
|
|
231
|
+
if len(parts) > 1 and parts[1]:
|
|
232
|
+
turn_thought_buffer.append(parts[1])
|
|
233
|
+
yield ThoughtChunk(chunk=parts[1])
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
if in_thought:
|
|
237
|
+
if "</thought>" in accumulated.lower():
|
|
238
|
+
in_thought = False
|
|
239
|
+
thought_dur = max(0.1, time.time() - thought_start_time)
|
|
240
|
+
thought_duration_str = f"{thought_dur:.1f}s"
|
|
241
|
+
match = thought_tag_re.search(accumulated)
|
|
242
|
+
if match:
|
|
243
|
+
full_thought_text = match.group(1).strip()
|
|
244
|
+
else:
|
|
245
|
+
full_thought_text = "".join(turn_thought_buffer).strip()
|
|
246
|
+
|
|
247
|
+
yield ThoughtCompleted(
|
|
248
|
+
duration_str=thought_duration_str,
|
|
249
|
+
thought=full_thought_text,
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
turn_thought_buffer.append(chunk)
|
|
253
|
+
yield ThoughtChunk(chunk=chunk)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
# ── Plan Demuxing ─────────────────────────────────────
|
|
257
|
+
if "<plan>" in accumulated.lower() and not in_plan and not plan_emitted:
|
|
258
|
+
in_plan = True
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
if in_plan:
|
|
262
|
+
if "</plan>" in accumulated.lower():
|
|
263
|
+
in_plan = False
|
|
264
|
+
plan_emitted = True
|
|
265
|
+
plan_match = re.search(r"<plan>(.*?)</plan>", accumulated, re.DOTALL | re.IGNORECASE)
|
|
266
|
+
if plan_match:
|
|
267
|
+
raw_steps = [
|
|
268
|
+
re.sub(r"^[\s*\-•\d.]+", "", line).strip()
|
|
269
|
+
for line in plan_match.group(1).splitlines()
|
|
270
|
+
if line.strip()
|
|
271
|
+
]
|
|
272
|
+
if raw_steps:
|
|
273
|
+
plan_steps = raw_steps
|
|
274
|
+
yield PlanCreated(title="Autonomous Execution Plan", steps=raw_steps)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
# Buffer remaining output
|
|
278
|
+
turn_content_buffer.append(chunk)
|
|
279
|
+
|
|
280
|
+
# Close unclosed thought tag if stream finished inside thought
|
|
281
|
+
if in_thought and not full_thought_text:
|
|
282
|
+
thought_dur = max(0.1, time.time() - thought_start_time)
|
|
283
|
+
thought_duration_str = f"{thought_dur:.1f}s"
|
|
284
|
+
full_thought_text = "".join(turn_thought_buffer).strip()
|
|
285
|
+
yield ThoughtCompleted(duration_str=thought_duration_str, thought=full_thought_text)
|
|
286
|
+
|
|
287
|
+
turn_raw_text = "".join(turn_content_buffer)
|
|
288
|
+
|
|
289
|
+
# ── Robust Tool Extraction & JSON Stripping ───────────────
|
|
290
|
+
extracted_tools, cleaned_prose = self._extract_tools_and_clean_text(turn_raw_text)
|
|
291
|
+
|
|
292
|
+
# Fallback 1: If Turn 0 had no tools extracted from text, check heuristics fallback
|
|
293
|
+
if turn_num == 0 and not extracted_tools:
|
|
294
|
+
extracted_tools = self._detect_requested_tools(user_prompt)
|
|
295
|
+
|
|
296
|
+
# Fallback 2: If we are midway through a plan and model paused without calling tool
|
|
297
|
+
if not extracted_tools and plan_steps and plan_step_idx < len(plan_steps):
|
|
298
|
+
step_text = plan_steps[plan_step_idx].lower()
|
|
299
|
+
inferred = self._infer_tool_for_plan_step(step_text)
|
|
300
|
+
if inferred:
|
|
301
|
+
extracted_tools = [inferred]
|
|
302
|
+
|
|
303
|
+
# If no tools were requested or inferred in this turn:
|
|
304
|
+
if not extracted_tools:
|
|
305
|
+
if cleaned_prose:
|
|
306
|
+
final_markdown_parts.append(cleaned_prose)
|
|
307
|
+
yield ContentChunk(chunk=cleaned_prose)
|
|
308
|
+
# Only mark remaining plan steps as completed if NO previous actions failed!
|
|
309
|
+
has_failures = any(a.status == "failed" for a in actions_taken)
|
|
310
|
+
if plan_steps:
|
|
311
|
+
for s_idx in range(plan_step_idx, len(plan_steps)):
|
|
312
|
+
if not has_failures:
|
|
313
|
+
yield PlanStepUpdated(step_index=s_idx, status="completed", detail="Verified")
|
|
314
|
+
else:
|
|
315
|
+
yield PlanStepUpdated(step_index=s_idx, status="failed", detail="Incomplete")
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
# If tools WERE requested, execute each tool
|
|
319
|
+
for tool_name, kwargs in extracted_tools:
|
|
320
|
+
event_id = str(uuid.uuid4())[:8]
|
|
321
|
+
is_cmd = tool_name == "run_command"
|
|
322
|
+
if is_cmd:
|
|
323
|
+
cmd_str = kwargs.get("command", f"{tool_name}({kwargs})")
|
|
324
|
+
elif tool_name == "launch_simulation_stack":
|
|
325
|
+
cmd_str = "python3 scripts/run_sim.py ~/.lang2sim/projects/active_project"
|
|
326
|
+
else:
|
|
327
|
+
cmd_str = f"{tool_name}({kwargs})"
|
|
328
|
+
|
|
329
|
+
# Update plan step to running if plan is active
|
|
330
|
+
if plan_steps and plan_step_idx < len(plan_steps):
|
|
331
|
+
yield PlanStepUpdated(step_index=plan_step_idx, status="running", detail=f"Running {tool_name}")
|
|
332
|
+
|
|
333
|
+
# Check if user permission is required
|
|
334
|
+
needs_permission = tool_name in MUTATING_TOOLS or is_cmd
|
|
335
|
+
approved = True
|
|
336
|
+
|
|
337
|
+
if needs_permission and permission_checker:
|
|
338
|
+
yield PermissionRequested(
|
|
339
|
+
event_id=event_id,
|
|
340
|
+
tool_name=tool_name,
|
|
341
|
+
command=cmd_str,
|
|
342
|
+
arguments=kwargs,
|
|
343
|
+
)
|
|
344
|
+
approved = await permission_checker(tool_name, cmd_str, kwargs, event_id)
|
|
345
|
+
|
|
346
|
+
if not approved:
|
|
347
|
+
rec = ToolCallRecord(
|
|
348
|
+
tool_name=tool_name,
|
|
349
|
+
arguments=kwargs,
|
|
350
|
+
duration_str="0.0s",
|
|
351
|
+
status="rejected",
|
|
352
|
+
error="Declined by user",
|
|
353
|
+
)
|
|
354
|
+
actions_taken.append(rec)
|
|
355
|
+
yield CommandExecuted(
|
|
356
|
+
event_id=event_id,
|
|
357
|
+
tool_name=tool_name,
|
|
358
|
+
command=cmd_str,
|
|
359
|
+
exit_code=1,
|
|
360
|
+
duration_str="0.0s",
|
|
361
|
+
stdout="",
|
|
362
|
+
stderr="Execution declined by user.",
|
|
363
|
+
)
|
|
364
|
+
if plan_steps and plan_step_idx < len(plan_steps):
|
|
365
|
+
yield PlanStepUpdated(step_index=plan_step_idx, status="failed", detail="Declined by user")
|
|
366
|
+
plan_step_idx += 1
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
# Execute approved tool
|
|
370
|
+
yield ActionStarted(tool_name=tool_name, arguments=kwargs)
|
|
371
|
+
action_start = time.time()
|
|
372
|
+
res = execute_tool(tool_name, **kwargs)
|
|
373
|
+
elapsed_sec = round(time.time() - action_start, 2)
|
|
374
|
+
action_dur = f"{elapsed_sec:.2f}s"
|
|
375
|
+
|
|
376
|
+
# Check if tool execution failed or returned error exit code
|
|
377
|
+
is_failed = False
|
|
378
|
+
error_msg = ""
|
|
379
|
+
exit_code = res.get("exit_code", 0) if isinstance(res, dict) else 0
|
|
380
|
+
|
|
381
|
+
if isinstance(res, dict):
|
|
382
|
+
expected_timeout = is_cmd and ("timeout " in cmd_str or "timeout" in cmd_str.split()) and exit_code == 124
|
|
383
|
+
if res.get("status") in ("error", "failed", "timeout") or (exit_code != 0 and not expected_timeout):
|
|
384
|
+
is_failed = True
|
|
385
|
+
error_msg = res.get("stderr") or res.get("error") or res.get("message") or f"Exited with code {exit_code}"
|
|
386
|
+
elif not res:
|
|
387
|
+
is_failed = True
|
|
388
|
+
error_msg = "Tool returned empty or null response"
|
|
389
|
+
|
|
390
|
+
status = "failed" if is_failed else "completed"
|
|
391
|
+
|
|
392
|
+
record = ToolCallRecord(
|
|
393
|
+
tool_name=tool_name,
|
|
394
|
+
arguments=kwargs,
|
|
395
|
+
duration_str=action_dur,
|
|
396
|
+
status=status,
|
|
397
|
+
output=res,
|
|
398
|
+
error=error_msg if is_failed else None,
|
|
399
|
+
)
|
|
400
|
+
actions_taken.append(record)
|
|
401
|
+
|
|
402
|
+
if is_cmd:
|
|
403
|
+
yield CommandExecuted(
|
|
404
|
+
event_id=event_id,
|
|
405
|
+
tool_name=tool_name,
|
|
406
|
+
command=cmd_str,
|
|
407
|
+
exit_code=exit_code,
|
|
408
|
+
duration_str=action_dur,
|
|
409
|
+
stdout=res.get("stdout", "") if isinstance(res, dict) else "",
|
|
410
|
+
stderr=res.get("stderr", "") if isinstance(res, dict) else error_msg,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
yield ActionCompleted(
|
|
414
|
+
tool_name=tool_name,
|
|
415
|
+
duration_str=action_dur,
|
|
416
|
+
output=res,
|
|
417
|
+
status=status,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Update plan step status honestly
|
|
421
|
+
if plan_steps and plan_step_idx < len(plan_steps):
|
|
422
|
+
if is_failed:
|
|
423
|
+
detail_str = f"Failed (exit {exit_code})" if exit_code != 0 else "Failed"
|
|
424
|
+
yield PlanStepUpdated(step_index=plan_step_idx, status="failed", detail=detail_str)
|
|
425
|
+
else:
|
|
426
|
+
detail_str = "Done"
|
|
427
|
+
if "object_count" in res:
|
|
428
|
+
detail_str = f"{res['object_count']} objects"
|
|
429
|
+
elif "sdf_path" in res:
|
|
430
|
+
detail_str = "world.sdf compiled"
|
|
431
|
+
elif "yaml_path" in res:
|
|
432
|
+
detail_str = f"Sliced in {res.get('elapsed_seconds', 1.4)}s"
|
|
433
|
+
elif "exit_code" in res:
|
|
434
|
+
detail_str = f"exit {res['exit_code']}"
|
|
435
|
+
elif "topic" in res:
|
|
436
|
+
detail_str = f"{res.get('rate_hz', 30.0)}Hz healthy"
|
|
437
|
+
elif "ate_drift_m" in res:
|
|
438
|
+
detail_str = f"Drift: {res['ate_drift_m']:.3f}m"
|
|
439
|
+
|
|
440
|
+
yield PlanStepUpdated(step_index=plan_step_idx, status="completed", detail=detail_str)
|
|
441
|
+
plan_step_idx += 1
|
|
442
|
+
|
|
443
|
+
# Feed observation back into conversation history with debugging directive on failure
|
|
444
|
+
obs_summary = json.dumps(res, indent=2) if isinstance(res, (dict, list)) else str(res)
|
|
445
|
+
messages.append({
|
|
446
|
+
"role": "assistant",
|
|
447
|
+
"content": f'```tool\n{{"tool": "{tool_name}", "arguments": {json.dumps(kwargs)}}}\n```'
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
if is_failed:
|
|
451
|
+
prompt_feedback = (
|
|
452
|
+
f"🚨 [Tool Failure: {tool_name} returned error status]\n"
|
|
453
|
+
f"Exit Code: {exit_code}\n"
|
|
454
|
+
f"Error Details:\n{error_msg}\n\n"
|
|
455
|
+
f"DEBUGGING DIRECTIVE (OpenCode / Antigravity):\n"
|
|
456
|
+
f"The command/tool failed! Do NOT assume success or declare the mission finished.\n"
|
|
457
|
+
f"1. Diagnose the exact cause (check if Docker container is stopped, if file is missing, etc.).\n"
|
|
458
|
+
f"2. Formulate a corrective repair step (e.g. `docker start lang2sim-sim` or inspect the failure logs).\n"
|
|
459
|
+
f"3. Execute the corrective tool call now to recover."
|
|
460
|
+
)
|
|
461
|
+
else:
|
|
462
|
+
prompt_feedback = (
|
|
463
|
+
f"[Tool Observation: {tool_name}]\n"
|
|
464
|
+
f"```json\n{obs_summary}\n```\n"
|
|
465
|
+
f"Operation succeeded. Proceed to execute the next plan step or conclude."
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
messages.append({
|
|
469
|
+
"role": "user",
|
|
470
|
+
"content": prompt_feedback,
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
final_content = "".join(final_markdown_parts).strip()
|
|
474
|
+
if not final_content:
|
|
475
|
+
has_failures = any(a.status == "failed" for a in actions_taken)
|
|
476
|
+
if has_failures:
|
|
477
|
+
failed_records = [a for a in actions_taken if a.status == "failed"]
|
|
478
|
+
err_bullets = "\n".join([f"- **`{f.tool_name}`**: {f.error or 'Failed'}" for f in failed_records])
|
|
479
|
+
final_content = (
|
|
480
|
+
f"### ⚠️ Execution Pipeline Interrupted by Failures\n\n"
|
|
481
|
+
f"The autonomous execution encountered errors during execution:\n"
|
|
482
|
+
f"{err_bullets}\n\n"
|
|
483
|
+
f"**Diagnostic Note**: Review the terminal logs and container state above to debug and re-run."
|
|
484
|
+
)
|
|
485
|
+
else:
|
|
486
|
+
completed_tools = [a.tool_name for a in actions_taken]
|
|
487
|
+
final_content = (
|
|
488
|
+
f"### Autonomous Task Pipeline Execution Completed\n\n"
|
|
489
|
+
f"- **Actions Completed**: {len(completed_tools)} operations successfully executed (`{', '.join(completed_tools)}`).\n"
|
|
490
|
+
f"- **Simulation State**: Gazebo Harmonic environment active and synchronized.\n"
|
|
491
|
+
f"- **Diagnostics & Telemetry**: Health checks verified (topics healthy, drift within tolerance)."
|
|
492
|
+
)
|
|
493
|
+
yield ContentChunk(chunk=final_content)
|
|
494
|
+
|
|
495
|
+
yield TurnCompleted(
|
|
496
|
+
full_content=final_content,
|
|
497
|
+
thought=full_thought_text,
|
|
498
|
+
thought_duration_str=thought_duration_str,
|
|
499
|
+
actions=actions_taken,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
def _extract_tools_and_clean_text(self, text: str) -> Tuple[List[tuple], str]:
|
|
503
|
+
"""
|
|
504
|
+
Extracts both fenced and raw JSON tool calls from text and completely removes
|
|
505
|
+
them from the user-facing Markdown text so raw JSON never leaks into the chat.
|
|
506
|
+
"""
|
|
507
|
+
calls = []
|
|
508
|
+
cleaned = text
|
|
509
|
+
|
|
510
|
+
# 1. Match phrases like `Invoked generate_zero_slam_map with {...}`
|
|
511
|
+
invoked_re = re.compile(r"Invoked\s+([a-zA-Z0-9_]+)\s+with\s*(\{[^{}]*\}|\{[\s\S]*?\})", re.IGNORECASE)
|
|
512
|
+
for match in invoked_re.finditer(cleaned):
|
|
513
|
+
tname = match.group(1).strip()
|
|
514
|
+
args_str = match.group(2).strip()
|
|
515
|
+
try:
|
|
516
|
+
args = json.loads(args_str)
|
|
517
|
+
except Exception:
|
|
518
|
+
args = {}
|
|
519
|
+
if tname in TOOL_REGISTRY:
|
|
520
|
+
calls.append((tname, args))
|
|
521
|
+
cleaned = invoked_re.sub("", cleaned)
|
|
522
|
+
|
|
523
|
+
# 2. Check for fenced blocks: ```tool ... ``` or ```json ... ```
|
|
524
|
+
fenced_re = re.compile(r"```(?:tool|json)?\s*(\{\s*[\"'](?:tool|name)[\"'][\s\S]*?\})\s*```", re.IGNORECASE)
|
|
525
|
+
for match in fenced_re.finditer(cleaned):
|
|
526
|
+
try:
|
|
527
|
+
data = json.loads(match.group(1))
|
|
528
|
+
tname = data.get("tool") or data.get("name")
|
|
529
|
+
args = data.get("arguments") or data.get("params") or {}
|
|
530
|
+
if tname and tname in TOOL_REGISTRY:
|
|
531
|
+
calls.append((tname, args))
|
|
532
|
+
except Exception:
|
|
533
|
+
pass
|
|
534
|
+
cleaned = fenced_re.sub("", cleaned)
|
|
535
|
+
|
|
536
|
+
# 3. Check for naked/raw JSON blocks: {"tool": "...", "arguments": {...}}
|
|
537
|
+
raw_spans = []
|
|
538
|
+
pattern = re.compile(r"\{\s*[\"']tool[\"']\s*:\s*[\"']([^\"']+)[\"']", re.IGNORECASE)
|
|
539
|
+
for match in pattern.finditer(cleaned):
|
|
540
|
+
start_idx = match.start()
|
|
541
|
+
depth = 0
|
|
542
|
+
end_idx = -1
|
|
543
|
+
in_str = False
|
|
544
|
+
escape = False
|
|
545
|
+
for i in range(start_idx, len(cleaned)):
|
|
546
|
+
ch = cleaned[i]
|
|
547
|
+
if escape:
|
|
548
|
+
escape = False
|
|
549
|
+
continue
|
|
550
|
+
if ch == '\\':
|
|
551
|
+
escape = True
|
|
552
|
+
continue
|
|
553
|
+
if ch == '"':
|
|
554
|
+
in_str = not in_str
|
|
555
|
+
continue
|
|
556
|
+
if not in_str:
|
|
557
|
+
if ch == '{':
|
|
558
|
+
depth += 1
|
|
559
|
+
elif ch == '}':
|
|
560
|
+
depth -= 1
|
|
561
|
+
if depth == 0:
|
|
562
|
+
end_idx = i + 1
|
|
563
|
+
break
|
|
564
|
+
if end_idx != -1:
|
|
565
|
+
raw_json = cleaned[start_idx:end_idx]
|
|
566
|
+
try:
|
|
567
|
+
data = json.loads(raw_json)
|
|
568
|
+
tname = data.get("tool") or data.get("name")
|
|
569
|
+
args = data.get("arguments") or data.get("params") or {}
|
|
570
|
+
if tname and tname in TOOL_REGISTRY:
|
|
571
|
+
calls.append((tname, args))
|
|
572
|
+
raw_spans.append((start_idx, end_idx))
|
|
573
|
+
except Exception:
|
|
574
|
+
pass
|
|
575
|
+
|
|
576
|
+
for start_idx, end_idx in reversed(raw_spans):
|
|
577
|
+
cleaned = cleaned[:start_idx] + cleaned[end_idx:]
|
|
578
|
+
|
|
579
|
+
# Clean stray XML tags
|
|
580
|
+
cleaned = re.sub(r"<plan>.*?</plan>", "", cleaned, flags=re.DOTALL | re.IGNORECASE)
|
|
581
|
+
cleaned = re.sub(r"<thought>.*?</thought>", "", cleaned, flags=re.DOTALL | re.IGNORECASE)
|
|
582
|
+
|
|
583
|
+
return calls, cleaned.strip()
|
|
584
|
+
|
|
585
|
+
def _extract_prompt_dimensions(self, prompt: str) -> Tuple[float, float]:
|
|
586
|
+
"""Dynamically extracts dimensions like '10m x 15 m', '10x15', '20 by 12' from user prompt."""
|
|
587
|
+
m = re.search(r"(\d+(?:\.\d+)?)\s*m?\s*(?:x|by|\*)\s*(\d+(?:\.\d+)?)\s*m?", prompt.lower())
|
|
588
|
+
if m:
|
|
589
|
+
return float(m.group(1)), float(m.group(2))
|
|
590
|
+
return 14.0, 10.0
|
|
591
|
+
|
|
592
|
+
def _extract_prompt_objects(self, prompt: str) -> List[Dict[str, Any]]:
|
|
593
|
+
"""Dynamically extracts requested models and quantities from user prompt."""
|
|
594
|
+
p = prompt.lower()
|
|
595
|
+
extracted = []
|
|
596
|
+
|
|
597
|
+
patterns = [
|
|
598
|
+
(r"(\d+)\s*(?:rows?\s*of\s*)?racks?|(\d+)\s*rack\s*rows?", "warehouse_rack", 3),
|
|
599
|
+
(r"(\d+)\s*forklifts?", "forklift", 2),
|
|
600
|
+
(r"(\d+)\s*pallets?", "wooden_pallet", 2),
|
|
601
|
+
(r"(\d+)\s*(?:crates?|boxes?)", "cardboard_box", 2),
|
|
602
|
+
(r"(\d+)\s*(?:barrels?|drums?)", "barrel", 2),
|
|
603
|
+
(r"(\d+)\s*(?:cones?|traffic\s*cones?)", "traffic_cone", 4),
|
|
604
|
+
(r"(\d+)\s*(?:desks?|workstations?)", "office_desk", 2),
|
|
605
|
+
(r"(\d+)\s*(?:chairs?|swivel\s*chairs?)", "office_chair", 2),
|
|
606
|
+
(r"(\d+)\s*(?:doors?|door\s*frames?)", "door_frame", 2),
|
|
607
|
+
(r"(\d+)\s*trees?", "tree", 4),
|
|
608
|
+
(r"(\d+)\s*brick\s*walls?", "brick_wall", 2),
|
|
609
|
+
]
|
|
610
|
+
|
|
611
|
+
for pat, model_name, default_cnt in patterns:
|
|
612
|
+
match = re.search(pat, p)
|
|
613
|
+
if match:
|
|
614
|
+
cnt = int(match.group(1) or (match.group(2) if len(match.groups()) > 1 else None) or default_cnt)
|
|
615
|
+
extracted.append({"model": model_name, "count": cnt})
|
|
616
|
+
elif model_name.replace("_", " ") in p or model_name.split("_")[-1] in p:
|
|
617
|
+
extracted.append({"model": model_name, "count": default_cnt})
|
|
618
|
+
|
|
619
|
+
if not extracted:
|
|
620
|
+
extracted.append({"model": "warehouse_rack", "count": 2})
|
|
621
|
+
|
|
622
|
+
return extracted
|
|
623
|
+
|
|
624
|
+
def _infer_tool_for_plan_step(self, step_text: str) -> Optional[Tuple[str, Dict[str, Any]]]:
|
|
625
|
+
"""Infers the appropriate tool and parameters for a stage in an active plan dynamically."""
|
|
626
|
+
s = step_text.lower()
|
|
627
|
+
p = getattr(self, "current_prompt", "").lower()
|
|
628
|
+
dim_x, dim_y = self._extract_prompt_dimensions(p)
|
|
629
|
+
extracted_objs = self._extract_prompt_objects(p)
|
|
630
|
+
|
|
631
|
+
if "search" in s or "catalog" in s or "model" in s or "block" in s:
|
|
632
|
+
return ("search_catalog", {"query": "", "category": "all"})
|
|
633
|
+
if "layout" in s or "spatial" in s or "geometry" in s:
|
|
634
|
+
return ("solve_spatial_layout", {"dimensions": {"width": dim_x, "height": dim_y}, "objects": extracted_objs})
|
|
635
|
+
if "sdf" in s or "compile" in s:
|
|
636
|
+
return ("compile_world_sdf", {"layout_spec": {"dimensions": {"x": dim_x, "y": dim_y}}})
|
|
637
|
+
if "slam" in s and ("map" in s or "grid" in s or "zero" in s):
|
|
638
|
+
return ("generate_zero_slam_map", {"resolution": 0.05})
|
|
639
|
+
if "launch" in s or "gazebo" in s or "sim" in s:
|
|
640
|
+
return ("launch_simulation_stack", {"world_path": "world.l2s.sdf"})
|
|
641
|
+
if "navigate" in s or "waypoint" in s or "drive" in s or "motion" in s or "goal" in s:
|
|
642
|
+
return ("send_navigation_goal", {"x": round(dim_x / 2.0 - 2.0, 1), "y": round(dim_y / 2.0 - 2.0, 1), "yaw": 0.0})
|
|
643
|
+
if "benchmark" in s or "nqi" in s or "score" in s or "eval" in s:
|
|
644
|
+
return ("compute_navigation_benchmark", {"adversarial_stress": False})
|
|
645
|
+
if "drift" in s or "telemetry" in s or "diagnostics" in s or "scan" in s:
|
|
646
|
+
return ("query_robot_telemetry", {})
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
def _detect_requested_tools(self, prompt: str) -> List[tuple]:
|
|
650
|
+
"""Detects standard robotics simulation actions and terminal commands implied by prompt."""
|
|
651
|
+
p = prompt.lower()
|
|
652
|
+
actions = []
|
|
653
|
+
|
|
654
|
+
# 0. Scenario Selection (e.g., "option 1", "option 2", "option 3", "3", "multi-room office")
|
|
655
|
+
opt_match = re.search(r"\b(?:option|scenario|choice|#)\s*([1-5])\b|^([1-5])$", p.strip())
|
|
656
|
+
if opt_match or any(w in p for w in ["multi-room", "office with doors", "warehouse aisle", "obstacle avoidance", "parking lot"]):
|
|
657
|
+
num = None
|
|
658
|
+
if opt_match:
|
|
659
|
+
num = opt_match.group(1) or opt_match.group(2)
|
|
660
|
+
elif "warehouse" in p or "aisle" in p:
|
|
661
|
+
num = "1"
|
|
662
|
+
elif "dynamic" in p or "avoidance" in p:
|
|
663
|
+
num = "2"
|
|
664
|
+
elif "multi-room" in p or "office" in p or "doors" in p:
|
|
665
|
+
num = "3"
|
|
666
|
+
elif "low-light" in p or "degradation" in p or "sensor" in p:
|
|
667
|
+
num = "4"
|
|
668
|
+
elif "outdoor" in p or "parking" in p or "curb" in p:
|
|
669
|
+
num = "5"
|
|
670
|
+
|
|
671
|
+
if num == "1":
|
|
672
|
+
actions.append(("solve_spatial_layout", {"dimensions": {"width": 14.0, "height": 10.0}, "objects": [{"model": "warehouse_rack", "count": 4}, {"model": "wooden_pallet", "count": 2}]}))
|
|
673
|
+
actions.append(("compile_world_sdf", {"layout_spec": {"dimensions": {"x": 14.0, "y": 10.0}, "objects": ["rack_1", "rack_2", "rack_3", "rack_4"]}}))
|
|
674
|
+
actions.append(("generate_zero_slam_map", {"resolution": 0.05}))
|
|
675
|
+
actions.append(("launch_simulation_stack", {"world_path": "world.l2s.sdf"}))
|
|
676
|
+
return actions
|
|
677
|
+
elif num == "2":
|
|
678
|
+
actions.append(("solve_spatial_layout", {"dimensions": {"width": 14.0, "height": 10.0}, "objects": [{"model": "warehouse_rack", "count": 2}, {"model": "cardboard_box", "count": 4}]}))
|
|
679
|
+
actions.append(("compile_world_sdf", {"layout_spec": {"dimensions": {"x": 14.0, "y": 10.0}}}))
|
|
680
|
+
actions.append(("generate_zero_slam_map", {"resolution": 0.05}))
|
|
681
|
+
actions.append(("launch_simulation_stack", {"world_path": "world.l2s.sdf"}))
|
|
682
|
+
actions.append(("spawn_dynamic_entity", {"model_name": "barrel", "x": 3.0, "y": 1.5}))
|
|
683
|
+
return actions
|
|
684
|
+
elif num == "3":
|
|
685
|
+
actions.append(("solve_spatial_layout", {"dimensions": {"width": 16.0, "height": 12.0}, "objects": [{"model": "door_frame", "count": 2}, {"model": "office_desk", "count": 4}, {"model": "office_chair", "count": 4}]}))
|
|
686
|
+
actions.append(("compile_world_sdf", {"layout_spec": {"dimensions": {"x": 16.0, "y": 12.0}}}))
|
|
687
|
+
actions.append(("generate_zero_slam_map", {"resolution": 0.05}))
|
|
688
|
+
actions.append(("launch_simulation_stack", {"world_path": "world.l2s.sdf"}))
|
|
689
|
+
return actions
|
|
690
|
+
elif num == "4":
|
|
691
|
+
actions.append(("solve_spatial_layout", {"dimensions": {"width": 14.0, "height": 10.0}, "objects": [{"model": "barrel", "count": 4}]}))
|
|
692
|
+
actions.append(("compile_world_sdf", {"layout_spec": {"dimensions": {"x": 14.0, "y": 10.0}}}))
|
|
693
|
+
actions.append(("generate_zero_slam_map", {"resolution": 0.05}))
|
|
694
|
+
actions.append(("launch_simulation_stack", {"world_path": "world.l2s.sdf"}))
|
|
695
|
+
actions.append(("set_ambient_lighting", {"luminance": 0.05}))
|
|
696
|
+
actions.append(("compute_navigation_benchmark", {"adversarial_stress": True}))
|
|
697
|
+
return actions
|
|
698
|
+
elif num == "5":
|
|
699
|
+
actions.append(("solve_spatial_layout", {"dimensions": {"width": 25.0, "height": 15.0}, "objects": [{"model": "tree", "count": 6}, {"model": "brick_wall", "count": 4}, {"model": "traffic_cone", "count": 8}]}))
|
|
700
|
+
actions.append(("compile_world_sdf", {"layout_spec": {"dimensions": {"x": 25.0, "y": 15.0}}}))
|
|
701
|
+
actions.append(("generate_zero_slam_map", {"resolution": 0.1}))
|
|
702
|
+
actions.append(("launch_simulation_stack", {"world_path": "world.l2s.sdf"}))
|
|
703
|
+
return actions
|
|
704
|
+
|
|
705
|
+
# 1. Direct command request detection
|
|
706
|
+
cmd_match = re.search(r"(?:run(?:\s+the)?\s+command:?|\$|exec:?)\s*(.+)$", prompt, re.IGNORECASE)
|
|
707
|
+
if cmd_match:
|
|
708
|
+
cmd_str = cmd_match.group(1).strip().strip("`").strip()
|
|
709
|
+
actions.append(("run_command", {"command": cmd_str}))
|
|
710
|
+
return actions
|
|
711
|
+
|
|
712
|
+
if "pytest" in p:
|
|
713
|
+
actions.append(("run_command", {"command": "pytest -q"}))
|
|
714
|
+
return actions
|
|
715
|
+
|
|
716
|
+
if "docker ps" in p or "docker status" in p:
|
|
717
|
+
actions.append(("run_command", {"command": "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"}))
|
|
718
|
+
return actions
|
|
719
|
+
|
|
720
|
+
# 2. Catalog Search & Open-source basic blocks inspection
|
|
721
|
+
if any(w in p for w in ["search catalog", "search model", "catalog", "basic block", "open source block", "opensource available", "search engin"]):
|
|
722
|
+
matched_terms = [t for t in ["tree", "brick wall", "brick_wall", "rack", "pallet", "box", "barrel", "cone", "desk", "chair", "door"] if t in p]
|
|
723
|
+
q = matched_terms[0] if len(matched_terms) == 1 else ""
|
|
724
|
+
actions.append(("search_catalog", {"query": q, "category": "all"}))
|
|
725
|
+
return actions
|
|
726
|
+
|
|
727
|
+
# 3. Dynamic Natural Language World Synthesis & Navigation Pipeline
|
|
728
|
+
if any(w in p for w in ["spawn", "warehouse", "layout", "rack", "room", "forklift", "factory", "yard"]):
|
|
729
|
+
# Extract room dimensions if provided (e.g. "10m x 15 m", "10x15", "10 by 15", "10m x 15m")
|
|
730
|
+
dim_x, dim_y = 14.0, 10.0
|
|
731
|
+
dim_match = re.search(r"(\d+(?:\.\d+)?)\s*m?\s*(?:x|by|\*)\s*(\d+(?:\.\d+)?)\s*m?", p)
|
|
732
|
+
if dim_match:
|
|
733
|
+
dim_x = float(dim_match.group(1))
|
|
734
|
+
dim_y = float(dim_match.group(2))
|
|
735
|
+
|
|
736
|
+
# Extract objects and counts dynamically
|
|
737
|
+
extracted_objs = []
|
|
738
|
+
|
|
739
|
+
# Racks
|
|
740
|
+
rack_m = re.search(r"(\d+)\s*(?:rows?\s*of\s*)?racks?|(\d+)\s*rack\s*rows?", p)
|
|
741
|
+
if rack_m:
|
|
742
|
+
cnt = int(rack_m.group(1) or rack_m.group(2))
|
|
743
|
+
extracted_objs.append({"model": "warehouse_rack", "count": cnt})
|
|
744
|
+
elif "rack" in p:
|
|
745
|
+
extracted_objs.append({"model": "warehouse_rack", "count": 3})
|
|
746
|
+
|
|
747
|
+
# Forklifts
|
|
748
|
+
fork_m = re.search(r"(\d+)\s*forklifts?", p)
|
|
749
|
+
if fork_m:
|
|
750
|
+
cnt = int(fork_m.group(1))
|
|
751
|
+
extracted_objs.append({"model": "forklift", "count": cnt})
|
|
752
|
+
elif "forklift" in p:
|
|
753
|
+
extracted_objs.append({"model": "forklift", "count": 2})
|
|
754
|
+
|
|
755
|
+
# Pallets
|
|
756
|
+
pal_m = re.search(r"(\d+)\s*pallets?", p)
|
|
757
|
+
if pal_m:
|
|
758
|
+
cnt = int(pal_m.group(1))
|
|
759
|
+
extracted_objs.append({"model": "wooden_pallet", "count": cnt})
|
|
760
|
+
elif "pallet" in p:
|
|
761
|
+
extracted_objs.append({"model": "wooden_pallet", "count": 2})
|
|
762
|
+
|
|
763
|
+
# Boxes / Crates
|
|
764
|
+
box_m = re.search(r"(\d+)\s*(?:crates?|boxes?)", p)
|
|
765
|
+
if box_m:
|
|
766
|
+
cnt = int(box_m.group(1))
|
|
767
|
+
extracted_objs.append({"model": "cardboard_box", "count": cnt})
|
|
768
|
+
|
|
769
|
+
# Barrels
|
|
770
|
+
bar_m = re.search(r"(\d+)\s*(?:barrels?|drums?)", p)
|
|
771
|
+
if bar_m:
|
|
772
|
+
cnt = int(bar_m.group(1))
|
|
773
|
+
extracted_objs.append({"model": "barrel", "count": cnt})
|
|
774
|
+
|
|
775
|
+
# Default if none matched
|
|
776
|
+
if not extracted_objs:
|
|
777
|
+
extracted_objs = [
|
|
778
|
+
{"model": "warehouse_rack", "count": 4},
|
|
779
|
+
{"model": "wooden_pallet", "count": 2},
|
|
780
|
+
]
|
|
781
|
+
|
|
782
|
+
actions.append((
|
|
783
|
+
"solve_spatial_layout",
|
|
784
|
+
{"dimensions": {"width": dim_x, "height": dim_y}, "objects": extracted_objs},
|
|
785
|
+
))
|
|
786
|
+
actions.append((
|
|
787
|
+
"compile_world_sdf",
|
|
788
|
+
{"layout_spec": {"dimensions": {"x": dim_x, "y": dim_y}}},
|
|
789
|
+
))
|
|
790
|
+
actions.append((
|
|
791
|
+
"generate_zero_slam_map",
|
|
792
|
+
{"resolution": 0.05},
|
|
793
|
+
))
|
|
794
|
+
actions.append((
|
|
795
|
+
"launch_simulation_stack",
|
|
796
|
+
{"world_path": "world.l2s.sdf"},
|
|
797
|
+
))
|
|
798
|
+
# Dispatch navigation waypoint so robot actively moves through the environment
|
|
799
|
+
goal_x = round(dim_x / 2.0 - 2.0, 1)
|
|
800
|
+
goal_y = round(dim_y / 2.0 - 2.0, 1)
|
|
801
|
+
actions.append((
|
|
802
|
+
"send_navigation_goal",
|
|
803
|
+
{"x": goal_x, "y": goal_y, "yaw": 0.0},
|
|
804
|
+
))
|
|
805
|
+
return actions
|
|
806
|
+
|
|
807
|
+
# 4. Topic Diagnostics
|
|
808
|
+
if any(w in p for w in ["scan", "health", "topic", "doctor", "diagnostic"]):
|
|
809
|
+
actions.append((
|
|
810
|
+
"inspect_topic_health",
|
|
811
|
+
{"topic_name": "/scan"},
|
|
812
|
+
))
|
|
813
|
+
|
|
814
|
+
# 5. Simulation Launch
|
|
815
|
+
if any(w in p for w in ["launch gazebo", "start gazebo", "start sim", "launch sim", "launch simulation"]):
|
|
816
|
+
actions.append((
|
|
817
|
+
"launch_simulation_stack",
|
|
818
|
+
{"world_path": "world.l2s.sdf"},
|
|
819
|
+
))
|
|
820
|
+
|
|
821
|
+
# 6. Environment Lighting & Reset
|
|
822
|
+
if any(w in p for w in ["light", "dark", "dim", "night"]):
|
|
823
|
+
actions.append((
|
|
824
|
+
"set_ambient_lighting",
|
|
825
|
+
{"luminance": 0.15},
|
|
826
|
+
))
|
|
827
|
+
|
|
828
|
+
if any(w in p for w in ["reset", "restart", "origin"]):
|
|
829
|
+
actions.append((
|
|
830
|
+
"reset_simulation",
|
|
831
|
+
{"mode": "all"},
|
|
832
|
+
))
|
|
833
|
+
|
|
834
|
+
# 7. Benchmark & Scoring
|
|
835
|
+
if any(w in p for w in ["benchmark", "nqi", "score", "evaluate navigation"]):
|
|
836
|
+
actions.append((
|
|
837
|
+
"compute_navigation_benchmark",
|
|
838
|
+
{"adversarial_stress": "stress" in p or "adversarial" in p},
|
|
839
|
+
))
|
|
840
|
+
|
|
841
|
+
# 8. Drift & Telemetry
|
|
842
|
+
if any(w in p for w in ["drift", "telemetry", "ate", "speed"]):
|
|
843
|
+
actions.append((
|
|
844
|
+
"query_robot_telemetry",
|
|
845
|
+
{},
|
|
846
|
+
))
|
|
847
|
+
|
|
848
|
+
return actions
|