Skip to content

Commit 48fb193

Browse files
committed
[24170] Added 'save_plans()' and 'load_plans()' to store and load AI generated plans
Signed-off-by: danipiza <dpizarrogallego@gmail.com>
1 parent 122e0fd commit 48fb193

1 file changed

Lines changed: 223 additions & 15 deletions

File tree

src/vulcanai/console/console.py

Lines changed: 223 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616

1717
import argparse
1818
import asyncio
19+
from datetime import datetime
20+
import json
1921
import sys
2022
import threading
23+
from pathlib import Path
24+
from typing import Optional
2125

2226
import pyperclip # To paste the clipboard into the terminal
2327
from textual import events, work
@@ -33,7 +37,7 @@
3337
from vulcanai.console.utils import SpinnerHook, StreamToTextual, attach_ros_logger_to_console, common_prefix
3438
from vulcanai.console.widget_custom_log_text_area import CustomLogTextArea
3539
from vulcanai.console.widget_spinner import SpinnerStatus
36-
40+
from vulcanai.core.plan_types import GlobalPlan
3741

3842
class TextualLogSink:
3943
"""A default console that prints to standard output."""
@@ -169,6 +173,9 @@ def __init__(
169173
# Suggestion index for RadioListModal
170174
self.suggestion_index = -1
171175
self.suggestion_index_changed = threading.Event()
176+
# Log creation files
177+
now = datetime.now()
178+
self.execution_time = now.strftime('%Y-%m-%d-%H-%M')
172179

173180
async def on_mouse_down(self, event: MouseEvent) -> None:
174181
"""
@@ -266,6 +273,8 @@ def worker() -> None:
266273
"/plan": self.cmd_plan,
267274
"/rerun": self.cmd_rerun,
268275
"/bb": self.cmd_blackboard_state,
276+
"/save_plans": self.cmd_save_plans,
277+
"/load_plan": self.cmd_load_plan,
269278
"/clear": self.cmd_clear,
270279
"/exit": self.cmd_quit,
271280
}
@@ -288,8 +297,8 @@ def worker() -> None:
288297
self.manager.register_tools_from_file(tool_file_path)
289298

290299
# Entry points tools
291-
for ep in self.tools_from_entrypoints:
292-
self.manager.register_tools_from_entry_points(ep)
300+
if self.tools_from_entrypoints != "":
301+
self.manager.register_tools_from_entry_points(self.tools_from_entrypoints)
293302

294303
# Add user context
295304
self.manager.add_user_context(self.user_context)
@@ -367,6 +376,154 @@ def worker(user_input: str = "") -> None:
367376

368377
# region Utilities
369378

379+
def save_history_log(self, output_dir: Path | str = None) -> Optional[Path]:
380+
"""
381+
Save the queries history to a '.log' file.
382+
"""
383+
if output_dir is None:
384+
# Default path
385+
output_dir = Path("./saved_plans/")
386+
else:
387+
output_dir = Path(output_dir)
388+
389+
# Create the output directory if it doesn't exist
390+
output_dir.mkdir(parents=True, exist_ok=True)
391+
392+
try:
393+
# Create a filename with timestamp
394+
filename = f"{self.execution_time}_history.log"
395+
filepath = output_dir / filename
396+
397+
# 186║
398+
# 187╗
399+
# 188╝
400+
# 200╚
401+
# 201╔
402+
# 202╩
403+
# 203╦
404+
# 204╠
405+
# 205═
406+
407+
# Log tittle
408+
log_lines = [
409+
"╔" + "═" * 78 + "╗",
410+
f"║ VulcanAI Console Session History".ljust(79) + "║",
411+
f"║ Execution Time: {self.execution_time}".ljust(79) + "║",
412+
"╚" + "═" * 78 + "╝",
413+
""
414+
]
415+
416+
# Add queries from manager history
417+
if self.manager and hasattr(self.manager, 'history') and self.manager.history:
418+
log_lines.append(f"Total Queries: {len(self.manager.history)}")
419+
log_lines.append("-" * 48)
420+
log_lines.append("")
421+
422+
for i, (user_text, plan_summary) in enumerate(self.manager.history, 1):
423+
log_lines.append(f"Query #{i}")
424+
log_lines.append(f" User Input: {user_text.split(chr(10))[-1] if chr(10) in user_text else user_text}")
425+
log_lines.append(f" Plan Summary: {plan_summary}")
426+
log_lines.append("")
427+
else:
428+
log_lines.append("No queries recorded.")
429+
log_lines.append("")
430+
431+
# Save to file
432+
filepath.write_text("\n".join(log_lines), encoding="utf-8")
433+
434+
self.logger.log_console(f"History log saved to: {filepath}")
435+
return filepath
436+
437+
except Exception as e:
438+
self.logger.log_console(f"Error saving history log: {e}")
439+
return None
440+
441+
def save_plans_to_json(self, output_dir: Path | str = None) -> list[Path]:
442+
"""
443+
Save all plans from 'self.plans_list' to JSON files.
444+
"""
445+
if output_dir is None:
446+
# Default path
447+
output_dir = Path("./saved_plans/")
448+
else:
449+
output_dir = Path(output_dir)
450+
451+
# Create the output directory if it doesn't exist
452+
output_dir.mkdir(parents=True, exist_ok=True)
453+
454+
saved_files = []
455+
456+
for idx, plan in enumerate(self.plans_list):
457+
if plan is None:
458+
self.logger.log_console(f"Skipping plan {idx}: plan is None")
459+
continue
460+
461+
try:
462+
# Create a filename based on index and timestamp
463+
filename = f"{self.execution_time}_plan_{idx}.json"
464+
filepath = output_dir / filename
465+
466+
# Convert plan to JSON dictionary
467+
plan_dict = plan.model_dump()
468+
469+
# Save to file
470+
filepath.write_text(
471+
json.dumps(plan_dict, indent=2),
472+
encoding="utf-8"
473+
)
474+
475+
saved_files.append(filepath)
476+
self.logger.log_console(f"Saved plan {idx} to: {filepath}")
477+
478+
except Exception as e:
479+
self.logger.log_console(f"Error saving plan {idx}: {e}")
480+
481+
if saved_files:
482+
self.logger.log_console(f"Successfully saved {len(saved_files)} plan(s)")
483+
else:
484+
self.logger.log_console("No plans were saved")
485+
486+
return saved_files
487+
488+
def load_and_execute_plan(self, plan_file: Path | str) -> Optional[dict]:
489+
"""
490+
Load a plan from a JSON file and execute it.
491+
"""
492+
try:
493+
plan_file = Path(plan_file)
494+
495+
if not plan_file.exists():
496+
self.logger.log_console(f"Error: Plan file not found: {plan_file}")
497+
return None
498+
499+
# Load the plan from JSON
500+
plan_data = json.loads(plan_file.read_text(encoding="utf-8"))
501+
502+
# Convert JSON to GlobalPlan object
503+
plan = GlobalPlan.model_validate(plan_data)
504+
505+
self.logger.log_console(f"Loaded plan from: {plan_file}")
506+
self.logger.log_console(f"Executing plan...")
507+
508+
# Execute the plan using the manager's executor and blackboard
509+
result = self.manager.executor.run(plan, self.manager.bb)
510+
511+
# Update the last blackboard state
512+
self.last_bb = result.get("blackboard", None)
513+
514+
# Log the result
515+
bb_parsed = str(self.last_bb).replace("<", "'").replace(">", "'")
516+
self.logger.log_console(f"Plan execution completed. Output: {bb_parsed}")
517+
518+
return result
519+
520+
except json.JSONDecodeError as e:
521+
self.logger.log_console(f"Error: Invalid JSON in plan file: {e}")
522+
return None
523+
except Exception as e:
524+
self.logger.log_console(f"Error executing plan: {e}")
525+
return None
526+
370527
def _apply_history_to_input(self) -> None:
371528
"""
372529
Function used to apply the current history index to the input box.
@@ -482,20 +639,22 @@ def cmd_help(self, _) -> None:
482639
"___________________\n"
483640
"<bold>Available commands:</bold>\n"
484641
"‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\n"
485-
"/<bold>help</bold> - Show this help message\n"
486-
"/<bold>tools</bold> - List available tools\n"
487-
"/<bold>edit_tools</bold> - Edit the list of available tools\n"
488-
"/<bold>change_k 'int'</bold> - Change the 'k' value for the top_k algorithm selection"
642+
"/<bold>help</bold> - Show this help message\n"
643+
"/<bold>tools</bold> - List available tools\n"
644+
"/<bold>edit_tools</bold> - Edit the list of available tools\n"
645+
"/<bold>change_k 'int'</bold> - Change the 'k' value for the top_k algorithm selection"
489646
" or show the current value if no 'int' is provided\n"
490-
"/<bold>history 'int'</bold> - Change the history depth or show the current value if no"
647+
"/<bold>history 'int'</bold> - Change the history depth or show the current value if no"
491648
" 'int' is provided\n"
492-
"/<bold>show_history</bold> - Show the current history\n"
493-
"/<bold>clear_history</bold> - Clear the history\n"
494-
"/<bold>plan</bold> - Show the last generated plan\n"
495-
"/<bold>rerun</bold> - Rerun the last plan\n"
496-
"/<bold>bb</bold> - Show the last blackboard state\n"
497-
"/<bold>clear</bold> - Clears the console screen\n"
498-
"/<bold>exit</bold> - Exit the console\n"
649+
"/<bold>show_history</bold> - Show the current history\n"
650+
"/<bold>clear_history</bold> - Clear the history\n"
651+
"/<bold>plan</bold> - Show the last generated plan\n"
652+
"/<bold>rerun</bold> - Rerun the last plan\n"
653+
"/<bold>save_plans 'dir'</bold> - Save all generated plans to JSON files (default: ./saved_plans/)\n"
654+
"/<bold>load_plan 'path'</bold> - Load and execute a plan from a JSON file\n"
655+
"/<bold>bb</bold> - Show the last blackboard state\n"
656+
"/<bold>clear</bold> - Clears the console screen\n"
657+
"/<bold>exit</bold> - Exit the console\n"
499658
"<bold>Query any other text</bold> to process it with the LLM and execute the plan generated.\n\n"
500659
"Add --image='path' to include images in the query. It can be used multiple times to add"
501660
" more images.\n"
@@ -664,6 +823,55 @@ def cmd_blackboard_state(self, _) -> None:
664823
else:
665824
self.logger.log_console("No blackboard available.")
666825

826+
def cmd_save_plans(self, args) -> None:
827+
"""Save all generated plans to JSON files and history log."""
828+
output_dir = "./saved_plans/"
829+
if len(args) > 0:
830+
output_dir = args[0]
831+
832+
# Save history log first
833+
history_log_path = self.save_history_log(output_dir)
834+
835+
if not self.plans_list:
836+
self.logger.log_console("No plans to save")
837+
if history_log_path:
838+
self.logger.log_console(f"History log saved successfully to: {Path(output_dir).absolute()}")
839+
return
840+
841+
saved_files = self.save_plans_to_json(output_dir)
842+
if saved_files:
843+
self.logger.log_console(f"All plans saved to: {Path(output_dir).absolute()}")
844+
else:
845+
self.logger.log_console("No plans were saved")
846+
847+
def cmd_load_plan(self, args) -> None:
848+
self._load_plan_worker(args) # start worker (dont await)
849+
850+
@work(thread=True)
851+
async def _load_plan_worker(self, args) -> None:
852+
"""
853+
Worker function used to run the command "rerun".
854+
It has to be a worker(thead=True) because the call 'self.manager.executor.run'
855+
might have a "call_from_thread" in the tool executed,
856+
and it is only valid in non Textual app Threads (separated Thread).
857+
858+
@work runs on the app's event loop (app thread) and is for async, non-blocking code.
859+
@work(thread=True) runs in a separate OS thread and is for blocking.
860+
861+
e.g.:
862+
'move_turtle' tool contains a 'call_from_thread'
863+
'ros2_topic' tool does not contains a 'call_from_thread'
864+
"""
865+
866+
if len(args) == 0:
867+
self.logger.log_console("Usage: /load_plan 'path/to/plan.json'")
868+
return
869+
870+
plan_file = args[0]
871+
result = self.load_and_execute_plan(plan_file)
872+
if result is None:
873+
self.logger.log_console(f"Failed to load and execute plan from: {plan_file}")
874+
667875
def cmd_clear(self, _) -> None:
668876
self.left_pannel.clear_console()
669877

0 commit comments

Comments
 (0)