Skip to content

Commit b1cf420

Browse files
feat: human-in-the-loop (subagents) (kagent-dev#1491)
This PR implements the last part of HITL system in Kagent -- subagent HITL flow. When the parent agent calls one or more subagents and any of them invokes a `requireApproval` tool, HITL request will be cascaded up to the user (regardless of call stack depth). In addition, it contains 1. test coverage for previous HITL utils 2. consistent types in python and UI for HITL 3. large enhancement to documentation of HITL implementation in Kagent 4. various HITL cleanups At a high level, this is what the user expects: <img width="2890" height="2398" alt="mermaid-diagram-2026-03-11-223608" src="https://github.com/user-attachments/assets/10d7ba18-8b9a-41d4-8a03-ca969717997f" /> For more information of HITL system in Kagent, including full sequence diagrams of how these flows are carried out, view `docs/architecture/human-in-the-loop.md`. --------- Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
1 parent 7a3da43 commit b1cf420

25 files changed

Lines changed: 2317 additions & 739 deletions

File tree

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

Lines changed: 384 additions & 489 deletions
Large diffs are not rendered by default.

go/adk/pkg/a2a/hitl.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ import (
99
)
1010

1111
var (
12-
denyWordPatterns []*regexp.Regexp
12+
rejectWordPatterns []*regexp.Regexp
1313
approveWordPatterns []*regexp.Regexp
1414
)
1515

1616
func init() {
17-
for _, keyword := range KAgentHitlResumeKeywordsDeny {
18-
denyWordPatterns = append(denyWordPatterns, regexp.MustCompile(`(?i)\b`+regexp.QuoteMeta(keyword)+`\b`))
17+
for _, keyword := range KAgentHitlResumeKeywordsReject {
18+
rejectWordPatterns = append(rejectWordPatterns, regexp.MustCompile(`(?i)\b`+regexp.QuoteMeta(keyword)+`\b`))
1919
}
2020
for _, keyword := range KAgentHitlResumeKeywordsApprove {
2121
approveWordPatterns = append(approveWordPatterns, regexp.MustCompile(`(?i)\b`+regexp.QuoteMeta(keyword)+`\b`))
@@ -28,20 +28,20 @@ const (
2828
KAgentHitlInterruptTypeToolApproval = "tool_approval"
2929
KAgentHitlDecisionTypeKey = "decision_type"
3030
KAgentHitlDecisionTypeApprove = "approve"
31-
KAgentHitlDecisionTypeDeny = "deny"
31+
KAgentHitlDecisionTypeReject = "reject"
3232
)
3333

3434
var (
3535
KAgentHitlResumeKeywordsApprove = []string{"approved", "approve", "proceed", "yes", "continue"}
36-
KAgentHitlResumeKeywordsDeny = []string{"denied", "deny", "reject", "no", "cancel", "stop"}
36+
KAgentHitlResumeKeywordsReject = []string{"denied", "deny", "reject", "no", "cancel", "stop"}
3737
)
3838

3939
// DecisionType represents a HITL decision.
4040
type DecisionType string
4141

4242
const (
4343
DecisionApprove DecisionType = "approve"
44-
DecisionDeny DecisionType = "deny"
44+
DecisionReject DecisionType = "reject"
4545
)
4646

4747
// ToolApprovalRequest represents a tool call requiring user approval.
@@ -60,9 +60,9 @@ func GetKAgentMetadataKey(key string) string {
6060
// keyword matching. Word boundaries prevent false positives from substrings
6161
// (e.g. "no" inside "know", "yes" inside "yesterday").
6262
func ExtractDecisionFromText(text string) DecisionType {
63-
for _, pattern := range denyWordPatterns {
63+
for _, pattern := range rejectWordPatterns {
6464
if pattern.MatchString(text) {
65-
return DecisionDeny
65+
return DecisionReject
6666
}
6767
}
6868
for _, pattern := range approveWordPatterns {
@@ -87,8 +87,8 @@ func ExtractDecisionFromMessage(message *a2atype.Message) DecisionType {
8787
switch decision {
8888
case KAgentHitlDecisionTypeApprove:
8989
return DecisionApprove
90-
case KAgentHitlDecisionTypeDeny:
91-
return DecisionDeny
90+
case KAgentHitlDecisionTypeReject:
91+
return DecisionReject
9292
}
9393
}
9494
}

go/adk/pkg/a2a/hitl_test.go

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,15 @@ func TestExtractDecisionFromMessage_DataPart(t *testing.T) {
4141
t.Errorf("ExtractDecisionFromMessage(approve DataPart) = %q, want %q", result, DecisionApprove)
4242
}
4343

44-
denyData := map[string]any{
45-
KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeDeny,
44+
rejectData := map[string]any{
45+
KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeReject,
4646
}
4747
message = a2atype.NewMessage(a2atype.MessageRoleUser,
48-
&a2atype.DataPart{Data: denyData},
48+
&a2atype.DataPart{Data: rejectData},
4949
)
5050
result = ExtractDecisionFromMessage(message)
51-
if result != DecisionDeny {
52-
t.Errorf("ExtractDecisionFromMessage(deny DataPart) = %q, want %q", result, DecisionDeny)
51+
if result != DecisionReject {
52+
t.Errorf("ExtractDecisionFromMessage(reject DataPart) = %q, want %q", result, DecisionReject)
5353
}
5454
}
5555

@@ -66,8 +66,8 @@ func TestExtractDecisionFromMessage_TextPart(t *testing.T) {
6666
a2atype.TextPart{Text: "Request denied, do not proceed"},
6767
)
6868
result = ExtractDecisionFromMessage(message)
69-
if result != DecisionDeny {
70-
t.Errorf("ExtractDecisionFromMessage(deny text) = %q, want %q", result, DecisionDeny)
69+
if result != DecisionReject {
70+
t.Errorf("ExtractDecisionFromMessage(reject text) = %q, want %q", result, DecisionReject)
7171
}
7272

7373
message = a2atype.NewMessage(a2atype.MessageRoleUser,
@@ -84,13 +84,13 @@ func TestExtractDecisionFromMessage_Priority(t *testing.T) {
8484
a2atype.TextPart{Text: "approved"},
8585
&a2atype.DataPart{
8686
Data: map[string]any{
87-
KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeDeny,
87+
KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeReject,
8888
},
8989
},
9090
)
9191
result := ExtractDecisionFromMessage(message)
92-
if result != DecisionDeny {
93-
t.Errorf("ExtractDecisionFromMessage(mixed parts) = %q, want %q (DataPart should take priority)", result, DecisionDeny)
92+
if result != DecisionReject {
93+
t.Errorf("ExtractDecisionFromMessage(mixed parts) = %q, want %q (DataPart should take priority)", result, DecisionReject)
9494
}
9595
}
9696

@@ -125,12 +125,12 @@ func TestExtractDecisionFromText_WordBoundary(t *testing.T) {
125125
{name: "yes inside yesterday should not match", text: "yesterday was fine", want: ""},
126126
{name: "stop inside unstoppable should not match", text: "unstoppable progress", want: ""},
127127
{name: "cancel inside cancellation should not match", text: "the cancellation policy", want: ""},
128-
{name: "standalone no matches", text: "no, I do not agree", want: DecisionDeny},
128+
{name: "standalone no matches", text: "no, I do not agree", want: DecisionReject},
129129
{name: "standalone yes matches", text: "yes, go ahead", want: DecisionApprove},
130-
{name: "standalone stop matches", text: "stop the process", want: DecisionDeny},
131-
{name: "case insensitive whole word", text: "NO", want: DecisionDeny},
132-
{name: "keyword at end of sentence", text: "the answer is no", want: DecisionDeny},
133-
{name: "keyword with punctuation", text: "no!", want: DecisionDeny},
130+
{name: "standalone stop matches", text: "stop the process", want: DecisionReject},
131+
{name: "case insensitive whole word", text: "NO", want: DecisionReject},
132+
{name: "keyword at end of sentence", text: "the answer is no", want: DecisionReject},
133+
{name: "keyword with punctuation", text: "no!", want: DecisionReject},
134134
{name: "continue inside discontinue should not match", text: "I will discontinue", want: ""},
135135
{name: "approve as standalone", text: "I approve", want: DecisionApprove},
136136
}

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

Lines changed: 86 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -345,17 +345,18 @@ async def _publish_failed_status_event(
345345
logger.error("Failed to publish failure event: %s", enqueue_error, exc_info=True)
346346

347347
@staticmethod
348-
def _find_pending_confirmations(session: Session) -> dict[str, str | None]:
348+
def _find_pending_confirmations(session: Session) -> dict[str, tuple[str | None, dict | None]]:
349349
"""Find pending adk_request_confirmation calls and their original tool call IDs.
350350
351351
Scans session events backwards for the most recent adk_request_confirmation
352352
FunctionCall events that haven't been responded to yet.
353353
354354
Returns:
355-
Dict mapping confirmation function_call_id to the original tool call ID
356-
(from args.originalFunctionCall.id), or None if not available.
355+
Dict mapping confirmation function_call_id to a tuple of:
356+
- the original tool call ID (from args.originalFunctionCall.id), or None
357+
- the original toolConfirmation payload (from args.toolConfirmation.payload), or None
357358
"""
358-
pending: dict[str, str | None] = {}
359+
pending: dict[str, tuple[str | None, dict | None]] = {}
359360
responded_ids: set[str] = set()
360361

361362
for event in reversed(session.events or []):
@@ -364,16 +365,23 @@ def _find_pending_confirmations(session: Session) -> dict[str, str | None]:
364365
if fr.name == REQUEST_CONFIRMATION_FUNCTION_CALL_NAME and fr.id is not None:
365366
responded_ids.add(fr.id)
366367

367-
# Collect requested confirmation IDs and extract original tool call ID
368+
# Collect requested confirmation IDs and extract original tool call ID + payload
368369
for fc in event.get_function_calls():
369370
if fc.name == REQUEST_CONFIRMATION_FUNCTION_CALL_NAME and fc.id is not None:
370-
# Extract original tool call ID from args.originalFunctionCall.id
371371
original_id = None
372+
original_payload = None
372373
if fc.args and isinstance(fc.args, dict):
373374
orig_fc = fc.args.get("originalFunctionCall")
374375
if isinstance(orig_fc, dict):
375376
original_id = orig_fc.get("id")
376-
pending[fc.id] = original_id
377+
tool_conf = fc.args.get("toolConfirmation")
378+
if isinstance(tool_conf, dict):
379+
original_payload = tool_conf.get("payload")
380+
if isinstance(original_payload, dict):
381+
original_payload = dict(original_payload)
382+
else:
383+
original_payload = None
384+
pending[fc.id] = (original_id, original_payload)
377385

378386
# Stop scanning once we find confirmation requests (they're recent)
379387
if pending:
@@ -385,6 +393,27 @@ def _find_pending_confirmations(session: Session) -> dict[str, str | None]:
385393

386394
return pending
387395

396+
@staticmethod
397+
def _build_confirmation_payload(
398+
original_payload: dict | None,
399+
extra: dict | None,
400+
) -> dict | None:
401+
"""Merge the original request_confirmation payload with decision-specific data.
402+
403+
The original payload (set by the tool in ``request_confirmation()``) is
404+
preserved so that the tool's ``_handle_resume`` can read its own state
405+
(e.g. subagent task_id, context_id). Decision-specific keys (like
406+
``rejection_reason``) are merged on top.
407+
"""
408+
if not original_payload and not extra:
409+
return None
410+
merged: dict = {}
411+
if original_payload:
412+
merged.update(original_payload)
413+
if extra:
414+
merged.update(extra)
415+
return merged
416+
388417
def _process_hitl_decision(
389418
self, session: Session, decision: str, message: Message
390419
) -> list[genai_types.Part] | None:
@@ -394,9 +423,9 @@ def _process_hitl_decision(
394423
return None
395424

396425
logger.info(
397-
"HITL continuation detected: decision=%s, pending_confirmations=%d",
426+
"HITL continuation: decision=%s, pending=%s",
398427
decision,
399-
len(pending_confirmations),
428+
{fc_id: orig_id for fc_id, (orig_id, _) in pending_confirmations.items()},
400429
)
401430

402431
# Check for ask-user answers — if present, build a single approved
@@ -405,8 +434,9 @@ def _process_hitl_decision(
405434
ask_user_answers = extract_ask_user_answers_from_message(message)
406435
if ask_user_answers is not None:
407436
parts = []
408-
for fc_id in pending_confirmations:
409-
confirmation = ToolConfirmation(confirmed=True, payload={"answers": ask_user_answers})
437+
for fc_id, (_, orig_payload) in pending_confirmations.items():
438+
payload = self._build_confirmation_payload(orig_payload, {"answers": ask_user_answers})
439+
confirmation = ToolConfirmation(confirmed=True, payload=payload)
410440
parts.append(
411441
genai_types.Part(
412442
function_response=genai_types.FunctionResponse(
@@ -424,19 +454,37 @@ def _process_hitl_decision(
424454
if decision == KAGENT_HITL_DECISION_TYPE_BATCH:
425455
# Batch mode: per-tool decisions
426456
batch_decisions = extract_batch_decisions_from_message(message) or {}
457+
logger.info(
458+
"HITL batch: batch_decisions=%s, rejection_reasons=%s",
459+
batch_decisions,
460+
rejection_reasons,
461+
)
427462
parts = []
428-
for fc_id, original_id in pending_confirmations.items():
429-
# Look up the per-tool decision using the original tool call ID
430-
tool_decision = batch_decisions.get(original_id, KAGENT_HITL_DECISION_TYPE_APPROVE)
431-
confirmed = tool_decision == KAGENT_HITL_DECISION_TYPE_APPROVE
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)
439-
# Append a response for each tool call
463+
for fc_id, (original_id, orig_payload) in pending_confirmations.items():
464+
# Check if this is a subagent HITL request by checking if orig_payload has hitl_parts.
465+
is_subagent = bool(orig_payload and orig_payload.get("hitl_parts"))
466+
467+
if is_subagent:
468+
# Forward the entire batch decision to the tool so
469+
# _handle_resume can relay it to the subagent as-is.
470+
all_approved = all(d == KAGENT_HITL_DECISION_TYPE_APPROVE for d in batch_decisions.values())
471+
extra: dict = {"batch_decisions": batch_decisions}
472+
if rejection_reasons:
473+
extra["rejection_reasons"] = rejection_reasons
474+
payload = self._build_confirmation_payload(orig_payload, extra)
475+
confirmation = ToolConfirmation(confirmed=all_approved, payload=payload)
476+
else:
477+
# Direct tool — look up by original_id as before
478+
tool_decision = batch_decisions.get(original_id, KAGENT_HITL_DECISION_TYPE_APPROVE)
479+
confirmed = tool_decision == KAGENT_HITL_DECISION_TYPE_APPROVE
480+
extra_reject: dict | None = None
481+
if not confirmed and rejection_reasons:
482+
reason = rejection_reasons.get(original_id) if original_id else None
483+
if reason:
484+
extra_reject = {"rejection_reason": reason}
485+
payload = self._build_confirmation_payload(orig_payload, extra_reject)
486+
confirmation = ToolConfirmation(confirmed=confirmed, payload=payload)
487+
440488
parts.append(
441489
genai_types.Part(
442490
function_response=genai_types.FunctionResponse(
@@ -451,22 +499,26 @@ def _process_hitl_decision(
451499
# Uniform mode: same decision for all pending tools
452500
confirmed = decision == KAGENT_HITL_DECISION_TYPE_APPROVE
453501
# Attach rejection reason if provided (uniform denial uses "*" sentinel)
454-
payload = None
502+
uniform_extra: dict | None = None
455503
if not confirmed and rejection_reasons:
456504
reason = rejection_reasons.get("*")
457505
if reason:
458-
payload = {"rejection_reason": reason}
459-
confirmation = ToolConfirmation(confirmed=confirmed, payload=payload)
460-
return [
461-
genai_types.Part(
462-
function_response=genai_types.FunctionResponse(
463-
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
464-
id=fc_id,
465-
response={"response": confirmation.model_dump_json()},
506+
uniform_extra = {"rejection_reason": reason}
507+
parts = []
508+
for fc_id, (_, orig_payload) in pending_confirmations.items():
509+
merged_payload = self._build_confirmation_payload(orig_payload, uniform_extra)
510+
confirmation = ToolConfirmation(confirmed=confirmed, payload=merged_payload)
511+
serialized = confirmation.model_dump_json()
512+
parts.append(
513+
genai_types.Part(
514+
function_response=genai_types.FunctionResponse(
515+
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
516+
id=fc_id,
517+
response={"response": serialized},
518+
)
466519
)
467520
)
468-
for fc_id in pending_confirmations
469-
]
521+
return parts
470522

471523
async def _handle_request(
472524
self,

0 commit comments

Comments
 (0)