feat(question): implement AskUserQuestion tool support

- Add question card builder and answer handling in feishu.py
- Extend SDKSession with pending question state and answer method
- Update card callback handler to support question answers
- Add test cases for question flow and card responses
- Document usage with test_can_use_tool_ask.py example
This commit is contained in:
Yuyao Huang
2026-04-02 08:52:50 +08:00
parent 26746335c4
commit 72ebf3b75d
8 changed files with 542 additions and 120 deletions
+6
View File
@@ -162,6 +162,12 @@ class SessionManager:
if session and session.sdk_session:
await session.sdk_session.approve(approved)
async def answer_question(self, conv_id: str, answers: dict[str, str]) -> None:
"""Resolve a pending AskUserQuestion with user's answers."""
session = self._sessions.get(conv_id)
if session and session.sdk_session:
await session.sdk_session.answer_question(answers)
# --- Close, list, permission ---
async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool:
+68 -9
View File
@@ -48,6 +48,7 @@ class SessionProgress:
last_result: str = ""
error: str = ""
pending_approval: str = "" # non-empty → waiting for approval, value is tool description
pending_question: dict | None = None # non-None → waiting for user answer to AskUserQuestion
class SDKSession:
@@ -97,6 +98,10 @@ class SDKSession:
self._pending_approval: asyncio.Future | None = None
self._pending_approval_desc: str = ""
# AskUserQuestion mechanism
self._pending_question: asyncio.Future | None = None
self._pending_question_data: dict | None = None # {questions: [...], conv_id: ...}
async def start(self) -> None:
"""Create and connect the ClaudeSDKClient, start the message loop."""
from agent.sdk_hooks import build_hooks
@@ -169,6 +174,7 @@ class SDKSession:
last_result=self._last_result[:1000],
error=self._error,
pending_approval=self._pending_approval_desc,
pending_question=self._pending_question_data,
)
async def interrupt(self) -> None:
@@ -189,6 +195,15 @@ class SDKSession:
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result(approved)
async def answer_question(self, answers: dict[str, str]) -> None:
"""Resolve a pending AskUserQuestion with user's selected answers.
Args:
answers: maps question text → selected option label.
"""
if self._pending_question and not self._pending_question.done():
self._pending_question.set_result(answers)
async def close(self) -> None:
"""Disconnect and clean up."""
if self._message_loop_task and not self._message_loop_task.done():
@@ -280,15 +295,64 @@ class SDKSession:
async def _permission_callback(
self, tool_name: str, input_data: dict, context: ToolPermissionContext
) -> PermissionResult:
"""can_use_tool — send approval card to Feishu, wait for card callback."""
"""can_use_tool — route to question card or approval card based on tool type."""
# Auto-allow read-only tools
if tool_name in ("Read", "Glob", "Grep", "WebSearch", "WebFetch"):
return PermissionResultAllow()
# AskUserQuestion: show options to user, collect answer, return via updated_input
if tool_name == "AskUserQuestion":
return await self._handle_ask_user_question(input_data)
if not self.chat_id:
return PermissionResultAllow()
# Send approval card
# Regular tools: approval flow
return await self._handle_tool_approval(tool_name, input_data)
async def _handle_ask_user_question(self, input_data: dict) -> PermissionResult:
"""Handle AskUserQuestion: send question card, wait for answer, return updated_input."""
if not self.chat_id:
return PermissionResultAllow()
questions = input_data.get("questions", [])
if not questions:
return PermissionResultAllow()
from bot.feishu import send_card, build_question_card
# Build and send question card
self._pending_question_data = {"questions": questions, "conv_id": self.conv_id}
card = build_question_card(
conv_id=self.conv_id,
questions=questions,
)
await send_card(self.chat_id, "chat_id", card)
# Wait for user's answer (via card callback or text reply)
loop = asyncio.get_running_loop()
self._pending_question = loop.create_future()
try:
answers = await asyncio.wait_for(
self._pending_question, timeout=APPROVAL_TIMEOUT
)
except asyncio.TimeoutError:
answers = {}
from bot.feishu import send_markdown
await send_markdown(self.chat_id, "chat_id", "⏰ 问题超时,已跳过。")
finally:
self._pending_question_data = None
# Pre-fill answers in the tool input
modified_input = dict(input_data)
if "answers" not in modified_input or not isinstance(modified_input.get("answers"), dict):
modified_input["answers"] = {}
modified_input["answers"].update(answers)
return PermissionResultAllow(updated_input=modified_input)
async def _handle_tool_approval(self, tool_name: str, input_data: dict) -> PermissionResult:
"""Handle regular tool approval: send approval card, wait for approve/deny."""
from bot.feishu import send_card, build_approval_card
summary = self._format_tool_summary(tool_name, input_data)
@@ -302,7 +366,6 @@ class SDKSession:
)
await send_card(self.chat_id, "chat_id", card)
# Wait for card callback or text reply y/n
loop = asyncio.get_running_loop()
self._pending_approval = loop.create_future()
try:
@@ -312,18 +375,14 @@ class SDKSession:
except asyncio.TimeoutError:
approved = False
from bot.feishu import send_markdown
await send_markdown(self.chat_id, "chat_id", "⏰ 审批超时,已自动拒绝。")
finally:
self._pending_approval_desc = ""
from agent.audit import log_permission_decision
log_permission_decision(
conv_id=self.conv_id,
tool_name=tool_name,
tool_input=input_data,
approved=approved,
conv_id=self.conv_id, tool_name=tool_name,
tool_input=input_data, approved=approved,
)
if approved:
return PermissionResultAllow()