Skip to content

Commit d602cb9

Browse files
feat: ask user question sdk tool
1 parent af2c7c5 commit d602cb9

File tree

4 files changed

+142
-2
lines changed

4 files changed

+142
-2
lines changed

dreadnode/agent/tools/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
)
1717

1818
if t.TYPE_CHECKING:
19-
from dreadnode.agent.tools import execute, fs, memory, planning, reporting, tasking
19+
from dreadnode.agent.tools import execute, fs, interaction, memory, planning, reporting, tasking
2020

2121
__all__ = [
2222
"AnyTool",
@@ -30,6 +30,7 @@
3030
"discover_tools_on_obj",
3131
"execute",
3232
"fs",
33+
"interaction",
3334
"memory",
3435
"planning",
3536
"reporting",
@@ -38,7 +39,7 @@
3839
"tool_method",
3940
]
4041

41-
__lazy_submodules__: list[str] = ["fs", "planning", "reporting", "tasking", "execute", "memory"]
42+
__lazy_submodules__: list[str] = ["execute", "fs", "interaction", "memory", "planning", "reporting", "tasking"]
4243
__lazy_components__: dict[str, str] = {}
4344

4445

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import typing as t
2+
3+
from pydantic import BaseModel, Field
4+
5+
from dreadnode.agent.tools.base import tool
6+
7+
8+
class QuestionOption(BaseModel):
9+
label: str = Field(..., description="Display text for this option")
10+
description: str = Field(..., description="Explanation of this option")
11+
12+
13+
class Question(BaseModel):
14+
id: str = Field(..., description="Unique identifier for this question")
15+
question: str = Field(..., description="The question text")
16+
options: list[QuestionOption] = Field(..., min_length=2, max_length=4)
17+
multi_select: bool = Field(default=False)
18+
19+
20+
@tool(catch=True)
21+
def ask_user(
22+
questions: t.Annotated[
23+
list[dict[str, t.Any]],
24+
"List of questions to ask. Each has: id, question, options (list of {label, description}), multi_select (optional)",
25+
],
26+
) -> str:
27+
"""
28+
Ask the user one or more multiple-choice questions during execution.
29+
30+
Use this when you need user input to make decisions, clarify requirements,
31+
or get preferences that affect your work.
32+
33+
Each question should have 2-4 options. Users can select one option, or multiple
34+
if multi_select is true.
35+
36+
Returns a formatted string with the user's responses.
37+
"""
38+
from dreadnode import log_metric, log_output
39+
40+
parsed = [Question(**q) for q in questions]
41+
42+
print("\n" + "=" * 80)
43+
print("Agent needs your input:")
44+
print("=" * 80 + "\n")
45+
46+
responses: dict[str, str | list[str]] = {}
47+
48+
for q_num, q in enumerate(parsed, 1):
49+
print(f"Question {q_num}/{len(parsed)}: {q.question}\n")
50+
51+
for idx, opt in enumerate(q.options, 1):
52+
print(f" {idx}. {opt.label}")
53+
print(f" {opt.description}\n")
54+
55+
if q.multi_select:
56+
choice_str = input("Your choice(s) (comma-separated): ").strip()
57+
choices = [int(c.strip()) for c in choice_str.split(",")]
58+
selected: list[str] = [q.options[i - 1].label for i in choices]
59+
responses[q.id] = selected
60+
else:
61+
choice = int(input("Your choice: ").strip())
62+
selected_option: str = q.options[choice - 1].label
63+
responses[q.id] = selected_option
64+
65+
print()
66+
67+
print("=" * 80 + "\n")
68+
69+
log_output("user_questions", [q.model_dump() for q in parsed])
70+
log_output("user_responses", responses)
71+
log_metric("questions_asked", len(parsed))
72+
73+
result = "User responses:\n"
74+
for q_id, answer in responses.items():
75+
if isinstance(answer, list):
76+
result += f"{q_id}: {', '.join(answer)}\n"
77+
else:
78+
result += f"{q_id}: {answer}\n"
79+
80+
return result

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,6 @@ skip-magic-trailing-comma = false
192192
"dreadnode/transforms/language.py" = [
193193
"RUF001", # intentional use of ambiguous unicode characters for airt
194194
]
195+
"dreadnode/agent/tools/interaction.py" = [
196+
"T201", # print required for user interaction
197+
]

tests/test_interaction.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from unittest.mock import patch
2+
3+
from dreadnode.agent.tools.interaction import Question, QuestionOption, ask_user
4+
5+
6+
def test_question_model() -> None:
7+
q = Question(
8+
id="test",
9+
question="Test question?",
10+
options=[
11+
QuestionOption(label="Option 1", description="First option"),
12+
QuestionOption(label="Option 2", description="Second option"),
13+
],
14+
)
15+
assert q.id == "test"
16+
assert len(q.options) == 2
17+
assert q.multi_select is False
18+
19+
20+
def test_ask_user_single_choice() -> None:
21+
questions = [
22+
{
23+
"id": "choice",
24+
"question": "Pick one",
25+
"options": [
26+
{"label": "A", "description": "First"},
27+
{"label": "B", "description": "Second"},
28+
],
29+
}
30+
]
31+
32+
with patch("builtins.input", return_value="1"), patch("builtins.print"):
33+
result = ask_user(questions)
34+
35+
assert "choice: A" in result
36+
37+
38+
def test_ask_user_multi_choice() -> None:
39+
questions = [
40+
{
41+
"id": "choices",
42+
"question": "Pick multiple",
43+
"options": [
44+
{"label": "A", "description": "First"},
45+
{"label": "B", "description": "Second"},
46+
{"label": "C", "description": "Third"},
47+
],
48+
"multi_select": True,
49+
}
50+
]
51+
52+
with patch("builtins.input", return_value="1,3"), patch("builtins.print"):
53+
result = ask_user(questions)
54+
55+
assert "A" in result
56+
assert "C" in result

0 commit comments

Comments
 (0)