Add ReAct plan approval step.

This commit is contained in:
Jason Tang
2026-05-25 18:34:00 -04:00
parent 9555cb9db5
commit 44c0c16fdb
5 changed files with 293 additions and 40 deletions
+1 -1
View File
@@ -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": {
+203 -15
View File
@@ -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)
+4 -1
View File
@@ -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
+12 -6
View File
@@ -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()
+73 -17
View File
@@ -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()