mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-09-07 08:25:54 +00:00
424 lines
No EOL
14 KiB
Python
424 lines
No EOL
14 KiB
Python
import datetime
|
|
import json
|
|
from typing import Any, Dict, List, Optional
|
|
from pathlib import Path
|
|
|
|
from openspace.utils.logging import Logger
|
|
|
|
logger = Logger.get_logger(__name__)
|
|
|
|
|
|
class TrajectoryRecorder:
|
|
def __init__(
|
|
self,
|
|
task_name: str = "",
|
|
log_dir: str = "./logs/trajectories",
|
|
enable_screenshot: bool = True,
|
|
enable_video: bool = False,
|
|
server_url: Optional[str] = None,
|
|
):
|
|
"""
|
|
Initialize trajectory recorder
|
|
|
|
Args:
|
|
task_name: task name (optional, will be saved in metadata)
|
|
log_dir: log directory
|
|
enable_screenshot: whether to save screenshots (through platforms.ScreenshotClient)
|
|
enable_video: whether to enable video recording (through platform.RecordingClient)
|
|
server_url: local_server address (None = read from config/environment variables)
|
|
"""
|
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
# Simplify naming rule: add prefix if task_name is provided, otherwise use timestamp only
|
|
if task_name:
|
|
folder_name = f"{task_name}_{timestamp}"
|
|
else:
|
|
folder_name = timestamp
|
|
|
|
self.trajectory_dir = Path(log_dir) / folder_name
|
|
self.trajectory_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create screenshots directory
|
|
if enable_screenshot:
|
|
self.screenshots_dir = self.trajectory_dir / "screenshots"
|
|
self.screenshots_dir.mkdir(exist_ok=True)
|
|
else:
|
|
self.screenshots_dir = None
|
|
|
|
# Config
|
|
self.task_name = task_name
|
|
self.enable_screenshot = enable_screenshot
|
|
self.enable_video = enable_video
|
|
self.server_url = server_url
|
|
|
|
# Trajectory data
|
|
self.steps: List[Dict] = []
|
|
self.step_counter = 0
|
|
|
|
# Metadata
|
|
self.metadata = {
|
|
"task_name": task_name,
|
|
"start_time": datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
|
"enable_screenshot": enable_screenshot,
|
|
"enable_video": enable_video,
|
|
}
|
|
|
|
# Video recorder (lazy initialization)
|
|
self._video_recorder = None
|
|
|
|
# Save initial metadata
|
|
self._save_metadata()
|
|
|
|
async def record_step(
|
|
self,
|
|
backend: str,
|
|
tool: str,
|
|
command: str,
|
|
result: Optional[Dict[str, Any]] = None,
|
|
parameters: Optional[Dict[str, Any]] = None,
|
|
screenshot: Optional[bytes] = None,
|
|
extra: Optional[Dict[str, Any]] = None,
|
|
auto_screenshot: bool = False,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Record one step operation
|
|
|
|
Args:
|
|
backend: backend type (gui/shell/mcp/web/system)
|
|
tool: tool name (name of BaseTool)
|
|
command: human-readable core command
|
|
result: execution result
|
|
parameters: tool parameters
|
|
screenshot: screenshot bytes (if provided)
|
|
extra: extra information (e.g. server field for MCP)
|
|
auto_screenshot: whether to automatically capture screenshot (through platforms.ScreenshotClient)
|
|
"""
|
|
self.step_counter += 1
|
|
step_num = self.step_counter
|
|
timestamp = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
step_info = {
|
|
"step": step_num,
|
|
"timestamp": timestamp,
|
|
"backend": backend,
|
|
}
|
|
|
|
# MCP needs to record server (between backend and tool)
|
|
if extra and "server" in extra:
|
|
step_info["server"] = extra.pop("server")
|
|
|
|
# General fields
|
|
step_info["tool"] = tool # BaseTool name
|
|
step_info["command"] = command # human-readable core command
|
|
|
|
# parameters unified write to top level
|
|
if parameters:
|
|
step_info["parameters"] = parameters
|
|
elif extra and "parameters" in extra:
|
|
step_info["parameters"] = extra.pop("parameters")
|
|
|
|
# Execution result remains original
|
|
step_info["result"] = result or {}
|
|
|
|
# Other extra information (e.g. coordinates/url) only added when needed
|
|
if extra:
|
|
step_info.update(extra)
|
|
|
|
# Automatic screenshot (if enabled and no screenshot provided)
|
|
if auto_screenshot and screenshot is None and self.enable_screenshot:
|
|
screenshot = await self._capture_screenshot()
|
|
|
|
# Save screenshot
|
|
if screenshot and self.enable_screenshot and self.screenshots_dir:
|
|
screenshot_filename = f"step_{step_num:03d}.png"
|
|
screenshot_path = self.screenshots_dir / screenshot_filename
|
|
with open(screenshot_path, "wb") as f:
|
|
f.write(screenshot)
|
|
step_info["screenshot"] = f"screenshots/{screenshot_filename}"
|
|
|
|
# Add to trajectory
|
|
self.steps.append(step_info)
|
|
|
|
# Save to traj.jsonl in real time
|
|
await self._append_to_traj_file(step_info)
|
|
|
|
return step_info
|
|
|
|
async def _capture_screenshot(self) -> Optional[bytes]:
|
|
"""Capture screenshot automatically through platforms.ScreenshotClient"""
|
|
try:
|
|
from openspace.platforms import ScreenshotClient
|
|
|
|
# Lazy initialization screenshot client
|
|
if not hasattr(self, '_screenshot_client'):
|
|
try:
|
|
self._screenshot_client = ScreenshotClient(base_url=self.server_url)
|
|
except Exception:
|
|
self._screenshot_client = None
|
|
return None
|
|
|
|
if self._screenshot_client is None:
|
|
return None
|
|
|
|
return await self._screenshot_client.capture()
|
|
|
|
except Exception:
|
|
return None
|
|
|
|
async def save_init_screenshot(self, screenshot: bytes, filename: str = "init.png"):
|
|
"""Save initial screenshot to screenshots dir and update metadata."""
|
|
if not (self.enable_screenshot and self.screenshots_dir and screenshot):
|
|
return
|
|
try:
|
|
filepath = self.screenshots_dir / filename
|
|
with open(filepath, "wb") as f:
|
|
f.write(screenshot)
|
|
# Update metadata
|
|
self.metadata["init_screenshot"] = f"screenshots/{filename}"
|
|
self._save_metadata()
|
|
except Exception as e:
|
|
logger.debug(f"Failed to save initial screenshot: {e}")
|
|
|
|
async def _append_to_traj_file(self, step_info: Dict[str, Any]):
|
|
"""Add step to traj.jsonl file"""
|
|
traj_file = self.trajectory_dir / "traj.jsonl"
|
|
try:
|
|
line = json.dumps(step_info, ensure_ascii=False, default=str)
|
|
with open(traj_file, "a", encoding="utf-8") as f:
|
|
f.write(line)
|
|
f.write("\n")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to append step {step_info.get('step', '?')} to traj.jsonl: {e}")
|
|
|
|
def _save_metadata(self):
|
|
"""Save metadata to metadata.json"""
|
|
metadata_file = self.trajectory_dir / "metadata.json"
|
|
with open(metadata_file, "w", encoding="utf-8") as f:
|
|
json.dump(self.metadata, f, indent=2, ensure_ascii=False)
|
|
|
|
async def start_video_recording(self):
|
|
"""Start video recording (through platform.RecordingClient)"""
|
|
if not self.enable_video:
|
|
return
|
|
|
|
try:
|
|
from openspace.recording.video import VideoRecorder
|
|
|
|
video_path = self.trajectory_dir / "recording.mp4"
|
|
self._video_recorder = VideoRecorder(str(video_path), base_url=self.server_url)
|
|
|
|
success = await self._video_recorder.start()
|
|
if not success:
|
|
self._video_recorder = None
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Video recording failed to start: {e}")
|
|
self._video_recorder = None
|
|
|
|
async def stop_video_recording(self):
|
|
"""Stop video recording"""
|
|
if self._video_recorder:
|
|
try:
|
|
await self._video_recorder.stop()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
self._video_recorder = None
|
|
|
|
async def add_metadata(self, key: str, value: Any):
|
|
"""Add metadata"""
|
|
self.metadata[key] = value
|
|
self._save_metadata()
|
|
|
|
async def finalize(self):
|
|
"""Finalize recording, save final information"""
|
|
self.metadata["end_time"] = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
self.metadata["total_steps"] = self.step_counter
|
|
|
|
# Backend statistics
|
|
backend_counts = {}
|
|
for step in self.steps:
|
|
backend = step.get("backend", "unknown")
|
|
backend_counts[backend] = backend_counts.get(backend, 0) + 1
|
|
self.metadata["backend_counts"] = backend_counts
|
|
|
|
self._save_metadata()
|
|
|
|
# Close internal ScreenshotClient, avoid unclosed session warning
|
|
await self._cleanup_screenshot_client()
|
|
|
|
# Stop video recording
|
|
await self.stop_video_recording()
|
|
|
|
logger.info(f"Recording completed: {self.trajectory_dir} (steps: {self.step_counter})")
|
|
|
|
async def _cleanup_screenshot_client(self):
|
|
"""Cleanup screenshot client resources"""
|
|
if hasattr(self, '_screenshot_client') and self._screenshot_client:
|
|
try:
|
|
await self._screenshot_client.close()
|
|
except Exception as e:
|
|
logger.debug(f"Failed to close screenshot client: {e}")
|
|
finally:
|
|
self._screenshot_client = None
|
|
|
|
def __del__(self):
|
|
"""Ensure resources are cleaned up even if finalize() is not called"""
|
|
# Note: This is a safety net. Best practice is to call finalize() explicitly.
|
|
if hasattr(self, '_video_recorder') and self._video_recorder:
|
|
logger.warning(
|
|
f"TrajectoryRecorder for {self.trajectory_dir} was not finalized properly. "
|
|
"Consider calling finalize() or using async context manager."
|
|
)
|
|
|
|
def get_trajectory_dir(self) -> str:
|
|
"""Get trajectory directory path"""
|
|
return str(self.trajectory_dir)
|
|
|
|
async def __aenter__(self):
|
|
"""Async context manager entry"""
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
"""Async context manager exit - ensures finalize() is called"""
|
|
await self.finalize()
|
|
return False
|
|
|
|
async def record_gui_step(
|
|
recorder: TrajectoryRecorder,
|
|
command: str,
|
|
task_description: str,
|
|
result: Dict[str, Any] = None,
|
|
screenshot: Optional[bytes] = None,
|
|
max_steps: int = 10,
|
|
tool: str = "gui_agent",
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Record GUI step
|
|
|
|
Args:
|
|
recorder: recorder instance
|
|
command: actual executed pyautogui command (e.g. "pyautogui.moveTo(960, 540)")
|
|
task_description: task description
|
|
result: execution result
|
|
screenshot: screenshot
|
|
max_steps: maximum number of steps
|
|
tool: tool name
|
|
"""
|
|
parameters = {
|
|
"task_description": task_description,
|
|
"max_steps": max_steps,
|
|
}
|
|
|
|
return await recorder.record_step(
|
|
backend="gui",
|
|
tool=tool,
|
|
command=command,
|
|
result=result,
|
|
parameters=parameters,
|
|
screenshot=screenshot,
|
|
)
|
|
|
|
|
|
async def record_shell_step(
|
|
recorder: TrajectoryRecorder,
|
|
command: str,
|
|
exit_code: int,
|
|
stdout: Optional[str] = None,
|
|
stderr: Optional[str] = None,
|
|
screenshot: Optional[bytes] = None,
|
|
tool: str = "shell_agent",
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Record Shell step
|
|
|
|
Args:
|
|
recorder: recorder instance
|
|
command: command executed
|
|
exit_code: exit code
|
|
stdout: standard output (simplified version, not saved completely)
|
|
stderr: standard error (simplified version)
|
|
screenshot: screenshot
|
|
tool: tool name
|
|
"""
|
|
stdout_brief = stdout[:200] + "..." if stdout and len(stdout) > 200 else stdout
|
|
stderr_brief = stderr[:200] + "..." if stderr and len(stderr) > 200 else stderr
|
|
|
|
result = {
|
|
"status": "success" if exit_code == 0 else "error",
|
|
"exit_code": exit_code,
|
|
"stdout": stdout_brief,
|
|
"stderr": stderr_brief,
|
|
}
|
|
|
|
return await recorder.record_step(
|
|
backend="shell",
|
|
tool=tool,
|
|
command=command,
|
|
result=result,
|
|
screenshot=screenshot,
|
|
)
|
|
|
|
async def record_mcp_step(
|
|
recorder: TrajectoryRecorder,
|
|
server: str,
|
|
tool_name: str,
|
|
parameters: Dict[str, Any],
|
|
result: Any,
|
|
screenshot: Optional[bytes] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Record MCP step
|
|
|
|
Args:
|
|
recorder: recorder instance
|
|
server: MCP server name
|
|
tool_name: tool name
|
|
parameters: tool parameters
|
|
result: execution result
|
|
screenshot: screenshot
|
|
"""
|
|
command = f"{server}.{tool_name}"
|
|
|
|
result_str = str(result)
|
|
result_brief = result_str[:200] + "..." if len(result_str) > 200 else result_str
|
|
|
|
return await recorder.record_step(
|
|
backend="mcp",
|
|
tool=tool_name,
|
|
command=command,
|
|
result={"status": "success", "output": result_brief},
|
|
parameters=parameters,
|
|
screenshot=screenshot,
|
|
extra={
|
|
"server": server,
|
|
}
|
|
)
|
|
|
|
|
|
async def record_web_step(
|
|
recorder: TrajectoryRecorder,
|
|
query: str,
|
|
result: Dict[str, Any],
|
|
screenshot: Optional[bytes] = None,
|
|
tool: str = "deep_research_agent",
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Record Web step (deep research)
|
|
|
|
Args:
|
|
recorder: recorder instance
|
|
query: search query
|
|
result: execution result
|
|
screenshot: screenshot
|
|
tool: tool name
|
|
"""
|
|
command = query # directly use query as command
|
|
|
|
return await recorder.record_step(
|
|
backend="web",
|
|
tool=tool,
|
|
command=command,
|
|
result=result,
|
|
screenshot=screenshot,
|
|
) |