|
6 | 6 | from openhands.sdk.agent.base import AgentBase |
7 | 7 | from openhands.sdk.context.prompts.prompt import render_template |
8 | 8 | from openhands.sdk.conversation.base import BaseConversation |
| 9 | +from openhands.sdk.conversation.event_store import EventLog |
9 | 10 | from openhands.sdk.conversation.exceptions import ConversationRunError |
10 | 11 | from openhands.sdk.conversation.secret_registry import SecretValue |
11 | 12 | from openhands.sdk.conversation.state import ( |
|
25 | 26 | DefaultConversationVisualizer, |
26 | 27 | ) |
27 | 28 | from openhands.sdk.event import ( |
| 29 | + ActionEvent, |
28 | 30 | CondensationRequest, |
29 | 31 | MessageEvent, |
| 32 | + ObservationEvent, |
30 | 33 | PauseEvent, |
31 | 34 | UserRejectObservation, |
32 | 35 | ) |
33 | 36 | from openhands.sdk.event.conversation_error import ConversationErrorEvent |
34 | 37 | from openhands.sdk.hooks import HookConfig, HookEventProcessor, create_hook_callback |
| 38 | +from openhands.sdk.io import LocalFileStore |
35 | 39 | from openhands.sdk.llm import LLM, Message, TextContent |
36 | 40 | from openhands.sdk.llm.llm_profile_store import LLMProfileStore |
37 | 41 | from openhands.sdk.llm.llm_registry import LLMRegistry |
@@ -958,6 +962,111 @@ def condense(self) -> None: |
958 | 962 |
|
959 | 963 | logger.info("Condensation request processed") |
960 | 964 |
|
| 965 | + def rerun_actions( |
| 966 | + self, |
| 967 | + rerun_log_path: str | Path | None = None, |
| 968 | + ) -> bool: |
| 969 | + """Re-execute all actions from the conversation's event history. |
| 970 | +
|
| 971 | + This method iterates through all ActionEvents in the conversation and |
| 972 | + re-executes them using their original action parameters. Execution |
| 973 | + stops immediately if any tool call fails. |
| 974 | +
|
| 975 | + WARNING: This is an advanced feature intended for specific use cases |
| 976 | + such as reproducing environment state from a saved conversation. Many |
| 977 | + tool operations are NOT idempotent: |
| 978 | +
|
| 979 | + - File operations may fail if files already exist or were deleted |
| 980 | + - Terminal commands may have different effects on changed state |
| 981 | + - API calls may have side effects or return different results |
| 982 | + - Browser state may differ from the original session |
| 983 | +
|
| 984 | + Use this method only when you understand that: |
| 985 | + 1. Results may differ from the original conversation |
| 986 | + 2. Some actions may fail due to changed environment state |
| 987 | + 3. The workspace should typically be reset before rerunning |
| 988 | +
|
| 989 | + Args: |
| 990 | + rerun_log_path: Optional directory path to save a rerun event log. |
| 991 | + If provided, events will be written incrementally to disk using |
| 992 | + EventLog, avoiding memory buildup for large conversations. |
| 993 | +
|
| 994 | + Returns: |
| 995 | + True if all actions executed successfully, False if any action failed. |
| 996 | +
|
| 997 | + Raises: |
| 998 | + KeyError: If a tool from the original conversation is not available. |
| 999 | + This is a configuration error (different from execution failure). |
| 1000 | + """ |
| 1001 | + # Ensure agent is initialized (loads plugins and initializes tools) |
| 1002 | + self._ensure_agent_ready() |
| 1003 | + |
| 1004 | + # Set up rerun log if path provided |
| 1005 | + rerun_log: EventLog | None = None |
| 1006 | + if rerun_log_path is not None: |
| 1007 | + log_dir = Path(rerun_log_path) |
| 1008 | + log_dir.mkdir(parents=True, exist_ok=True) |
| 1009 | + file_store = LocalFileStore(str(log_dir)) |
| 1010 | + rerun_log = EventLog(file_store, dir_path="events") |
| 1011 | + |
| 1012 | + action_count = 0 |
| 1013 | + |
| 1014 | + for event in self._state.events: |
| 1015 | + if not isinstance(event, ActionEvent): |
| 1016 | + continue |
| 1017 | + if event.action is None: |
| 1018 | + # Skip actions that failed validation during original run |
| 1019 | + continue |
| 1020 | + |
| 1021 | + action_count += 1 |
| 1022 | + tool_name = event.tool_name |
| 1023 | + |
| 1024 | + # Get the tool from the agent's tools_map |
| 1025 | + tool = self.agent.tools_map.get(tool_name) |
| 1026 | + if tool is None: |
| 1027 | + available_tools = list(self.agent.tools_map.keys()) |
| 1028 | + raise KeyError( |
| 1029 | + f"Tool '{tool_name}' not found during rerun. " |
| 1030 | + f"Available tools: {available_tools}. " |
| 1031 | + f"Ensure the agent is configured with the same tools as the " |
| 1032 | + f"original conversation." |
| 1033 | + ) |
| 1034 | + |
| 1035 | + if not tool.executor: |
| 1036 | + logger.warning( |
| 1037 | + f"Skipping action {action_count}: " |
| 1038 | + f"tool '{tool_name}' has no executor" |
| 1039 | + ) |
| 1040 | + continue |
| 1041 | + |
| 1042 | + # Execute the tool with the original action |
| 1043 | + try: |
| 1044 | + logger.info(f"Rerunning action {action_count}: {tool_name}") |
| 1045 | + observation = tool(event.action, self) |
| 1046 | + |
| 1047 | + # Log the action and observation incrementally |
| 1048 | + if rerun_log is not None: |
| 1049 | + # Append action event (copy from original) |
| 1050 | + rerun_log.append(event) |
| 1051 | + # Append observation event |
| 1052 | + obs_event = ObservationEvent( |
| 1053 | + source="environment", |
| 1054 | + tool_name=tool_name, |
| 1055 | + tool_call_id=event.tool_call_id, |
| 1056 | + observation=observation, |
| 1057 | + action_id=event.id, |
| 1058 | + ) |
| 1059 | + rerun_log.append(obs_event) |
| 1060 | + except Exception as e: |
| 1061 | + logger.error( |
| 1062 | + f"Action {action_count} ({tool_name}) failed during rerun: {e}" |
| 1063 | + ) |
| 1064 | + # Log is already written incrementally, just return failure |
| 1065 | + return False |
| 1066 | + |
| 1067 | + logger.info(f"Rerun complete: {action_count} actions processed successfully") |
| 1068 | + return True |
| 1069 | + |
961 | 1070 | def execute_tool(self, tool_name: str, action: Action) -> Observation: |
962 | 1071 | """Execute a tool directly without going through the agent loop. |
963 | 1072 |
|
|
0 commit comments