@@ -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