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.
Files changed (65) hide show
  1. lang2sim/__init__.py +47 -0
  2. lang2sim/agent/__init__.py +5 -0
  3. lang2sim/agent/nemotron_client.py +109 -0
  4. lang2sim/agent/react_loop.py +848 -0
  5. lang2sim/agent/tools.py +998 -0
  6. lang2sim/cli/app.py +183 -0
  7. lang2sim/cli/screens/__init__.py +8 -0
  8. lang2sim/cli/screens/command_palette_modal.py +146 -0
  9. lang2sim/cli/screens/landing_screen.py +96 -0
  10. lang2sim/cli/screens/sessions_modal.py +236 -0
  11. lang2sim/cli/screens/workspace_screen.py +709 -0
  12. lang2sim/cli/widgets/__init__.py +16 -0
  13. lang2sim/cli/widgets/action_pill.py +67 -0
  14. lang2sim/cli/widgets/command_card.py +265 -0
  15. lang2sim/cli/widgets/plan_card.py +106 -0
  16. lang2sim/cli/widgets/prompt_card.py +96 -0
  17. lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
  18. lang2sim/cli/widgets/thought_box.py +64 -0
  19. lang2sim/compiler/__init__.py +15 -0
  20. lang2sim/compiler/sdf_compiler.py +65 -0
  21. lang2sim/compiler/sir_generator.py +46 -0
  22. lang2sim/compiler/sir_validator.py +87 -0
  23. lang2sim/core/__init__.py +50 -0
  24. lang2sim/core/config.py +116 -0
  25. lang2sim/core/models.py +279 -0
  26. lang2sim/core/session_manager.py +189 -0
  27. lang2sim/perturbation/__init__.py +50 -0
  28. lang2sim/perturbation/_compat.py +19 -0
  29. lang2sim/perturbation/gazebo_bridge.py +520 -0
  30. lang2sim/perturbation/mcp_server.py +129 -0
  31. lang2sim/perturbation/routers/__init__.py +19 -0
  32. lang2sim/perturbation/routers/world.py +110 -0
  33. lang2sim/perturbation/sdf_snippets.py +112 -0
  34. lang2sim/runtime/__init__.py +7 -0
  35. lang2sim/runtime/control_api/__init__.py +7 -0
  36. lang2sim/runtime/control_api/client.py +187 -0
  37. lang2sim/runtime/control_api/server.py +504 -0
  38. lang2sim/runtime/display.py +141 -0
  39. lang2sim/runtime/docker_runner.py +286 -0
  40. lang2sim/runtime/doctor.py +220 -0
  41. lang2sim/runtime/hardware_probe.py +144 -0
  42. lang2sim/runtime/health_monitor.py +66 -0
  43. lang2sim/runtime/launch_builder.py +137 -0
  44. lang2sim/runtime/nav2_dispatcher.py +215 -0
  45. lang2sim/runtime/platform.py +137 -0
  46. lang2sim/runtime/ros_bridge.py +183 -0
  47. lang2sim/runtime/run_folder.py +86 -0
  48. lang2sim/runtime/self_healing.py +387 -0
  49. lang2sim/runtime/session.py +136 -0
  50. lang2sim/runtime/world_check.py +197 -0
  51. lang2sim/telemetry/__init__.py +85 -0
  52. lang2sim/telemetry/nqi_scorer.py +645 -0
  53. lang2sim/telemetry/ros_subscribers.py +489 -0
  54. lang2sim/telemetry/trajectory.py +258 -0
  55. lang2sim/telemetry/vision.py +477 -0
  56. lang2sim/world_gen/__init__.py +16 -0
  57. lang2sim/world_gen/catalog_loader.py +83 -0
  58. lang2sim/world_gen/map_slicer.py +131 -0
  59. lang2sim/world_gen/shapely_solver.py +256 -0
  60. lang2sim-2.0.0.dist-info/METADATA +53 -0
  61. lang2sim-2.0.0.dist-info/RECORD +65 -0
  62. lang2sim-2.0.0.dist-info/WHEEL +5 -0
  63. lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
  64. lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
  65. 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