From c3ec776fb119352c77493c605e404396f0d1dfb7 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Tue, 3 Mar 2026 15:53:19 -0500 Subject: [PATCH 1/5] python ask user tool and rejection reason fixes Signed-off-by: Jet Chiang --- .../src/kagent/adk/_agent_executor.py | 40 +++- .../kagent-adk/src/kagent/adk/_approval.py | 13 +- .../src/kagent/adk/_memory_service.py | 2 + .../src/kagent/adk/tools/ask_user_tool.py | 111 ++++++++++ .../kagent-adk/src/kagent/adk/types.py | 4 + .../kagent-adk/tests/unittests/test_hitl.py | 205 +++++++++++++++++- .../src/kagent/core/a2a/__init__.py | 11 + .../src/kagent/core/a2a/_consts.py | 4 + .../kagent-core/src/kagent/core/a2a/_hitl.py | 76 +++++++ 9 files changed, 454 insertions(+), 12 deletions(-) create mode 100644 python/packages/kagent-adk/src/kagent/adk/tools/ask_user_tool.py diff --git a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py index c9be0dff5..a4ac5e280 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py +++ b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py @@ -41,8 +41,10 @@ KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_HITL_DECISION_TYPE_BATCH, TaskResultAggregator, + extract_ask_user_answers_from_message, extract_batch_decisions_from_message, extract_decision_from_message, + extract_rejection_reasons_from_message, get_kagent_metadata_key, ) from kagent.core.tracing._span_processor import ( @@ -397,6 +399,28 @@ def _process_hitl_decision( len(pending_confirmations), ) + # Check for ask-user answers — if present, build a single approved + # ToolConfirmation with the answers payload regardless of decision_type. + # The tool will use the payload and construct the user answer to the agent + ask_user_answers = extract_ask_user_answers_from_message(message) + if ask_user_answers is not None: + parts = [] + for fc_id in pending_confirmations: + confirmation = ToolConfirmation(confirmed=True, payload={"answers": ask_user_answers}) + parts.append( + genai_types.Part( + function_response=genai_types.FunctionResponse( + name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME, + id=fc_id, + response={"response": confirmation.model_dump_json()}, + ) + ) + ) + return parts + + # Extract optional rejection reasons from the message. + rejection_reasons = extract_rejection_reasons_from_message(message) + if decision == KAGENT_HITL_DECISION_TYPE_BATCH: # Batch mode: per-tool decisions batch_decisions = extract_batch_decisions_from_message(message) or {} @@ -405,7 +429,13 @@ def _process_hitl_decision( # Look up the per-tool decision using the original tool call ID tool_decision = batch_decisions.get(original_id, KAGENT_HITL_DECISION_TYPE_APPROVE) confirmed = tool_decision == KAGENT_HITL_DECISION_TYPE_APPROVE - confirmation = ToolConfirmation(confirmed=confirmed) + # Attach rejection reason if provided for this specific tool + payload: dict | None = None + if not confirmed and rejection_reasons: + reason = rejection_reasons.get(original_id) if original_id else None + if reason: + payload = {"rejection_reason": reason} + confirmation = ToolConfirmation(confirmed=confirmed, payload=payload) # Append a response for each tool call parts.append( genai_types.Part( @@ -420,7 +450,13 @@ def _process_hitl_decision( else: # Uniform mode: same decision for all pending tools confirmed = decision == KAGENT_HITL_DECISION_TYPE_APPROVE - confirmation = ToolConfirmation(confirmed=confirmed) + # Attach rejection reason if provided (uniform denial uses "*" sentinel) + payload = None + if not confirmed and rejection_reasons: + reason = rejection_reasons.get("*") + if reason: + payload = {"rejection_reason": reason} + confirmation = ToolConfirmation(confirmed=confirmed, payload=payload) return [ genai_types.Part( function_response=genai_types.FunctionResponse( diff --git a/python/packages/kagent-adk/src/kagent/adk/_approval.py b/python/packages/kagent-adk/src/kagent/adk/_approval.py index 1898a15a9..01f4f3b96 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_approval.py +++ b/python/packages/kagent-adk/src/kagent/adk/_approval.py @@ -31,7 +31,7 @@ def before_tool( tool: BaseTool, args: dict[str, Any], tool_context: ToolContext, - ) -> dict | None: + ) -> str | dict | None: tool_name = tool.name if tool_name not in tools_requiring_approval: return None # No approval needed, proceed normally @@ -42,9 +42,18 @@ def before_tool( logger.debug("Tool %s approved by user, proceeding", tool_name) return None # Approved — proceed with tool execution logger.debug("Tool %s rejected by user", tool_name) - return {"status": "rejected", "message": "Tool call was rejected by user."} + # Check for an optional rejection reason in the payload + # (the key "rejection_reason" is set by _agent_executor._process_hitl_decision) + payload = tool_context.tool_confirmation.payload or {} + reason = payload.get("rejection_reason", "") if isinstance(payload, dict) else "" + # __build_response_event wraps it as {"result": "..."} that LLM adapters expect + # ADK will skip executing the function if the before tool callback returns a response + if reason: + return f"Tool call was rejected by user. Reason: {reason}" + return "Tool call was rejected by user." # First invocation — request confirmation and block execution + # # This response is never sent to the LLM logger.debug("Tool %s requires approval, requesting confirmation", tool_name) tool_context.request_confirmation( hint=f"Tool '{tool_name}' requires approval before execution.", diff --git a/python/packages/kagent-adk/src/kagent/adk/_memory_service.py b/python/packages/kagent-adk/src/kagent/adk/_memory_service.py index 165ff019a..3cf0aa4f6 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_memory_service.py +++ b/python/packages/kagent-adk/src/kagent/adk/_memory_service.py @@ -333,6 +333,8 @@ async def _generate_embedding_async( litellm_model = f"ollama/{model_name}" elif provider == "vertex_ai": litellm_model = f"vertex_ai/{model_name}" + elif provider == "gemini": + litellm_model = f"gemini/{model_name}" try: is_batch = isinstance(input_data, list) diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/ask_user_tool.py b/python/packages/kagent-adk/src/kagent/adk/tools/ask_user_tool.py new file mode 100644 index 000000000..caaa90ef9 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/tools/ask_user_tool.py @@ -0,0 +1,111 @@ +"""Built-in tool for asking the user questions during agent execution. + +Uses the ADK-native ToolContext.request_confirmation() mechanism so that +the standard HITL event plumbing (adk_request_confirmation, long_running_tool_ids, +executor resume path) handles the interrupt and resume transparently. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from google.adk.tools.base_tool import BaseTool +from google.adk.tools.tool_context import ToolContext +from google.genai import types + +logger = logging.getLogger(__name__) + +# Schema for a single question object passed to ask_user. +_QUESTION_SCHEMA = types.Schema( + type=types.Type.OBJECT, + properties={ + "question": types.Schema( + type=types.Type.STRING, + description="The question text to display to the user.", + ), + "choices": types.Schema( + type=types.Type.ARRAY, + items=types.Schema(type=types.Type.STRING), + description=( + "Predefined answer choices shown as selectable chips. Leave empty for a free-text-only question." + ), + ), + "multiple": types.Schema( + type=types.Type.BOOLEAN, + description=("If true, the user can select multiple choices. Defaults to false (single-select)."), + ), + }, + required=["question"], +) + + +class AskUserTool(BaseTool): + """Built-in tool that lets the agent ask the user one or more questions. + + The tool uses the ADK ``request_confirmation`` mechanism to pause + execution and present the questions to the UI. On the resume path the + UI sends back ``ask_user_answers`` in the approval DataPart and the + executor injects them via ``ToolConfirmation.payload``. + + Because the interrupt is driven by ``request_confirmation``, this tool + does *not* need to be listed in ``tools_requiring_approval``. + """ + + def __init__(self) -> None: + super().__init__( + name="ask_user", + description=( + "Ask the user one or more questions and wait for their answers " + "before continuing. Use this when you need clarifying information, " + "preferences, or explicit confirmation from the user." + ), + ) + + def _get_declaration(self) -> types.FunctionDeclaration: + return types.FunctionDeclaration( + name=self.name, + description=self.description, + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "questions": types.Schema( + type=types.Type.ARRAY, + items=_QUESTION_SCHEMA, + description="List of questions to ask the user.", + ), + }, + required=["questions"], + ), + ) + + async def run_async( + self, + *, + args: dict[str, Any], + tool_context: ToolContext, + ) -> Any: + questions: list[dict] = args.get("questions", []) + + if tool_context.tool_confirmation is None: + # First invocation — pause execution and ask the user. + summary = "; ".join(q.get("question", "") for q in questions if q.get("question")) + tool_context.request_confirmation(hint=summary or "Questions for the user.") + logger.debug("ask_user: requesting confirmation with %d question(s)", len(questions)) + return {"status": "pending", "questions": questions} + + if tool_context.tool_confirmation.confirmed: + # Second invocation — the executor injected answers via payload. + payload = tool_context.tool_confirmation.payload or {} + answers: list[dict] = payload.get("answers", []) if isinstance(payload, dict) else [] + result = [] + for i, q in enumerate(questions): + ans = answers[i]["answer"] if i < len(answers) and "answer" in answers[i] else [] + result.append({"question": q.get("question", ""), "answer": ans}) + logger.debug("ask_user: returning %d answer(s)", len(result)) + return json.dumps(result) + + # User cancelled or rejected (should not normally happen for ask_user). + logger.debug("ask_user: confirmation not received, returning cancelled status") + return json.dumps({"status": "cancelled"}) diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index d4ad9723f..26b3df144 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -18,6 +18,7 @@ from kagent.adk._mcp_toolset import KAgentMcpToolset from kagent.adk.models._litellm import KAgentLiteLlm from kagent.adk.sandbox_code_executer import SandboxedLocalCodeExecutor +from kagent.adk.tools.ask_user_tool import AskUserTool from .models import AzureOpenAI as OpenAIAzure from .models import OpenAI as OpenAINative @@ -377,6 +378,9 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: code_executor = SandboxedLocalCodeExecutor() if self.execute_code else None model = _create_llm_from_model_config(self.model) + # Add built-in ask_user tool unconditionally — every agent can ask the user questions. + tools.append(AskUserTool()) + # Build before_tool_callback if any tools require approval before_tool_callback = make_approval_callback(tools_requiring_approval) if tools_requiring_approval else None diff --git a/python/packages/kagent-adk/tests/unittests/test_hitl.py b/python/packages/kagent-adk/tests/unittests/test_hitl.py index 49b44aaab..81dda3024 100644 --- a/python/packages/kagent-adk/tests/unittests/test_hitl.py +++ b/python/packages/kagent-adk/tests/unittests/test_hitl.py @@ -12,11 +12,13 @@ from kagent.adk._agent_executor import A2aAgentExecutor from kagent.adk._approval import make_approval_callback from kagent.core.a2a import ( + KAGENT_ASK_USER_ANSWERS_KEY, KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_HITL_DECISION_TYPE_BATCH, KAGENT_HITL_DECISION_TYPE_KEY, KAGENT_HITL_DECISION_TYPE_REJECT, KAGENT_HITL_DECISIONS_KEY, + KAGENT_HITL_REJECTION_REASONS_KEY, ) @@ -91,14 +93,14 @@ def test_approved_confirmation_allows_execution(self): assert result is None # Tool proceeds def test_rejected_confirmation_blocks_execution(self): - """When tool_confirmation.confirmed is False, tool returns rejection.""" + """When tool_confirmation.confirmed is False, tool returns rejection string.""" callback = make_approval_callback({"delete_file"}) tool = MockBaseTool("delete_file") confirmation = ToolConfirmation(confirmed=False) ctx = MockToolContext(tool_confirmation=confirmation) result = callback(tool, {"path": "/tmp"}, ctx) - assert result is not None - assert result["status"] == "rejected" + assert isinstance(result, str) + assert "rejected" in result def test_multiple_tools_mixed(self): """Only tools in the set request confirmation, others proceed.""" @@ -239,12 +241,23 @@ def test_find_pending_confirmations_missing_original_id(): assert pending == {"fc1": None} +def _make_simple_message(parts=None) -> Message: + """Create a minimal real Message for testing.""" + return Message( + role=Role.user, + message_id="test-msg", + task_id="test-task", + context_id="test-ctx", + parts=parts or [], + ) + + def test_process_hitl_decision_no_pending(): executor = A2aAgentExecutor(runner=MagicMock()) session = MagicMock(spec=Session) session.events = [] - parts = executor._process_hitl_decision(session, KAGENT_HITL_DECISION_TYPE_APPROVE, MagicMock(spec=Message)) + parts = executor._process_hitl_decision(session, KAGENT_HITL_DECISION_TYPE_APPROVE, _make_simple_message()) assert parts is None @@ -263,8 +276,7 @@ def test_process_hitl_decision_uniform_approve(): ) ] - message = MagicMock(spec=Message) - parts = executor._process_hitl_decision(session, KAGENT_HITL_DECISION_TYPE_APPROVE, message) + parts = executor._process_hitl_decision(session, KAGENT_HITL_DECISION_TYPE_APPROVE, _make_simple_message()) assert parts is not None assert len(parts) == 1 @@ -291,8 +303,7 @@ def test_process_hitl_decision_uniform_deny(): ) ] - message = MagicMock(spec=Message) - parts = executor._process_hitl_decision(session, KAGENT_HITL_DECISION_TYPE_REJECT, message) + parts = executor._process_hitl_decision(session, KAGENT_HITL_DECISION_TYPE_REJECT, _make_simple_message()) assert parts is not None assert len(parts) == 1 @@ -356,3 +367,181 @@ def test_process_hitl_decision_batch(): fr2 = parts_by_id["fc2"] resp2 = json.loads(fr2.response["response"]) assert resp2["confirmed"] is False + + +def test_process_hitl_decision_uniform_deny_with_reason(): + """Uniform deny with a rejection_reason populates ToolConfirmation.payload.""" + executor = A2aAgentExecutor(runner=MagicMock()) + session = MagicMock(spec=Session) + session.events = [ + MockEvent( + function_calls=[ + MockFunctionCall( + REQUEST_CONFIRMATION_FUNCTION_CALL_NAME, + "fc1", + args={"originalFunctionCall": {"id": "orig123"}}, + ) + ] + ) + ] + + message = Message( + role=Role.user, + message_id="msg1", + task_id="task1", + context_id="ctx1", + parts=[ + Part( + DataPart( + data={ + KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_REJECT, + "rejection_reason": "Too risky", + } + ) + ) + ], + ) + + parts = executor._process_hitl_decision(session, KAGENT_HITL_DECISION_TYPE_REJECT, message) + + assert parts is not None + assert len(parts) == 1 + fr = parts[0].function_response + resp = json.loads(fr.response["response"]) + assert resp["confirmed"] is False + assert resp["payload"]["rejection_reason"] == "Too risky" + + +def test_process_hitl_decision_batch_with_per_tool_reason(): + """Batch deny with per-tool rejection reasons populates ToolConfirmation.payload for denied tools.""" + executor = A2aAgentExecutor(runner=MagicMock()) + session = MagicMock(spec=Session) + session.events = [ + MockEvent( + function_calls=[ + MockFunctionCall( + REQUEST_CONFIRMATION_FUNCTION_CALL_NAME, + "fc1", + args={"originalFunctionCall": {"id": "orig123"}}, + ), + MockFunctionCall( + REQUEST_CONFIRMATION_FUNCTION_CALL_NAME, + "fc2", + args={"originalFunctionCall": {"id": "orig456"}}, + ), + ] + ) + ] + + message = Message( + role=Role.user, + message_id="msg1", + task_id="task1", + context_id="ctx1", + parts=[ + Part( + DataPart( + data={ + KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_BATCH, + KAGENT_HITL_DECISIONS_KEY: { + "orig123": KAGENT_HITL_DECISION_TYPE_APPROVE, + "orig456": KAGENT_HITL_DECISION_TYPE_REJECT, + }, + KAGENT_HITL_REJECTION_REASONS_KEY: { + "orig456": "Wrong environment", + }, + } + ) + ) + ], + ) + + parts = executor._process_hitl_decision(session, KAGENT_HITL_DECISION_TYPE_BATCH, message) + + assert parts is not None + assert len(parts) == 2 + + parts_by_id = {p.function_response.id: p.function_response for p in parts} + + # Approved tool — no payload + fr1 = parts_by_id["fc1"] + resp1 = json.loads(fr1.response["response"]) + assert resp1["confirmed"] is True + assert resp1.get("payload") is None + + # Denied tool — reason in payload + fr2 = parts_by_id["fc2"] + resp2 = json.loads(fr2.response["response"]) + assert resp2["confirmed"] is False + assert resp2["payload"]["rejection_reason"] == "Wrong environment" + + +def test_approval_callback_rejection_with_reason(): + """Rejected callback with a reason in payload returns a result containing that reason.""" + callback = make_approval_callback({"delete_file"}) + tool = MockBaseTool("delete_file") + confirmation = ToolConfirmation(confirmed=False, payload={"rejection_reason": "Dangerous path"}) + ctx = MockToolContext(tool_confirmation=confirmation) + result = callback(tool, {"path": "/tmp"}, ctx) + assert result is not None + assert "Dangerous path" in result + + +def test_approval_callback_rejection_without_reason(): + """Rejected callback without a reason returns generic rejection message in result key.""" + callback = make_approval_callback({"delete_file"}) + tool = MockBaseTool("delete_file") + confirmation = ToolConfirmation(confirmed=False) + ctx = MockToolContext(tool_confirmation=confirmation) + result = callback(tool, {"path": "/tmp"}, ctx) + assert result is not None + assert result == "Tool call was rejected by user." + + +# --------------------------------------------------------------------------- +# Ask-user tests +# --------------------------------------------------------------------------- + + +def test_process_hitl_decision_ask_user_answers(): + """Ask-user answers produce an approved ToolConfirmation with answers payload.""" + executor = A2aAgentExecutor(runner=MagicMock()) + session = MagicMock(spec=Session) + session.events = [ + MockEvent( + function_calls=[ + MockFunctionCall( + REQUEST_CONFIRMATION_FUNCTION_CALL_NAME, + "fc1", + args={"originalFunctionCall": {"id": "ask123"}}, + ) + ] + ) + ] + + answers = [{"answer": ["PostgreSQL"]}, {"answer": ["Auth", "Caching"]}] + message = Message( + role=Role.user, + message_id="msg1", + task_id="task1", + context_id="ctx1", + parts=[ + Part( + DataPart( + data={ + KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_APPROVE, + KAGENT_ASK_USER_ANSWERS_KEY: answers, + } + ) + ) + ], + ) + + parts = executor._process_hitl_decision(session, KAGENT_HITL_DECISION_TYPE_APPROVE, message) + + assert parts is not None + assert len(parts) == 1 + fr = parts[0].function_response + resp = json.loads(fr.response["response"]) + assert resp["confirmed"] is True + assert resp["payload"]["answers"] == answers diff --git a/python/packages/kagent-core/src/kagent/core/a2a/__init__.py b/python/packages/kagent-core/src/kagent/core/a2a/__init__.py index 8424e61ba..054b7abdb 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/__init__.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/__init__.py @@ -7,19 +7,24 @@ A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, A2A_DATA_PART_METADATA_TYPE_KEY, ADK_METADATA_KEY_PREFIX, + KAGENT_ASK_USER_ANSWERS_KEY, + KAGENT_ASK_USER_TOOL_NAME, KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_HITL_DECISION_TYPE_BATCH, KAGENT_HITL_DECISION_TYPE_DENY, KAGENT_HITL_DECISION_TYPE_KEY, KAGENT_HITL_DECISION_TYPE_REJECT, KAGENT_HITL_DECISIONS_KEY, + KAGENT_HITL_REJECTION_REASONS_KEY, get_kagent_metadata_key, read_metadata_value, ) from ._hitl import ( DecisionType, + extract_ask_user_answers_from_message, extract_batch_decisions_from_message, extract_decision_from_message, + extract_rejection_reasons_from_message, ) from ._requests import KAgentRequestContextBuilder from ._task_result_aggregator import TaskResultAggregator @@ -46,9 +51,15 @@ "KAGENT_HITL_DECISION_TYPE_REJECT", "KAGENT_HITL_DECISION_TYPE_BATCH", "KAGENT_HITL_DECISIONS_KEY", + "KAGENT_HITL_REJECTION_REASONS_KEY", + # Ask-user constants + "KAGENT_ASK_USER_TOOL_NAME", + "KAGENT_ASK_USER_ANSWERS_KEY", # HITL types "DecisionType", # HITL utilities "extract_decision_from_message", "extract_batch_decisions_from_message", + "extract_rejection_reasons_from_message", + "extract_ask_user_answers_from_message", ] diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_consts.py b/python/packages/kagent-core/src/kagent/core/a2a/_consts.py index 0eac1453e..88c41c965 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_consts.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_consts.py @@ -67,3 +67,7 @@ def read_metadata_value(metadata: dict | None, key: str, default=None): KAGENT_HITL_DECISION_TYPE_REJECT = "reject" KAGENT_HITL_DECISION_TYPE_BATCH = "batch" KAGENT_HITL_DECISIONS_KEY = "decisions" +KAGENT_HITL_REJECTION_REASONS_KEY = "rejection_reasons" + +KAGENT_ASK_USER_TOOL_NAME = "ask_user" +KAGENT_ASK_USER_ANSWERS_KEY = "ask_user_answers" diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_hitl.py b/python/packages/kagent-core/src/kagent/core/a2a/_hitl.py index 749becf8f..4ea0d3fd6 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_hitl.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_hitl.py @@ -13,12 +13,14 @@ ) from ._consts import ( + KAGENT_ASK_USER_ANSWERS_KEY, KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_HITL_DECISION_TYPE_BATCH, KAGENT_HITL_DECISION_TYPE_DENY, KAGENT_HITL_DECISION_TYPE_KEY, KAGENT_HITL_DECISION_TYPE_REJECT, KAGENT_HITL_DECISIONS_KEY, + KAGENT_HITL_REJECTION_REASONS_KEY, ) logger = logging.getLogger(__name__) @@ -138,3 +140,77 @@ def extract_batch_decisions_from_message(message: Message | None) -> dict[str, D return filtered or None return None + + +def extract_rejection_reasons_from_message(message: Message | None) -> dict[str, str] | None: + """Extract per-tool rejection reasons from A2A message. + + For uniform denials, the reason is extracted from the top-level + ``rejection_reason`` key and returned mapped to the sentinel key ``"*"``. + For batch denials, reasons are extracted from the ``rejection_reasons`` + dict (mapping original tool call IDs → reason strings). + + Args: + message: A2A message from user + + Returns: + Dict mapping original tool call IDs (or ``"*"`` for uniform) to + reason strings, or None if no reasons found. + """ + if not message or not message.parts: + return None + + for part in message.parts: + if not hasattr(part, "root"): + continue + + inner = part.root + + if isinstance(inner, DataPart): + data = inner.data + decision = data.get(KAGENT_HITL_DECISION_TYPE_KEY) + + if decision == KAGENT_HITL_DECISION_TYPE_BATCH: + reasons = data.get(KAGENT_HITL_REJECTION_REASONS_KEY) + if isinstance(reasons, dict): + filtered: dict[str, str] = {} + for call_id, reason in reasons.items(): + if isinstance(call_id, str) and isinstance(reason, str) and reason: + filtered[call_id] = reason + return filtered or None + elif decision in (KAGENT_HITL_DECISION_TYPE_DENY, KAGENT_HITL_DECISION_TYPE_REJECT): + reason = data.get("rejection_reason") + if isinstance(reason, str) and reason: + return {"*": reason} + + return None + + +def extract_ask_user_answers_from_message(message: Message | None) -> list[dict] | None: + """Extract ask-user answers from A2A message. + + When the UI sends an ask-user response, the DataPart contains an + ``ask_user_answers`` list of ``{answer: [...]}`` dicts. + + Args: + message: A2A message from user + + Returns: + List of answer dicts, or None if this is not an ask-user response. + """ + if not message or not message.parts: + return None + + for part in message.parts: + if not hasattr(part, "root"): + continue + + inner = part.root + + if isinstance(inner, DataPart): + data = inner.data + answers = data.get(KAGENT_ASK_USER_ANSWERS_KEY) + if isinstance(answers, list): + return answers + + return None From c7c4e4745ae0cb4c945c4447417fbf7b7f230f1f Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Tue, 3 Mar 2026 16:09:30 -0500 Subject: [PATCH 2/5] update hitl docs with enhancements Signed-off-by: Jet Chiang --- docs/architecture/human-in-the-loop.md | 128 +++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/docs/architecture/human-in-the-loop.md b/docs/architecture/human-in-the-loop.md index c49f246be..445fabea5 100644 --- a/docs/architecture/human-in-the-loop.md +++ b/docs/architecture/human-in-the-loop.md @@ -485,3 +485,131 @@ some while rejecting others: to the next LLM step without pausing. 7. The LLM receives the mixed results and generates an appropriate text response (e.g., summarising what succeeded and what was rejected). + +--- + +## Enhancement: Rejection Reasons + +Add an optional free-text reason to rejections. The reason travels through +the existing HITL plumbing using `ToolConfirmation.payload` from the `DataPart` from frotnend. + +Currently we use two custom keys to indicate rejection reason in the frontend payload and the ADK payload. +This handles parallel tool call rejection as well using the batch flow described above. + +In the before-tool callback (`_approval.py`) we will check if payload exists. +If so, we will append the rejection reason to the callback response to tell the model. + +--- + +## Enhancement: Ask-User Tool + +Discussed in issue #1415. +Why this is a good idea: https://www.atcyrus.com/stories/claude-code-ask-user-question-tool-guide + +### Design + +A built-in `ask_user` tool that: +- Lets the model pose **one or more questions** in a single call (batched). +- Each question can include **predefined choices** and a flag for whether + multiple selections are allowed. +- The user always has the option to **type a free-text answer** alongside + (or instead of) the predefined choices. +- Uses the **same `request_confirmation` HITL plumbing** as tool approval — + no new executor logic, event converter changes, or A2A protocol extensions. + +The tool is added **unconditionally** to every agent as a built-in tool, +similar to how memory tools are added. + +### Tool Schema + +```python +ask_user(questions=[ + { + "question": "Which database should I use?", + "choices": ["PostgreSQL", "MySQL", "SQLite"], + "multiple": False # single-select (default) + }, + { + "question": "Which features do you want?", + "choices": ["Auth", "Logging", "Caching"], + "multiple": True # multi-select + }, + { + "question": "Any additional requirements?", + "choices": [], # free-text only + "multiple": False + } +]) +``` + +### Tool Result (returned to model) + +```python +[ + {"question": "Which database should I use?", "answer": ["PostgreSQL"]}, + {"question": "Which features do you want?", "answer": ["Auth", "Caching"]}, + {"question": "Any additional requirements?", "answer": ["Add rate limiting"]} +] +``` + +### Data Flow + +- A special path to process ask user result is added to agent executor and HITL converter +- UI will be updated to recognize and display this +- This tool will be added to every agent by default + +```plaintext +LLM generates function call: ask_user(questions=[...]) + ▼ +AskUserTool.run_async (first invocation) + │ tool_context.tool_confirmation is None + │ Calls tool_context.request_confirmation(hint=) + │ Returns {"status": "pending", "questions": [...]} + ▼ +ADK generates adk_request_confirmation event (built-in) + │ originalFunctionCall = {name: "ask_user", args: {questions: [...]}, id: ...} + │ long_running_tool_ids set + ▼ +Event converter (existing, unchanged) + │ Converts to DataPart: {type: "function_call", is_long_running: true} + │ Sets TaskState.input_required + ▼ +Executor event loop + │ Breaks on long_running_tool_ids (existing logic) + ▼ +UI detects input_required + adk_request_confirmation + │ Sees originalFunctionCall.name === "ask_user" + │ Renders AskUserDisplay instead of ToolApprovalRequest card + ▼ +AskUserDisplay renders: + │ For each question: + │ - Header (bold, short) + question text + │ - Choice buttons as toggleable chips (single or multi-select) + │ - Free-text input always visible below choices + │ Single Submit button at the bottom + ▼ +User selects choices / types answers, clicks Submit + │ UI sends DataPart: + │ {decision_type: "approve", + │ ask_user_answers: [ + │ {answer: ["PostgreSQL"]}, + │ {answer: ["Auth", "Caching"]}, + │ {answer: ["Add rate limiting"]} + │ ]} + ▼ +Executor resume path + │ Sees decision_type: "approve" + ask_user_answers present + │ Constructs ToolConfirmation(confirmed=True, + │ payload={"answers": [...]}) + ▼ +ADK replays ask_user tool call + ▼ +AskUserTool.run_async (second invocation) + │ tool_context.tool_confirmation.confirmed is True + │ Reads payload["answers"] + │ Zips answers with original questions + │ Returns JSON: [{question: "...", answer: [...]}, ...] + ▼ +LLM receives the answers as the function response + │ Continues execution with user's input +``` From 2e23d9bdc236d459d0892d7186b51b17be170449 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Tue, 3 Mar 2026 16:25:27 -0500 Subject: [PATCH 3/5] ui Signed-off-by: Jet Chiang --- ui/src/components/ToolDisplay.tsx | 55 +++++- ui/src/components/chat/AskUserDisplay.tsx | 188 +++++++++++++++++++++ ui/src/components/chat/ChatInterface.tsx | 109 +++++++++++- ui/src/components/chat/ChatMessage.tsx | 22 ++- ui/src/components/chat/ToolCallDisplay.tsx | 4 +- ui/src/lib/messageHandlers.ts | 41 ++++- 6 files changed, 397 insertions(+), 22 deletions(-) create mode 100644 ui/src/components/chat/AskUserDisplay.tsx diff --git a/ui/src/components/ToolDisplay.tsx b/ui/src/components/ToolDisplay.tsx index 20d335df5..163bed29b 100644 --- a/ui/src/components/ToolDisplay.tsx +++ b/ui/src/components/ToolDisplay.tsx @@ -3,6 +3,7 @@ import { FunctionCall } from "@/types"; import { ScrollArea } from "@radix-ui/react-scroll-area"; import { FunctionSquare, CheckCircle, Clock, Code, ChevronUp, ChevronDown, Loader2, Text, Check, Copy, AlertCircle, ShieldAlert } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; export type ToolCallStatus = "requested" | "executing" | "completed" | "pending_approval" | "approved" | "rejected"; @@ -18,7 +19,7 @@ interface ToolDisplayProps { /** When true, the card is in a "decided but not yet submitted" state (batch flow). */ isDecided?: boolean; onApprove?: () => void; - onReject?: () => void; + onReject?: (reason?: string) => void; } const ToolDisplay = ({ call, result, status = "requested", isError = false, isDecided = false, onApprove, onReject }: ToolDisplayProps) => { @@ -26,6 +27,8 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe const [areResultsExpanded, setAreResultsExpanded] = useState(false); const [isCopied, setIsCopied] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const [showRejectForm, setShowRejectForm] = useState(false); + const [rejectionReason, setRejectionReason] = useState(""); const hasResult = result !== undefined; @@ -47,12 +50,25 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe onApprove(); }; - const handleReject = async () => { + /** Show the rejection reason form instead of immediately rejecting. */ + const handleRejectClick = () => { + setShowRejectForm(true); + }; + + /** Confirm rejection — submits with optional reason. */ + const handleRejectConfirm = async () => { if (!onReject) { return; } + setShowRejectForm(false); setIsSubmitting(true); - onReject(); + onReject(rejectionReason.trim() || undefined); + }; + + /** Cancel the rejection form — go back to Approve/Reject buttons. */ + const handleRejectCancel = () => { + setShowRejectForm(false); + setRejectionReason(""); }; // Define UI elements based on status @@ -165,7 +181,7 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe {/* Approval buttons — hidden when decided (batch) or submitting */} - {status === "pending_approval" && !isSubmitting && !isDecided && ( + {status === "pending_approval" && !isSubmitting && !isDecided && !showRejectForm && (
+
+
+ )} + + {/* Rejection reason form — shown after clicking Reject */} + {status === "pending_approval" && !isSubmitting && !isDecided && showRejectForm && ( +
+