diff --git a/ida-plugin.json b/ida-plugin.json index 396ad40..02b75da 100644 --- a/ida-plugin.json +++ b/ida-plugin.json @@ -3,7 +3,7 @@ "plugin": { "name": "IDAssist", "entryPoint": "idassist_plugin.py", - "version": "2.0.0", + "version": "2.1.0", "description": "AI-powered reverse engineering plugin with LLM analysis, semantic knowledge graphs, RAG search, and SymGraph collaboration", "license": "MIT", "urls": { diff --git a/src/controllers/query_controller.py b/src/controllers/query_controller.py index 936d412..ed3ac8f 100644 --- a/src/controllers/query_controller.py +++ b/src/controllers/query_controller.py @@ -31,6 +31,7 @@ from src.services.transcript_renderer import TranscriptRenderer from src.services.tool_approval_service import ToolApprovalService from src.controllers.react_thread import ReActOrchestratorThread from src.services.models.react_models import ReActConfig, ReActResult, ReActStatus +from src.services.react.react_prompts import ReActPrompts class LLMQueryThread(QThread): @@ -488,6 +489,45 @@ class ToolExecutorThread(QThread): _STREAM_ARG_UNSET = object() +class ReActPlanningThread(QThread): + plan_complete = Signal(str) + plan_error = Signal(str) + + def __init__(self, prompt: str, provider_config: dict, llm_factory, parent=None): + super().__init__(parent) + self.prompt = prompt + self.provider_config = provider_config + self.llm_factory = llm_factory + self.cancelled = False + + def cancel(self): + self.cancelled = True + + def run(self): + try: + asyncio.run(self._run_plan()) + except Exception as e: + if not self.cancelled: + self.plan_error.emit(str(e)) + + async def _run_plan(self): + if self.cancelled: + return + from src.services.models.llm_models import ChatMessage, ChatRequest, MessageRole + + provider = self.llm_factory.create_provider(self.provider_config) + request = ChatRequest( + messages=[ChatMessage(role=MessageRole.USER, content=self.prompt)], + model=getattr(provider, "model", ""), + max_tokens=getattr(provider, "max_tokens", 4096), + temperature=0.3, + stream=False, + ) + response = await provider.chat_completion(request) + if not self.cancelled: + self.plan_complete.emit(response.content or "") + + class QueryController: CHAT_TYPE_METADATA_KEY = "chat_type" DOCUMENT_CHAT_METADATA_KEY = "is_document_chat" @@ -563,13 +603,22 @@ class QueryController: # ReAct (agentic mode) state self._react_thread: Optional[ReActOrchestratorThread] = None + self._react_planning_thread: Optional[ReActPlanningThread] = None self._react_active = False + self._react_plan_pending = False self._react_streaming_final_answer = False self._last_todo_snapshot = "" self._last_react_tasks: List[Dict[str, str]] = [] self._current_react_run_id: Optional[str] = None self._current_react_objective: Optional[str] = None self._current_react_final_status: Optional[str] = None + self._pending_react_plan: Optional[str] = None + self._pending_react_initial_context: str = "" + self._pending_react_provider_config: Optional[Dict[str, Any]] = None + self._pending_react_provider_type: str = "anthropic_platform" + self._pending_react_mcp_tools: List[Dict[str, Any]] = [] + self._pending_react_config: Optional[ReActConfig] = None + self._pending_react_refinements: List[str] = [] # Connect view signals self._connect_signals() @@ -606,6 +655,8 @@ class QueryController: self.view.agentic_enabled_changed.connect(self.on_agentic_enabled_changed) self.view.accept_all_tools_changed.connect(self.on_accept_all_tools_changed) self.view.approval_decision_requested.connect(self.on_approval_decision_requested) + self.view.plan_approved_requested.connect(self.on_react_plan_approved) + self.view.plan_cancel_requested.connect(self.on_react_plan_cancelled) self.view.transcript_link_clicked.connect(self.on_transcript_link_clicked) self.view.rlhf_feedback_requested.connect(self.handle_rlhf_feedback) @@ -1249,6 +1300,9 @@ class QueryController: # Route to agentic mode if enabled if self.view.is_agentic_enabled(): + if self._react_plan_pending: + self._refine_react_plan(query_text) + return self._submit_agentic_query(query_text) return @@ -1350,6 +1404,12 @@ class QueryController: self._reasoning_filter.reset() self._streaming_renderer.reset() + if self._react_planning_thread and self._react_planning_thread.isRunning(): + self._react_planning_thread.cancel() + self._react_planning_thread.wait(1000) + self.view.set_query_running(False) + return + # Cancel ReAct if active if self._react_active and self._react_thread: self._react_thread.cancel() @@ -3604,6 +3664,123 @@ Tool Usage Guidelines: # ReAct (Agentic Mode) Methods # ========================================================================= + def _build_react_planning_prompt(self, objective: str, initial_context: str, + previous_plan: Optional[str] = None, + refinement: Optional[str] = None) -> str: + base = ReActPrompts.get_planning_prompt(objective, initial_context) + if previous_plan: + base += f"\n\n## Previous Proposed Plan\n{previous_plan}\n" + if refinement: + base += ( + f"\n\n## User Refinement Request\n{refinement}\n\n" + "Revise the plan to incorporate this feedback. Return only the updated markdown checklist." + ) + else: + base += "\n\nReturn only the proposed markdown checklist and any brief assumptions needed to execute it." + return base + + def _start_react_planning(self, objective: str, initial_context: str, + provider_config: Dict[str, Any], + previous_plan: Optional[str] = None, + refinement: Optional[str] = None): + if self._react_planning_thread and self._react_planning_thread.isRunning(): + self._react_planning_thread.cancel() + self._react_planning_thread.wait(1000) + + prompt = self._build_react_planning_prompt(objective, initial_context, previous_plan, refinement) + self._react_planning_thread = ReActPlanningThread(prompt, provider_config, self.llm_factory) + self._react_planning_thread.plan_complete.connect(self._on_react_plan_ready) + self._react_planning_thread.plan_error.connect(self._on_react_plan_error) + self.view.set_query_running(True) + self._react_planning_thread.start() + + def _on_react_plan_ready(self, plan_text: str): + self._pending_react_plan = plan_text.strip() + self._react_plan_pending = True + self.view.set_query_running(False) + self.view.set_pending_plan(self._pending_react_plan) + if self.current_chat_id and self._current_query_binary_hash and self._pending_react_plan: + self.transcript_service.append_todo_snapshot( + self._current_query_binary_hash, + str(self.current_chat_id), + self._pending_react_plan, + [], + metadata_extra={ + "react_run_id": self._current_react_run_id, + "react_objective": self._current_react_objective, + "agent_phase": "plan_pending", + }, + ) + self._refresh_transcript_view("") + + def _on_react_plan_error(self, error: str): + log.log_error(f"ReAct planning error: {error}") + self.view.set_query_running(False) + self._react_plan_pending = False + self.view.set_pending_plan(None) + self._add_message_to_chat(self.current_chat_id, "error", f"**Agentic Planning Error:** {error}") + self._current_query_binary_hash = None + + def _refine_react_plan(self, refinement_text: str): + if not self._react_plan_pending or not self._pending_react_provider_config: + return + self._pending_react_refinements.append(refinement_text) + if self.current_chat_id and self._current_query_binary_hash: + self.transcript_service.append_event( + self._current_query_binary_hash, + str(self.current_chat_id), + TranscriptEventKind.USER_MESSAGE, + "user", + refinement_text, + metadata={"agent_phase": "plan_refinement", "react_run_id": self._current_react_run_id}, + ) + self._start_react_planning( + self._current_react_objective or refinement_text, + self._pending_react_initial_context, + self._pending_react_provider_config, + previous_plan=self._pending_react_plan, + refinement=refinement_text, + ) + + def on_react_plan_cancelled(self): + if self._react_planning_thread and self._react_planning_thread.isRunning(): + self._react_planning_thread.cancel() + self._react_plan_pending = False + self._pending_react_plan = None + self.view.set_pending_plan(None) + self.view.set_query_running(False) + self._current_react_run_id = None + self._current_react_objective = None + self._current_query_binary_hash = None + + def on_react_plan_approved(self): + if not self._react_plan_pending or not self._pending_react_plan: + return + self._react_plan_pending = False + self.view.set_pending_plan(None) + if self.current_chat_id and self._current_query_binary_hash: + self.transcript_service.append_iteration_notice( + self._current_query_binary_hash, + str(self.current_chat_id), + "Approved agentic investigation plan", + metadata={"category": "plan_approved"}, + metadata_extra={ + "react_run_id": self._current_react_run_id, + "react_objective": self._current_react_objective, + "agent_phase": "plan_approved", + }, + ) + + provider = self.llm_factory.create_provider(self._pending_react_provider_config) + self._start_react_analysis( + self._current_react_objective or "", + self._pending_react_initial_context, + provider, + self._pending_react_mcp_tools, + self._pending_react_config or ReActConfig(max_iterations=15, reflection_enabled=True), + approved_plan=self._pending_react_plan, + ) + def _submit_agentic_query(self, query_text: str): """Handle agentic (ReAct) query submission""" log.log_info(f"Agentic query submitted: {query_text[:50]}...") @@ -3625,12 +3802,11 @@ Tool Usage Guidelines: if self.current_chat_id is None: self.new_chat() - # Set active state - self._react_active = True + # Set planning state. Tools are not initialized/executed until plan approval. + self._react_plan_pending = False self._current_react_run_id = react_run_id self._current_react_objective = query_text self._current_query_binary_hash = self._get_current_binary_hash() - self.view.set_query_running(True) self._active_stream_markdown = "" self._stream_base_html = "" self._context_snapshot = self._build_context_snapshot() @@ -3678,22 +3854,21 @@ Tool Usage Guidelines: + initial_context ) - # MCP tools are required for agentic mode - self.mcp_connection_manager.ensure_connections() - mcp_tools = self.mcp_connection_manager.get_available_tools_for_llm() - log.log_info(f"Agentic mode with {len(mcp_tools)} tools available") - - # Create provider instance - provider = self.llm_factory.create_provider(active_provider) - # Create ReAct config config = ReActConfig( max_iterations=15, reflection_enabled=True ) - # Start ReAct thread - self._start_react_analysis(query_text, initial_context, provider, mcp_tools, config) + self._pending_react_initial_context = initial_context + self._pending_react_provider_config = active_provider + self._pending_react_provider_type = provider_type + self._pending_react_mcp_tools = [] + self._pending_react_config = config + self._pending_react_refinements = [] + self._pending_react_plan = None + + self._start_react_planning(query_text, initial_context, active_provider) except Exception as e: error_msg = f"Exception in submit_agentic_query: {str(e)}" @@ -3716,9 +3891,17 @@ Current Offset: {context.get('offset_hex', 'N/A')}""" def _start_react_analysis(self, objective: str, initial_context: str, provider, mcp_tools: List[Dict[str, Any]], - config: ReActConfig): + config: ReActConfig, + approved_plan: Optional[str] = None): """Start the ReAct analysis thread""" log.log_info("Starting ReAct analysis thread") + self._react_active = True + self.view.set_query_running(True) + + if not mcp_tools: + self.mcp_connection_manager.ensure_connections() + mcp_tools = self.mcp_connection_manager.get_available_tools_for_llm() + log.log_info(f"Agentic mode with {len(mcp_tools)} tools available") # Reset streaming state for ReAct analysis self._reasoning_filter.reset() @@ -3738,7 +3921,8 @@ Current Offset: {context.get('offset_hex', 'N/A')}""" llm_provider=provider, mcp_orchestrator=self.mcp_orchestrator, mcp_tools=mcp_tools, - config=config + config=config, + approved_plan=approved_plan ) # Connect signals @@ -3941,6 +4125,8 @@ Current Offset: {context.get('offset_hex', 'N/A')}""" self._current_react_run_id = None self._current_react_objective = None self._current_react_final_status = None + self._pending_react_plan = None + self._pending_react_provider_config = None self._current_query_binary_hash = None self.view.set_query_running(False) @@ -3970,6 +4156,8 @@ Current Offset: {context.get('offset_hex', 'N/A')}""" self._current_react_run_id = None self._current_react_objective = None self._current_react_final_status = None + self._pending_react_plan = None + self._pending_react_provider_config = None self._current_query_binary_hash = None self.view.set_query_running(False) diff --git a/src/controllers/react_thread.py b/src/controllers/react_thread.py index 2f50ccc..280c414 100644 --- a/src/controllers/react_thread.py +++ b/src/controllers/react_thread.py @@ -44,6 +44,7 @@ class ReActOrchestratorThread(QThread): mcp_orchestrator, mcp_tools: List[Dict[str, Any]], config: ReActConfig = None, + approved_plan: Optional[str] = None, parent=None): """ Initialize the ReAct orchestrator thread. @@ -64,6 +65,7 @@ class ReActOrchestratorThread(QThread): self.mcp_orchestrator = mcp_orchestrator self.mcp_tools = mcp_tools self.config = config or ReActConfig() + self.approved_plan = approved_plan self.cancelled = False self._orchestrator: Optional[ReActOrchestrator] = None @@ -123,7 +125,8 @@ class ReActOrchestratorThread(QThread): # Run analysis result = await self._orchestrator.analyze( self.objective, - self.initial_context + self.initial_context, + approved_plan=self.approved_plan, ) return result diff --git a/src/services/react/react_orchestrator.py b/src/services/react/react_orchestrator.py index 7b8168a..c89832d 100644 --- a/src/services/react/react_orchestrator.py +++ b/src/services/react/react_orchestrator.py @@ -116,7 +116,8 @@ class ReActOrchestrator: self.conversation_history = cleaned_history - async def analyze(self, objective: str, initial_context: str = "") -> ReActResult: + async def analyze(self, objective: str, initial_context: str = "", + approved_plan: Optional[str] = None) -> ReActResult: """ Run ReAct analysis for the given objective. @@ -144,11 +145,16 @@ class ReActOrchestrator: log.log_info(f"ReActOrchestrator: Starting analysis for: {objective[:50]}...") try: - # Phase 1: Planning - if self.on_progress: - self.on_progress("Planning investigation...", 0) - - await self._run_planning(objective, initial_context) + # Phase 1: Planning. If the controller already got explicit user approval, + # seed the todo manager from that plan and skip a second planning call. + if approved_plan: + self.todo_manager.initialize_from_llm_response(approved_plan) + if self.on_todos_updated: + self.on_todos_updated(self.todo_manager.to_transcript_snapshot()) + else: + if self.on_progress: + self.on_progress("Planning investigation...", 0) + await self._run_planning(objective, initial_context) if self.cancelled: return ReActResult.cancelled() diff --git a/src/views/query_tab_view.py b/src/views/query_tab_view.py index e04633b..1e0ccd4 100644 --- a/src/views/query_tab_view.py +++ b/src/views/query_tab_view.py @@ -81,6 +81,8 @@ class QueryTabView(QWidget): agentic_enabled_changed = Signal(bool) accept_all_tools_changed = Signal(bool) approval_decision_requested = Signal(str) + plan_approved_requested = Signal() + plan_cancel_requested = Signal() transcript_link_clicked = Signal(str) history_entry_upsert_requested = Signal(int, str, str, str) # RLHF feedback signals @@ -111,6 +113,7 @@ class QueryTabView(QWidget): # Main text widget - HTML browser/Markdown editor self.create_main_text_widget() + self.create_plan_panel() self.create_approval_panel() # History table @@ -122,12 +125,13 @@ class QueryTabView(QWidget): # Add widgets to splitter self.splitter.addWidget(self.query_browser) self.splitter.addWidget(self.query_editor) + self.splitter.addWidget(self.plan_panel) self.splitter.addWidget(self.approval_panel) self.splitter.addWidget(self.history_table) self.splitter.addWidget(self.input_widget) # Set initial splitter sizes (give more space to main text and input) - self.splitter.setSizes([400, 400, 0, 80, 100]) + self.splitter.setSizes([400, 400, 0, 0, 80, 100]) layout.addWidget(self.splitter) @@ -343,9 +347,10 @@ class QueryTabView(QWidget): # Save history and input panel sizes before the swap old_sizes = self.splitter.sizes() - approval_size = old_sizes[2] if len(old_sizes) >= 3 else 0 - history_size = old_sizes[3] if len(old_sizes) >= 4 else 80 - input_size = old_sizes[4] if len(old_sizes) >= 5 else 100 + plan_size = old_sizes[2] if len(old_sizes) >= 3 else 0 + approval_size = old_sizes[3] if len(old_sizes) >= 4 else 0 + history_size = old_sizes[4] if len(old_sizes) >= 5 else 80 + input_size = old_sizes[5] if len(old_sizes) >= 6 else 100 if self.is_edit_mode: self.query_browser.hide() @@ -364,11 +369,11 @@ class QueryTabView(QWidget): # Restore: give all remaining space to the active text widget total = sum(self.splitter.sizes()) - active_size = total - approval_size - history_size - input_size + active_size = total - plan_size - approval_size - history_size - input_size if self.is_edit_mode: - self.splitter.setSizes([0, active_size, approval_size, history_size, input_size]) + self.splitter.setSizes([0, active_size, plan_size, approval_size, history_size, input_size]) else: - self.splitter.setSizes([active_size, 0, approval_size, history_size, input_size]) + self.splitter.setSizes([active_size, 0, plan_size, approval_size, history_size, input_size]) self.edit_mode_changed.emit(self.is_edit_mode) @@ -378,15 +383,16 @@ class QueryTabView(QWidget): return self.is_edit_mode = False old_sizes = self.splitter.sizes() - approval_size = old_sizes[2] if len(old_sizes) >= 3 else 0 - history_size = old_sizes[3] if len(old_sizes) >= 4 else 80 - input_size = old_sizes[4] if len(old_sizes) >= 5 else 100 + plan_size = old_sizes[2] if len(old_sizes) >= 3 else 0 + approval_size = old_sizes[3] if len(old_sizes) >= 4 else 0 + history_size = old_sizes[4] if len(old_sizes) >= 5 else 80 + input_size = old_sizes[5] if len(old_sizes) >= 6 else 100 self.query_editor.hide() self.query_browser.show() self.edit_save_button.setText("Edit") total = sum(self.splitter.sizes()) - active_size = total - approval_size - history_size - input_size - self.splitter.setSizes([active_size, 0, approval_size, history_size, input_size]) + active_size = total - plan_size - approval_size - history_size - input_size + self.splitter.setSizes([active_size, 0, plan_size, approval_size, history_size, input_size]) def on_submit_clicked(self): """Handle submit button click - toggles between submit and stop""" @@ -696,17 +702,28 @@ class QueryTabView(QWidget): self.approval_panel.show() self._restore_splitter_after_approval_change(approval_visible=True) + def set_pending_plan(self, plan_text: str = None): + if not plan_text: + self.plan_panel.hide() + self.plan_preview.setPlainText("") + self._restore_splitter_after_approval_change() + return + self.plan_preview.setPlainText(plan_text) + self.plan_panel.show() + self._restore_splitter_after_approval_change() + def _restore_splitter_after_approval_change(self, approval_visible: bool = False): sizes = self.splitter.sizes() - if len(sizes) < 5: + if len(sizes) < 6: return + plan_size = 170 if self.plan_panel.isVisible() else 0 approval_size = 96 if approval_visible else 0 - remaining = sum(sizes) - approval_size + remaining = sum(sizes) - plan_size - approval_size active_index = 1 if self.is_edit_mode else 0 - history_size = sizes[3] - input_size = sizes[4] + history_size = sizes[4] + input_size = sizes[5] active_size = max(0, remaining - history_size - input_size) - updated = [0, 0, approval_size, history_size, input_size] + updated = [0, 0, plan_size, approval_size, history_size, input_size] updated[active_index] = active_size self.splitter.setSizes(updated) @@ -843,3 +860,42 @@ class QueryTabView(QWidget): self.rlhf_feedback_requested.emit(False) elif ":" in url_str: self.transcript_link_clicked.emit(url_str) + def create_plan_panel(self): + self.plan_panel = QWidget() + self.plan_panel.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self.plan_panel.setStyleSheet( + "QWidget { border: 1px solid #4f8cc9; background: #303842; }" + "QLabel { border: none; color: #d8dee9; }" + "QPlainTextEdit { border: none; background: #2f3439; color: #d8dee9; padding: 4px; }" + "QPushButton { margin: 3px; padding: 3px 8px; }" + ) + panel_layout = QVBoxLayout() + panel_layout.setContentsMargins(6, 5, 6, 5) + panel_layout.setSpacing(4) + + self.plan_summary_label = QLabel("Review the proposed investigation plan. Submit refinement details below, or approve to run.") + self.plan_summary_label.setWordWrap(True) + self.plan_summary_label.setStyleSheet("font-size: 11px;") + panel_layout.addWidget(self.plan_summary_label) + + self.plan_preview = QPlainTextEdit() + self.plan_preview.setReadOnly(True) + self.plan_preview.setMaximumBlockCount(300) + self.plan_preview.setMaximumHeight(130) + self.plan_preview.setStyleSheet("font-family: monospace; font-size: 11px;") + panel_layout.addWidget(self.plan_preview) + + button_row = QHBoxLayout() + button_row.setContentsMargins(0, 0, 0, 0) + button_row.setSpacing(4) + self.approve_plan_button = QPushButton("Approve Plan") + self.cancel_plan_button = QPushButton("Cancel") + self.approve_plan_button.clicked.connect(self.plan_approved_requested.emit) + self.cancel_plan_button.clicked.connect(self.plan_cancel_requested.emit) + button_row.addWidget(self.approve_plan_button) + button_row.addWidget(self.cancel_plan_button) + button_row.addStretch() + panel_layout.addLayout(button_row) + + self.plan_panel.setLayout(panel_layout) + self.plan_panel.hide()