diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index f01e299..3a57630 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -23,7 +23,7 @@ import os from datetime import datetime from pathlib import Path -from langchain.agents.middleware import AgentMiddleware +from langchain.agents.middleware import AgentMiddleware, HumanInTheLoopMiddleware from . import paths as _paths_mod from .config import apply_config_to_env, get_effective_config @@ -503,16 +503,16 @@ def create_cli_agent( mw.insert(0, AskUserMiddleware()) + # HITL on main agent only — passing `interrupt_on=` to create_deep_agent + # would propagate it to every subagent, breaking parallel execute calls + # (multi-pending-interrupt LangGraph error). + if not cfg.auto_approve: + mw.append(HumanInTheLoopMiddleware(interrupt_on={"execute": True})) + # Re-load MCP tools from current config (picks up /mcp add changes) kwargs = load_mcp_and_build_kwargs(be, mw, on_mcp_progress=on_mcp_progress) - # HITL: gate shell execution for user approval - _interrupt_on: dict[str, bool] | None = None - if not cfg.auto_approve: - _interrupt_on = {"execute": True} - return create_deep_agent( **kwargs, checkpointer=checkpointer, - interrupt_on=_interrupt_on, ).with_config({"recursion_limit": 1000}) diff --git a/EvoScientist/stream/display.py b/EvoScientist/stream/display.py index 818e017..e210905 100644 --- a/EvoScientist/stream/display.py +++ b/EvoScientist/stream/display.py @@ -977,9 +977,18 @@ def _prompt_hitl_approval(action_requests: list) -> list[dict] | None: """Display approval prompt and get user decision. Returns list of decisions if approved, None if rejected. + + Uses ``questionary.select()`` for arrow-key navigation, matching the + style used by ``_resolve_ask_user_prompt``. Imports are lazy so the + auto-approve / shell-allow-list fast paths in ``_resolve_hitl_approval`` + don't pay for them. """ global _session_auto_approve + import questionary # type: ignore[import-untyped] + + from ..cli.widgets.thread_selector import PICKER_STYLE as _PICKER_STYLE + console.print() panel_text = Text() for i, req in enumerate(action_requests): @@ -993,10 +1002,6 @@ def _prompt_hitl_approval(action_requests: list) -> list[dict] | None: if panel_text.plain: panel_text.append("\n") panel_text.append(f" {i + 1}. {desc}", style="yellow") - panel_text.append("\n\n") - panel_text.append( - " [1] Approve [2] Reject [3] Approve all (session)", style="dim" - ) console.print( Panel( @@ -1007,21 +1012,37 @@ def _prompt_hitl_approval(action_requests: list) -> list[dict] | None: ) ) + n = len(action_requests) + if n <= 1: + approve_label = "Approve" + reject_label = "Reject" + else: + approve_label = f"Approve all {n}" + reject_label = f"Reject all {n}" + auto_label = "Approve all (session)" + try: - choice = input(" Choose [1/2/3, Enter=Approve]: ").strip() or "1" + selected = questionary.select( + "Approval required", + choices=[approve_label, reject_label, auto_label], + style=_PICKER_STYLE, + ).ask() except (EOFError, KeyboardInterrupt): console.print("[dim] Rejected.[/dim]") return None - if choice == "1": - return [{"type": "approve"} for _ in action_requests] - elif choice == "3": - _session_auto_approve = True - return [{"type": "approve"} for _ in action_requests] - else: + if selected is None: # Ctrl+C inside questionary console.print("[dim] Rejected.[/dim]") return None + if selected == approve_label: + return [{"type": "approve"} for _ in action_requests] + if selected == auto_label: + _session_auto_approve = True + return [{"type": "approve"} for _ in action_requests] + console.print("[dim] Rejected.[/dim]") + return None + # --------------------------------------------------------------------------- # Async-to-sync bridge