From a529be230da6c12cb56672ff0d9f36f39f379e52 Mon Sep 17 00:00:00 2001 From: Vivien Ramahandry <56304555+vramahandry@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:39:40 +0200 Subject: [PATCH 1/3] fix(go-adk): surface the sub-agent's ask_user question in RemoteHitlHint RemoteHitlHint() only listed the paused tool's name ('ask_user'), never the actual question text, even though HitlTool.Args already carries it via VisibleTools(). A human relayed a bubbled-up sub-agent HITL pause saw 'requires approval for tool(s): ask_user' with no way to know what was actually being asked. Fixes #2473 Signed-off-by: Vivien Ramahandry <56304555+vramahandry@users.noreply.github.com> --- go/adk/pkg/a2a/hitl.go | 33 +++++++++++++++++++++++++++++++-- go/adk/pkg/a2a/hitl_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/go/adk/pkg/a2a/hitl.go b/go/adk/pkg/a2a/hitl.go index bcd46ab8f..ccbddfa8d 100644 --- a/go/adk/pkg/a2a/hitl.go +++ b/go/adk/pkg/a2a/hitl.go @@ -361,17 +361,46 @@ func VisibleTools(approval *ToolApprovalRequest, ask *AskUserRequest) []HitlTool return nil } +// askUserQuestionText extracts the human-readable question text(s) from an +// ask_user HitlTool's args (see VisibleTools), or "" if the shape is +// unexpected. This is the content RemoteHitlHint would otherwise discard, +// surfacing only the tool name ("ask_user") instead of what is actually +// being asked. +func askUserQuestionText(args map[string]any) string { + raw, _ := args["questions"].([]map[string]any) + texts := make([]string, 0, len(raw)) + for _, q := range raw { + if text, ok := q["question"].(string); ok && text != "" { + texts = append(texts, text) + } + } + return strings.Join(texts, " ") +} + func RemoteHitlHint(state *RemoteHitlState) string { if state == nil { return "Remote agent requires human input before continuing." } tools := VisibleTools(state.ToolApprovalRequest, state.AskUserRequest) names := make([]string, 0, len(tools)) + questions := make([]string, 0, len(tools)) for _, tool := range tools { - if tool.Name != "" { - names = append(names, tool.Name) + if tool.Name == "" { + continue + } + names = append(names, tool.Name) + if tool.Name == "ask_user" { + if q := askUserQuestionText(tool.Args); q != "" { + questions = append(questions, q) + } } } + // A real ask_user question is more useful to the human than the bare + // tool name — surface it directly rather than "requires approval for + // tool(s): ask_user", which carries no actionable information. + if len(questions) > 0 { + return fmt.Sprintf("Remote agent '%s' asks: %s", state.SubagentName, strings.Join(questions, " ")) + } if len(names) > 0 { return fmt.Sprintf("Remote agent '%s' requires approval for tool(s): %s", state.SubagentName, strings.Join(names, ", ")) diff --git a/go/adk/pkg/a2a/hitl_test.go b/go/adk/pkg/a2a/hitl_test.go index 81ee6bd41..85fb6c2dd 100644 --- a/go/adk/pkg/a2a/hitl_test.go +++ b/go/adk/pkg/a2a/hitl_test.go @@ -260,3 +260,29 @@ func TestBuildRemoteHitlStateAndHint(t *testing.T) { t.Fatalf("hint = %q", got) } } + +// A sub-agent's ask_user pause should surface the actual question in the +// hint, not just "requires approval for tool(s): ask_user" — a human can't +// act on a tool name alone. +func TestBuildRemoteHitlStateAndHintAskUser(t *testing.T) { + task := &a2atype.Task{ + ID: "child-task", ContextID: "child-context", + Status: a2atype.TaskStatus{ + Message: AttachHitlExtension(a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart("pause")), &AskUserRequest{ + Type: HITLTypeAskUserRequest, + ID: "confirm-1", + Questions: []map[string]any{ + {"question": "What is the GitHub owner/org for the repo?"}, + }, + }), + }, + } + state := BuildRemoteHitlState(task, "github_agent") + if state == nil || state.AskUserRequest == nil { + t.Fatalf("state = %#v", state) + } + want := "Remote agent 'github_agent' asks: What is the GitHub owner/org for the repo?" + if got := RemoteHitlHint(state); got != want { + t.Fatalf("hint = %q, want %q", got, want) + } +} From 9c3ac9e9e6a1fc08aed1ce20e41d94ce68590c4c Mon Sep 17 00:00:00 2001 From: Vivien Ramahandry <56304555+vramahandry@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:11:53 +0200 Subject: [PATCH 2/3] fix(go-adk): read AskUserRequest.Questions directly in RemoteHitlHint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit askUserQuestionText() type-asserted a nested ask_user HitlTool's Args["questions"] as []map[string]any, but Args round-trips through JSON into a plain map[string]any, which decodes "questions" as []any instead — silently dropping the question for nested (two-level) ask_user pauses. AskUserRequest.Questions is already correctly typed in both the direct and nested case (see BuildHITLStatusMessage), so read it directly instead of re-deriving from VisibleTools() output. Addresses review feedback from supreme-gg-gg on #2475. Signed-off-by: Vivien Ramahandry <56304555+vramahandry@users.noreply.github.com> --- go/adk/pkg/a2a/hitl.go | 42 +++++++++++++++++-------------------- go/adk/pkg/a2a/hitl_test.go | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/go/adk/pkg/a2a/hitl.go b/go/adk/pkg/a2a/hitl.go index ccbddfa8d..399b8da12 100644 --- a/go/adk/pkg/a2a/hitl.go +++ b/go/adk/pkg/a2a/hitl.go @@ -362,14 +362,12 @@ func VisibleTools(approval *ToolApprovalRequest, ask *AskUserRequest) []HitlTool } // askUserQuestionText extracts the human-readable question text(s) from an -// ask_user HitlTool's args (see VisibleTools), or "" if the shape is -// unexpected. This is the content RemoteHitlHint would otherwise discard, -// surfacing only the tool name ("ask_user") instead of what is actually -// being asked. -func askUserQuestionText(args map[string]any) string { - raw, _ := args["questions"].([]map[string]any) - texts := make([]string, 0, len(raw)) - for _, q := range raw { +// ask_user request's typed Questions field, or "" if none carry text. This is +// the content RemoteHitlHint would otherwise discard, surfacing only the +// tool name ("ask_user") instead of what is actually being asked. +func askUserQuestionText(questions []map[string]any) string { + texts := make([]string, 0, len(questions)) + for _, q := range questions { if text, ok := q["question"].(string); ok && text != "" { texts = append(texts, text) } @@ -381,25 +379,23 @@ func RemoteHitlHint(state *RemoteHitlState) string { if state == nil { return "Remote agent requires human input before continuing." } + // AskUserRequest.Questions carries the real question text whether or not + // Nested is set (see BuildHITLStatusMessage), so read it directly rather + // than re-deriving from VisibleTools()'s nested HitlTool.Args: those args + // round-trip through JSON into a plain map[string]any, which decodes + // "questions" as []any rather than []map[string]any, silently losing the + // question in the nested case. + if state.AskUserRequest != nil { + if q := askUserQuestionText(state.AskUserRequest.Questions); q != "" { + return fmt.Sprintf("Remote agent '%s' asks: %s", state.SubagentName, q) + } + } tools := VisibleTools(state.ToolApprovalRequest, state.AskUserRequest) names := make([]string, 0, len(tools)) - questions := make([]string, 0, len(tools)) for _, tool := range tools { - if tool.Name == "" { - continue + if tool.Name != "" { + names = append(names, tool.Name) } - names = append(names, tool.Name) - if tool.Name == "ask_user" { - if q := askUserQuestionText(tool.Args); q != "" { - questions = append(questions, q) - } - } - } - // A real ask_user question is more useful to the human than the bare - // tool name — surface it directly rather than "requires approval for - // tool(s): ask_user", which carries no actionable information. - if len(questions) > 0 { - return fmt.Sprintf("Remote agent '%s' asks: %s", state.SubagentName, strings.Join(questions, " ")) } if len(names) > 0 { return fmt.Sprintf("Remote agent '%s' requires approval for tool(s): %s", diff --git a/go/adk/pkg/a2a/hitl_test.go b/go/adk/pkg/a2a/hitl_test.go index 85fb6c2dd..dc8ea113c 100644 --- a/go/adk/pkg/a2a/hitl_test.go +++ b/go/adk/pkg/a2a/hitl_test.go @@ -286,3 +286,40 @@ func TestBuildRemoteHitlStateAndHintAskUser(t *testing.T) { t.Fatalf("hint = %q, want %q", got, want) } } + +// A two-level nested ask_user pause (grandchild agent, relayed through the +// child) should also surface the real question. The nested HitlTool's Args +// round-trip through JSON into a plain map[string]any, decoding "questions" +// as []any rather than []map[string]any — the hint must read +// AskUserRequest.Questions directly instead of re-deriving from those args. +func TestBuildRemoteHitlStateAndHintAskUserNested(t *testing.T) { + question := "What is the GitHub owner/org for the repo?" + task := &a2atype.Task{ + ID: "child-task", ContextID: "child-context", + Status: a2atype.TaskStatus{ + Message: AttachHitlExtension(a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart("pause")), &AskUserRequest{ + Type: HITLTypeAskUserRequest, + ID: "confirm-1", + Questions: []map[string]any{{"question": question}}, + Nested: &NestedHitlRequest{ + TaskID: "grandchild-task", ContextID: "grandchild-context", SubagentName: "grandchild_agent", + Tools: []HitlTool{{ + ID: "confirm-2", CallID: "confirm-2", Name: "ask_user", + Args: map[string]any{"questions": []map[string]any{{"question": question}}}, + }}, + }, + }), + }, + } + state := BuildRemoteHitlState(task, "github_agent") + if state == nil || state.AskUserRequest == nil || state.AskUserRequest.Nested == nil { + t.Fatalf("state = %#v", state) + } + if _, ok := state.AskUserRequest.Nested.Tools[0].Args["questions"].([]map[string]any); ok { + t.Fatalf("nested tool args decoded as []map[string]any; test no longer exercises the []any round-trip shape") + } + want := "Remote agent 'github_agent' asks: " + question + if got := RemoteHitlHint(state); got != want { + t.Fatalf("hint = %q, want %q", got, want) + } +} From 40bec6ba35e5a4eda888aa9ade999067976a5360 Mon Sep 17 00:00:00 2001 From: Vivien Ramahandry <56304555+vramahandry@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:58:14 +0200 Subject: [PATCH 3/3] style(go-adk): trim verbose comments in RemoteHitlHint per review nit supreme-gg-gg flagged the inline comment as too long; tightened it and the similarly verbose doc/test comments added in the same change while keeping the JSON round-trip explanation. --- go/adk/pkg/a2a/hitl.go | 13 +++---------- go/adk/pkg/a2a/hitl_test.go | 11 +++-------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/go/adk/pkg/a2a/hitl.go b/go/adk/pkg/a2a/hitl.go index 399b8da12..4f96a184a 100644 --- a/go/adk/pkg/a2a/hitl.go +++ b/go/adk/pkg/a2a/hitl.go @@ -361,10 +361,7 @@ func VisibleTools(approval *ToolApprovalRequest, ask *AskUserRequest) []HitlTool return nil } -// askUserQuestionText extracts the human-readable question text(s) from an -// ask_user request's typed Questions field, or "" if none carry text. This is -// the content RemoteHitlHint would otherwise discard, surfacing only the -// tool name ("ask_user") instead of what is actually being asked. +// askUserQuestionText joins the question text from a typed Questions field, or "" if none carry text. func askUserQuestionText(questions []map[string]any) string { texts := make([]string, 0, len(questions)) for _, q := range questions { @@ -379,12 +376,8 @@ func RemoteHitlHint(state *RemoteHitlState) string { if state == nil { return "Remote agent requires human input before continuing." } - // AskUserRequest.Questions carries the real question text whether or not - // Nested is set (see BuildHITLStatusMessage), so read it directly rather - // than re-deriving from VisibleTools()'s nested HitlTool.Args: those args - // round-trip through JSON into a plain map[string]any, which decodes - // "questions" as []any rather than []map[string]any, silently losing the - // question in the nested case. + // Read Questions directly: VisibleTools()'s nested Args round-trip through + // JSON to []any, silently losing the question text in the nested case. if state.AskUserRequest != nil { if q := askUserQuestionText(state.AskUserRequest.Questions); q != "" { return fmt.Sprintf("Remote agent '%s' asks: %s", state.SubagentName, q) diff --git a/go/adk/pkg/a2a/hitl_test.go b/go/adk/pkg/a2a/hitl_test.go index dc8ea113c..c82e50756 100644 --- a/go/adk/pkg/a2a/hitl_test.go +++ b/go/adk/pkg/a2a/hitl_test.go @@ -261,9 +261,7 @@ func TestBuildRemoteHitlStateAndHint(t *testing.T) { } } -// A sub-agent's ask_user pause should surface the actual question in the -// hint, not just "requires approval for tool(s): ask_user" — a human can't -// act on a tool name alone. +// A sub-agent's ask_user pause should surface the question, not just the tool name. func TestBuildRemoteHitlStateAndHintAskUser(t *testing.T) { task := &a2atype.Task{ ID: "child-task", ContextID: "child-context", @@ -287,11 +285,8 @@ func TestBuildRemoteHitlStateAndHintAskUser(t *testing.T) { } } -// A two-level nested ask_user pause (grandchild agent, relayed through the -// child) should also surface the real question. The nested HitlTool's Args -// round-trip through JSON into a plain map[string]any, decoding "questions" -// as []any rather than []map[string]any — the hint must read -// AskUserRequest.Questions directly instead of re-deriving from those args. +// A two-level nested ask_user pause should also surface the question, since +// the nested HitlTool's Args round-trip through JSON and lose their type. func TestBuildRemoteHitlStateAndHintAskUserNested(t *testing.T) { question := "What is the GitHub owner/org for the repo?" task := &a2atype.Task{