Skip to content

Commit 92f6283

Browse files
feat: human-in-the-loop (user input) (#1420)
- Ask user questions tool #1415 (added by default) - Enhancements to the HITL logic (regarding rejection) --------- Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
1 parent e67e1ac commit 92f6283

16 files changed

Lines changed: 978 additions & 38 deletions

File tree

docs/architecture/human-in-the-loop.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,3 +485,131 @@ some while rejecting others:
485485
to the next LLM step without pausing.
486486
7. The LLM receives the mixed results and generates an appropriate text
487487
response (e.g., summarising what succeeded and what was rejected).
488+
489+
---
490+
491+
## Enhancement: Rejection Reasons
492+
493+
Add an optional free-text reason to rejections. The reason travels through
494+
the existing HITL plumbing using `ToolConfirmation.payload` from the `DataPart` from frontend.
495+
496+
Currently we use two custom keys to indicate rejection reason in the frontend payload and the ADK payload.
497+
This handles parallel tool call rejection as well using the batch flow described above.
498+
499+
In the before-tool callback (`_approval.py`) we will check if payload exists.
500+
If so, we will append the rejection reason to the callback response to tell the model.
501+
502+
---
503+
504+
## Enhancement: Ask-User Tool
505+
506+
Discussed in issue #1415.
507+
Why this is a good idea: https://www.atcyrus.com/stories/claude-code-ask-user-question-tool-guide
508+
509+
### Design
510+
511+
A built-in `ask_user` tool that:
512+
- Lets the model pose **one or more questions** in a single call (batched).
513+
- Each question can include **predefined choices** and a flag for whether
514+
multiple selections are allowed.
515+
- The user always has the option to **type a free-text answer** alongside
516+
(or instead of) the predefined choices.
517+
- Uses the **same `request_confirmation` HITL plumbing** as tool approval —
518+
no new executor logic, event converter changes, or A2A protocol extensions.
519+
520+
The tool is added **unconditionally** to every agent as a built-in tool,
521+
similar to how memory tools are added.
522+
523+
### Tool Schema
524+
525+
```python
526+
ask_user(questions=[
527+
{
528+
"question": "Which database should I use?",
529+
"choices": ["PostgreSQL", "MySQL", "SQLite"],
530+
"multiple": False # single-select (default)
531+
},
532+
{
533+
"question": "Which features do you want?",
534+
"choices": ["Auth", "Logging", "Caching"],
535+
"multiple": True # multi-select
536+
},
537+
{
538+
"question": "Any additional requirements?",
539+
"choices": [], # free-text only
540+
"multiple": False
541+
}
542+
])
543+
```
544+
545+
### Tool Result (returned to model)
546+
547+
```python
548+
[
549+
{"question": "Which database should I use?", "answer": ["PostgreSQL"]},
550+
{"question": "Which features do you want?", "answer": ["Auth", "Caching"]},
551+
{"question": "Any additional requirements?", "answer": ["Add rate limiting"]}
552+
]
553+
```
554+
555+
### Data Flow
556+
557+
- A special path to process ask user result is added to agent executor and HITL converter
558+
- UI will be updated to recognize and display this
559+
- This tool will be added to every agent by default
560+
561+
```plaintext
562+
LLM generates function call: ask_user(questions=[...])
563+
564+
AskUserTool.run_async (first invocation)
565+
│ tool_context.tool_confirmation is None
566+
│ Calls tool_context.request_confirmation(hint=<question summary>)
567+
│ Returns {"status": "pending", "questions": [...]}
568+
569+
ADK generates adk_request_confirmation event (built-in)
570+
│ originalFunctionCall = {name: "ask_user", args: {questions: [...]}, id: ...}
571+
│ long_running_tool_ids set
572+
573+
Event converter (existing, unchanged)
574+
│ Converts to DataPart: {type: "function_call", is_long_running: true}
575+
│ Sets TaskState.input_required
576+
577+
Executor event loop
578+
│ Breaks on long_running_tool_ids (existing logic)
579+
580+
UI detects input_required + adk_request_confirmation
581+
│ Sees originalFunctionCall.name === "ask_user"
582+
│ Renders AskUserDisplay instead of ToolApprovalRequest card
583+
584+
AskUserDisplay renders:
585+
│ For each question:
586+
│ - Header (bold, short) + question text
587+
│ - Choice buttons as toggleable chips (single or multi-select)
588+
│ - Free-text input always visible below choices
589+
│ Single Submit button at the bottom
590+
591+
User selects choices / types answers, clicks Submit
592+
│ UI sends DataPart:
593+
│ {decision_type: "approve",
594+
│ ask_user_answers: [
595+
│ {answer: ["PostgreSQL"]},
596+
│ {answer: ["Auth", "Caching"]},
597+
│ {answer: ["Add rate limiting"]}
598+
│ ]}
599+
600+
Executor resume path
601+
│ Sees decision_type: "approve" + ask_user_answers present
602+
│ Constructs ToolConfirmation(confirmed=True,
603+
│ payload={"answers": [...]})
604+
605+
ADK replays ask_user tool call
606+
607+
AskUserTool.run_async (second invocation)
608+
│ tool_context.tool_confirmation.confirmed is True
609+
│ Reads payload["answers"]
610+
│ Zips answers with original questions
611+
│ Returns JSON: [{question: "...", answer: [...]}, ...]
612+
613+
LLM receives the answers as the function response
614+
│ Continues execution with user's input
615+
```

python/packages/kagent-adk/src/kagent/adk/_agent_executor.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@
4141
KAGENT_HITL_DECISION_TYPE_APPROVE,
4242
KAGENT_HITL_DECISION_TYPE_BATCH,
4343
TaskResultAggregator,
44+
extract_ask_user_answers_from_message,
4445
extract_batch_decisions_from_message,
4546
extract_decision_from_message,
47+
extract_rejection_reasons_from_message,
4648
get_kagent_metadata_key,
4749
)
4850
from kagent.core.tracing._span_processor import (
@@ -397,6 +399,28 @@ def _process_hitl_decision(
397399
len(pending_confirmations),
398400
)
399401

402+
# Check for ask-user answers — if present, build a single approved
403+
# ToolConfirmation with the answers payload regardless of decision_type.
404+
# The tool will use the payload and construct the user answer to the agent
405+
ask_user_answers = extract_ask_user_answers_from_message(message)
406+
if ask_user_answers is not None:
407+
parts = []
408+
for fc_id in pending_confirmations:
409+
confirmation = ToolConfirmation(confirmed=True, payload={"answers": ask_user_answers})
410+
parts.append(
411+
genai_types.Part(
412+
function_response=genai_types.FunctionResponse(
413+
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
414+
id=fc_id,
415+
response={"response": confirmation.model_dump_json()},
416+
)
417+
)
418+
)
419+
return parts
420+
421+
# Extract optional rejection reasons from the message.
422+
rejection_reasons = extract_rejection_reasons_from_message(message)
423+
400424
if decision == KAGENT_HITL_DECISION_TYPE_BATCH:
401425
# Batch mode: per-tool decisions
402426
batch_decisions = extract_batch_decisions_from_message(message) or {}
@@ -405,7 +429,13 @@ def _process_hitl_decision(
405429
# Look up the per-tool decision using the original tool call ID
406430
tool_decision = batch_decisions.get(original_id, KAGENT_HITL_DECISION_TYPE_APPROVE)
407431
confirmed = tool_decision == KAGENT_HITL_DECISION_TYPE_APPROVE
408-
confirmation = ToolConfirmation(confirmed=confirmed)
432+
# Attach rejection reason if provided for this specific tool
433+
payload: dict | None = None
434+
if not confirmed and rejection_reasons:
435+
reason = rejection_reasons.get(original_id) if original_id else None
436+
if reason:
437+
payload = {"rejection_reason": reason}
438+
confirmation = ToolConfirmation(confirmed=confirmed, payload=payload)
409439
# Append a response for each tool call
410440
parts.append(
411441
genai_types.Part(
@@ -420,7 +450,13 @@ def _process_hitl_decision(
420450
else:
421451
# Uniform mode: same decision for all pending tools
422452
confirmed = decision == KAGENT_HITL_DECISION_TYPE_APPROVE
423-
confirmation = ToolConfirmation(confirmed=confirmed)
453+
# Attach rejection reason if provided (uniform denial uses "*" sentinel)
454+
payload = None
455+
if not confirmed and rejection_reasons:
456+
reason = rejection_reasons.get("*")
457+
if reason:
458+
payload = {"rejection_reason": reason}
459+
confirmation = ToolConfirmation(confirmed=confirmed, payload=payload)
424460
return [
425461
genai_types.Part(
426462
function_response=genai_types.FunctionResponse(

python/packages/kagent-adk/src/kagent/adk/_approval.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def before_tool(
3131
tool: BaseTool,
3232
args: dict[str, Any],
3333
tool_context: ToolContext,
34-
) -> dict | None:
34+
) -> str | dict | None:
3535
tool_name = tool.name
3636
if tool_name not in tools_requiring_approval:
3737
return None # No approval needed, proceed normally
@@ -42,9 +42,18 @@ def before_tool(
4242
logger.debug("Tool %s approved by user, proceeding", tool_name)
4343
return None # Approved — proceed with tool execution
4444
logger.debug("Tool %s rejected by user", tool_name)
45-
return {"status": "rejected", "message": "Tool call was rejected by user."}
45+
# Check for an optional rejection reason in the payload
46+
# (the key "rejection_reason" is set by _agent_executor._process_hitl_decision)
47+
payload = tool_context.tool_confirmation.payload or {}
48+
reason = payload.get("rejection_reason", "") if isinstance(payload, dict) else ""
49+
# __build_response_event wraps it as {"result": "..."} that LLM adapters expect
50+
# ADK will skip executing the function if the before tool callback returns a response
51+
if reason:
52+
return f"Tool call was rejected by user. Reason: {reason}"
53+
return "Tool call was rejected by user."
4654

4755
# First invocation — request confirmation and block execution
56+
# This response is never sent to the LLM
4857
logger.debug("Tool %s requires approval, requesting confirmation", tool_name)
4958
tool_context.request_confirmation(
5059
hint=f"Tool '{tool_name}' requires approval before execution.",

python/packages/kagent-adk/src/kagent/adk/_memory_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,8 @@ async def _generate_embedding_async(
333333
litellm_model = f"ollama/{model_name}"
334334
elif provider == "vertex_ai":
335335
litellm_model = f"vertex_ai/{model_name}"
336+
elif provider == "gemini":
337+
litellm_model = f"gemini/{model_name}"
336338

337339
try:
338340
is_batch = isinstance(input_data, list)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Built-in tool for asking the user questions during agent execution.
2+
3+
Uses the ADK-native ToolContext.request_confirmation() mechanism so that
4+
the standard HITL event plumbing (adk_request_confirmation, long_running_tool_ids,
5+
executor resume path) handles the interrupt and resume transparently.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import logging
12+
from typing import Any
13+
14+
from google.adk.tools.base_tool import BaseTool
15+
from google.adk.tools.tool_context import ToolContext
16+
from google.genai import types
17+
18+
logger = logging.getLogger(__name__)
19+
20+
# Schema for a single question object passed to ask_user.
21+
_QUESTION_SCHEMA = types.Schema(
22+
type=types.Type.OBJECT,
23+
properties={
24+
"question": types.Schema(
25+
type=types.Type.STRING,
26+
description="The question text to display to the user.",
27+
),
28+
"choices": types.Schema(
29+
type=types.Type.ARRAY,
30+
items=types.Schema(type=types.Type.STRING),
31+
description=(
32+
"Predefined answer choices shown as selectable chips. Leave empty for a free-text-only question."
33+
),
34+
),
35+
"multiple": types.Schema(
36+
type=types.Type.BOOLEAN,
37+
description=("If true, the user can select multiple choices. Defaults to false (single-select)."),
38+
),
39+
},
40+
required=["question"],
41+
)
42+
43+
44+
class AskUserTool(BaseTool):
45+
"""Built-in tool that lets the agent ask the user one or more questions.
46+
47+
The tool uses the ADK ``request_confirmation`` mechanism to pause
48+
execution and present the questions to the UI. On the resume path the
49+
UI sends back ``ask_user_answers`` in the approval DataPart and the
50+
executor injects them via ``ToolConfirmation.payload``.
51+
52+
Because the interrupt is driven by ``request_confirmation``, this tool
53+
does *not* need to be listed in ``tools_requiring_approval``.
54+
"""
55+
56+
def __init__(self) -> None:
57+
super().__init__(
58+
name="ask_user",
59+
description=(
60+
"Ask the user one or more questions and wait for their answers "
61+
"before continuing. Use this when you need clarifying information, "
62+
"preferences, or explicit confirmation from the user."
63+
),
64+
)
65+
66+
def _get_declaration(self) -> types.FunctionDeclaration:
67+
return types.FunctionDeclaration(
68+
name=self.name,
69+
description=self.description,
70+
parameters=types.Schema(
71+
type=types.Type.OBJECT,
72+
properties={
73+
"questions": types.Schema(
74+
type=types.Type.ARRAY,
75+
items=_QUESTION_SCHEMA,
76+
description="List of questions to ask the user.",
77+
),
78+
},
79+
required=["questions"],
80+
),
81+
)
82+
83+
async def run_async(
84+
self,
85+
*,
86+
args: dict[str, Any],
87+
tool_context: ToolContext,
88+
) -> Any:
89+
questions: list[dict] = args.get("questions", [])
90+
91+
if tool_context.tool_confirmation is None:
92+
# First invocation — pause execution and ask the user.
93+
summary = "; ".join(q.get("question", "") for q in questions if q.get("question"))
94+
tool_context.request_confirmation(hint=summary or "Questions for the user.")
95+
logger.debug("ask_user: requesting confirmation with %d question(s)", len(questions))
96+
return {"status": "pending", "questions": questions}
97+
98+
if tool_context.tool_confirmation.confirmed:
99+
# Second invocation — the executor injected answers via payload.
100+
payload = tool_context.tool_confirmation.payload or {}
101+
answers: list[dict] = payload.get("answers", []) if isinstance(payload, dict) else []
102+
result = []
103+
for i, q in enumerate(questions):
104+
ans = answers[i]["answer"] if i < len(answers) and "answer" in answers[i] else []
105+
result.append({"question": q.get("question", ""), "answer": ans})
106+
logger.debug("ask_user: returning %d answer(s)", len(result))
107+
return json.dumps(result)
108+
109+
# User cancelled or rejected (should not normally happen for ask_user).
110+
logger.debug("ask_user: confirmation not received, returning cancelled status")
111+
return json.dumps({"status": "cancelled"})

python/packages/kagent-adk/src/kagent/adk/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from kagent.adk._mcp_toolset import KAgentMcpToolset
1919
from kagent.adk.models._litellm import KAgentLiteLlm
2020
from kagent.adk.sandbox_code_executer import SandboxedLocalCodeExecutor
21+
from kagent.adk.tools.ask_user_tool import AskUserTool
2122

2223
from .models import AzureOpenAI as OpenAIAzure
2324
from .models import OpenAI as OpenAINative
@@ -377,6 +378,9 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None:
377378
code_executor = SandboxedLocalCodeExecutor() if self.execute_code else None
378379
model = _create_llm_from_model_config(self.model)
379380

381+
# Add built-in ask_user tool unconditionally — every agent can ask the user questions.
382+
tools.append(AskUserTool())
383+
380384
# Build before_tool_callback if any tools require approval
381385
before_tool_callback = make_approval_callback(tools_requiring_approval) if tools_requiring_approval else None
382386

0 commit comments

Comments
 (0)