fix/cli hitl (#202)

* feat(display): enhance approval prompt with questionary for better navigation

* feat(cli): add HumanInTheLoopMiddleware for user approval in main agent
This commit is contained in:
Xi Zhang
2026-05-02 01:54:40 +02:00
committed by GitHub
parent 0d8ac4f24b
commit 29e7fd383d
2 changed files with 39 additions and 18 deletions
+7 -7
View File
@@ -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})
+32 -11
View File
@@ -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