Files
EvoScientist-Multi/EvoScientist/middleware/ask_user.py
T
Yuyue Zhao 4505300c8d feat: Add **More Effort** code generation mode (#118)
* feat: Add **More Effort** code generation mode

* feat: Enhance code generation mode selection and update documentation

---------

Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
2026-03-28 18:17:47 +00:00

421 lines
15 KiB
Python

"""Ask user middleware for agent-initiated interactive questions.
Enables the agent to proactively ask the user for clarification during
research workflows. The middleware registers an ``ask_user`` tool that
uses LangGraph ``interrupt()`` to pause the graph; the UI layer collects
answers and resumes with ``Command(resume={...})``.
Ported from upstream DeepAgents ``AskUserMiddleware`` with a system
prompt tailored for scientific research contexts.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from typing import NotRequired
from langchain.agents.middleware.types import (
AgentMiddleware,
ContextT,
ModelRequest,
ModelResponse,
ResponseT,
)
from langchain.tools import InjectedToolCallId
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.types import Command, interrupt
from pydantic import BeforeValidator, Field
from typing_extensions import TypedDict
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------
class Choice(TypedDict):
"""A single choice option for a multiple choice question."""
value: Annotated[str, Field(description="The display label for this choice.")]
class Question(TypedDict):
"""A question to ask the user."""
question: Annotated[str, Field(description="The question text to display.")]
type: Annotated[
Literal["text", "multiple_choice"],
Field(
description=(
"Question type. 'text' for free-form input, 'multiple_choice' for "
"predefined options."
)
),
]
choices: NotRequired[
Annotated[
list[Choice],
Field(
description=(
"Options for multiple_choice questions. An 'Other' free-form "
"option is always appended automatically."
)
),
]
]
required: NotRequired[
Annotated[
bool,
Field(
description="Whether the user must answer. Defaults to true if omitted."
),
]
]
def _coerce_questions_list(v: Any) -> Any:
"""Accept a JSON string and parse it to a list.
LLMs sometimes serialize the ``questions`` argument as a JSON string
instead of a native list. This before-validator transparently handles
that case so the tool invocation succeeds on the first try.
"""
if isinstance(v, str):
try:
parsed = json.loads(v)
if isinstance(parsed, list):
return parsed
except (json.JSONDecodeError, TypeError):
pass
return v
# Annotated type for the tool parameter — schema stays ``list[Question]``
# for the LLM, but runtime accepts JSON strings too.
QuestionsList = Annotated[list[Question], BeforeValidator(_coerce_questions_list)]
class AskUserRequest(TypedDict):
"""Request payload sent via interrupt when asking the user questions."""
type: Literal["ask_user"]
questions: list[Question]
tool_call_id: str
class AskUserAnswered(TypedDict):
"""Widget result when the user submits answers."""
type: Literal["answered"]
"""Discriminator tag, always ``'answered'``."""
answers: list[str]
"""User-provided answers, one per question."""
class AskUserCancelled(TypedDict):
"""Widget result when the user cancels the prompt."""
type: Literal["cancelled"]
"""Discriminator tag, always ``'cancelled'``."""
# Discriminated union for the ask_user widget Future result.
AskUserWidgetResult = AskUserAnswered | AskUserCancelled
# ---------------------------------------------------------------------------
# Prompts (research-tailored)
# ---------------------------------------------------------------------------
ASK_USER_TOOL_DESCRIPTION = """\
Ask the user one or more questions when you need clarification or specific input
before proceeding with a research task.
Each question can be:
- "text": Free-form text response
- "multiple_choice": User selects from predefined options (an "Other" option is always appended)
Use when: dataset/model/framework selection is ambiguous, experiment parameters unclear,
research scope needs clarification, significant plan needs confirmation,
resource estimation before heavy compute, or execution failures need user-guided recovery.
Do NOT use for trivial decisions or questions answerable from context/memory/web search."""
ASK_USER_SYSTEM_PROMPT = """\
## `ask_user` — Interactive Clarification Tool
You have access to the `ask_user` tool to ask the user questions when you need
information that cannot be determined from the conversation, loaded skills,
or available tools.
### When to use `ask_user`:
- **Dataset or benchmark selection**: "Which dataset should I use: CIFAR-10, ImageNet, or a custom dataset?"
- **Experiment parameters**: "What GPU memory budget should I target? What batch size range is acceptable?"
- **Research scope**: "Should I focus on accuracy improvements or inference speed?"
- **Methodology choice**: "For the baseline comparison, should I reimplement from the paper or use the official repo?"
- **Paper or report preferences**: "Which venue format should I target: NeurIPS, ICML, or ICLR?"
- **Ambiguous instructions**: When the user's request has multiple valid interpretations
- **Resource constraints**: When the approach depends on available compute, time, or data
- **Code generation mode**: When an iterative-coding skill (e.g. `experiment-iterative-coder`) is installed, ask the user which mode to use before delegating code tasks
### Resource & execution awareness (`ask_user` is especially valuable here):
- **Pre-execution estimation**: Before heavy compute (training, large-scale eval),
estimate time/memory/cost and confirm. E.g. "Training needs ~2h and ~16GB GPU.
Proceed, or reduce model size?"
- **Timeout & failure recovery**: When a command times out (exit code 124) or fails
with OOM/CUDA errors, present recovery options. E.g. "Training timed out. Options:
(A) run in background, (B) reduce epochs, (C) switch to smaller model"
- **Intermediate checkpoints**: When results diverge from expectations, ask before
continuing. E.g. "Baseline accuracy 62% vs expected 80%. Investigate or proceed?"
- **Workflow mode selection**: When multiple execution strategies are available
(e.g. single-pass vs iterative refinement via `experiment-iterative-coder`),
let the user choose before committing to a path
### When NOT to use `ask_user`:
- Simple yes/no decisions — proceed with your best judgment
- Information already provided in the conversation or memory
- Trivial choices that don't meaningfully affect outcomes
- Questions you can answer by searching with `tavily_search` or reading files
- During sub-agent execution (only the main agent should ask the user)
### Guidelines:
- Be concise and specific — avoid vague, open-ended questions
- Use `multiple_choice` when there are 2-5 clear options
- Use `text` for open-ended input (preferred language, custom parameters, etc.)
- Group related questions into a **single** `ask_user` call (max 5 questions)
- Never ask more than once per decision point — respect the user's time
- After receiving answers, summarize what you understood before proceeding"""
# ---------------------------------------------------------------------------
# Validation & parsing
# ---------------------------------------------------------------------------
def _validate_questions(questions: list[Question]) -> None:
"""Validate ask_user question structure before interrupting.
Raises:
ValueError: If the questions list or an individual question is invalid.
"""
if not questions:
msg = "ask_user requires at least one question"
raise ValueError(msg)
for q in questions:
question_text = q.get("question")
if not isinstance(question_text, str) or not question_text.strip():
msg = "ask_user questions must have non-empty 'question' text"
raise ValueError(msg)
question_type = q.get("type")
if question_type not in {"text", "multiple_choice"}:
msg = f"unsupported ask_user question type: {question_type!r}"
raise ValueError(msg)
if question_type == "multiple_choice" and not q.get("choices"):
msg = (
f"multiple_choice question "
f"{q.get('question')!r} requires a "
f"non-empty 'choices' list"
)
raise ValueError(msg)
if question_type == "text" and q.get("choices"):
msg = f"text question {q.get('question')!r} must not define 'choices'"
raise ValueError(msg)
def _parse_answers(
response: object,
questions: list[Question],
tool_call_id: str,
) -> Command[Any]:
"""Parse an interrupt response into a ``Command`` with a ``ToolMessage``.
Supports explicit status signaling from the adapter:
- ``answered`` (default): consume provided ``answers``
- ``cancelled``: synthesize ``(cancelled)`` answers
- ``error``: synthesize ``(error: ...)`` answers
Malformed payloads are converted into explicit error answers instead of
silently defaulting to ``(no answer)``.
"""
status: str = "answered"
error_text: str | None = None
answers: list[str]
if not isinstance(response, dict):
logger.error(
"ask_user received malformed resume payload "
"(expected dict, got %s); returning explicit error answers",
type(response).__name__,
)
answers = []
status = "error"
error_text = "invalid ask_user response payload"
else:
response_dict = cast("dict[str, Any]", response)
response_status = response_dict.get("status")
if isinstance(response_status, str):
status = response_status
if "answers" not in response_dict:
if status == "answered":
logger.error(
"ask_user received resume payload without 'answers'; "
"returning explicit error answers"
)
answers = []
status = "error"
error_text = "missing ask_user answers payload"
else:
answers = []
else:
raw_answers = response_dict["answers"]
if isinstance(raw_answers, list):
answers = [str(answer) for answer in raw_answers]
else:
logger.error(
"ask_user received non-list 'answers' payload (%s); "
"returning explicit error answers",
type(raw_answers).__name__,
)
answers = []
status = "error"
error_text = "invalid ask_user answers payload"
if status == "error":
response_error = response_dict.get("error")
if isinstance(response_error, str) and response_error:
error_text = response_error
elif status == "cancelled":
answers = ["(cancelled)" for _ in questions]
elif status == "answered":
if len(answers) != len(questions):
logger.warning(
"ask_user answer count mismatch: expected %d, got %d",
len(questions),
len(answers),
)
else:
logger.error(
"ask_user received unknown status %r; returning explicit error answers",
status,
)
answers = []
status = "error"
error_text = "invalid ask_user response status"
if status == "error":
detail = error_text or "ask_user interaction failed"
answers = [f"(error: {detail})" for _ in questions]
formatted_answers = []
for i, q in enumerate(questions):
answer = answers[i] if i < len(answers) else "(no answer)"
formatted_answers.append(f"Q: {q['question']}\nA: {answer}")
result_text = "\n\n".join(formatted_answers)
return Command(
update={
"messages": [ToolMessage(result_text, tool_call_id=tool_call_id)],
}
)
# ---------------------------------------------------------------------------
# Middleware class
# ---------------------------------------------------------------------------
class AskUserMiddleware(AgentMiddleware[Any, ContextT, ResponseT]):
"""Middleware that provides an ``ask_user`` tool for interactive questioning.
Adds an ``ask_user`` tool that allows the main agent to ask the user
questions during execution. Questions can be free-form text or multiple
choice. The tool uses LangGraph ``interrupt()`` to pause execution and
wait for user input.
"""
def __init__(
self,
*,
system_prompt: str = ASK_USER_SYSTEM_PROMPT,
tool_description: str = ASK_USER_TOOL_DESCRIPTION,
) -> None:
super().__init__()
self.system_prompt = system_prompt
self.tool_description = tool_description
@tool(description=self.tool_description)
def _ask_user(
questions: QuestionsList,
tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command[Any]:
"""Ask the user one or more questions."""
_validate_questions(questions)
ask_request = AskUserRequest(
type="ask_user",
questions=questions,
tool_call_id=tool_call_id,
)
response = interrupt(ask_request)
return _parse_answers(response, questions, tool_call_id)
_ask_user.name = "ask_user"
self.tools = [_ask_user]
def wrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[[ModelRequest[ContextT]], ModelResponse[ResponseT]],
) -> ModelResponse[ResponseT] | AIMessage:
"""Inject the ask_user system prompt."""
if request.system_message is not None:
new_system_content = [
*request.system_message.content_blocks,
{"type": "text", "text": f"\n\n{self.system_prompt}"},
]
else:
new_system_content = [{"type": "text", "text": self.system_prompt}]
new_system_message = SystemMessage(
content=cast("list[str | dict[str, str]]", new_system_content)
)
return handler(request.override(system_message=new_system_message))
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT] | AIMessage:
"""Inject the ask_user system prompt (async)."""
if request.system_message is not None:
new_system_content = [
*request.system_message.content_blocks,
{"type": "text", "text": f"\n\n{self.system_prompt}"},
]
else:
new_system_content = [{"type": "text", "text": self.system_prompt}]
new_system_message = SystemMessage(
content=cast("list[str | dict[str, str]]", new_system_content)
)
return await handler(request.override(system_message=new_system_message))