Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/architecture/human-in-the-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ request may also carry `nested` child-agent correlation: `subagent_name`,
The server rejects responses that use the wrong task/context, omit required
decisions, duplicate IDs, or answer an operation that is no longer pending.

The status message also carries a text part that describes the pause. Every
runtime composes that text the same way, and a client receives it whether or not
it activated the extension:

- an `ask_user` pause renders its questions, joined with `; `;
- any other pause renders the hints its tools supplied, followed by the tool
names in parentheses: `Deleting this file requires approval (delete_file,
restart_pod)`;
- with no hints, it names the tools: `Approval is required for tool(s):
delete_file`;
- a pause that carries no tool call renders `Human input is required before the
agent can continue.`

The `hint` field of an activated payload holds that same string.

## Ownership

| Component | Responsibility |
Expand Down
4 changes: 1 addition & 3 deletions go/adk/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,7 @@ func main() {
},
}
}
agentCard.Capabilities = a2atype.AgentCapabilities{
Streaming: stream,
}
agentCard.Capabilities.Streaming = stream

// Delegate the actor-local A2A server and task store to app.New.
kagentApp, err := app.New(app.AppConfig{
Expand Down
17 changes: 12 additions & 5 deletions go/adk/pkg/a2a/agentcard.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,8 @@ func EnrichAgentCard(card *a2atype.AgentCard, agent adkagent.Agent) {
if card.Description == "" && agent.Description() != "" {
card.Description = agent.Description()
}
// If the agent card does not have the HITL extension, add it.
// Kagent's harness always supports it.
if !hasHITLExtension(card.Capabilities.Extensions) {
card.Capabilities.Extensions = append(card.Capabilities.Extensions, apia2a.HITLExtension())
}

EnsureHITLExtension(card)

// Default to JSONRPC when no interface is explicitly configured.
if len(card.SupportedInterfaces) == 0 {
Expand All @@ -36,6 +33,16 @@ func EnrichAgentCard(card *a2atype.AgentCard, agent adkagent.Agent) {
}
}

// EnsureHITLExtension declares the optional HITL extension on the card so a client
// can discover it and negotiate. Kagent's harness always supports it, and the
// declaration does not depend on whether an ADK agent was supplied.
func EnsureHITLExtension(card *a2atype.AgentCard) {
if card == nil || hasHITLExtension(card.Capabilities.Extensions) {
return
}
card.Capabilities.Extensions = append(card.Capabilities.Extensions, apia2a.HITLExtension())
}

func hasHITLExtension(extensions []a2atype.AgentExtension) bool {
for _, extension := range extensions {
if extension.URI == HITLExtensionURI {
Expand Down
68 changes: 68 additions & 0 deletions go/adk/pkg/a2a/agentcard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package a2a

import (
"iter"
"testing"

a2atype "github.com/a2aproject/a2a-go/v2/a2a"
adkagent "google.golang.org/adk/v2/agent"
"google.golang.org/adk/v2/session"
)

func TestEnsureHITLExtension(t *testing.T) {
t.Run("attaches when absent", func(t *testing.T) {
card := &a2atype.AgentCard{Name: "agent"}
EnsureHITLExtension(card)
if !hasHITLExtension(card.Capabilities.Extensions) {
t.Fatalf("extensions = %#v, want the HITL extension", card.Capabilities.Extensions)
}
})

t.Run("idempotent", func(t *testing.T) {
card := &a2atype.AgentCard{Name: "agent"}
EnsureHITLExtension(card)
EnsureHITLExtension(card)
if len(card.Capabilities.Extensions) != 1 {
t.Errorf("len(extensions) = %d, want 1", len(card.Capabilities.Extensions))
}
})

t.Run("preserves unrelated extensions", func(t *testing.T) {
card := &a2atype.AgentCard{
Name: "agent",
Capabilities: a2atype.AgentCapabilities{
Extensions: []a2atype.AgentExtension{{URI: "https://example.com/extensions/other/v1"}},
},
}
EnsureHITLExtension(card)
if len(card.Capabilities.Extensions) != 2 {
t.Fatalf("len(extensions) = %d, want 2", len(card.Capabilities.Extensions))
}
if card.Capabilities.Extensions[0].URI != "https://example.com/extensions/other/v1" {
t.Errorf("extensions[0].URI = %q, want the pre-existing extension", card.Capabilities.Extensions[0].URI)
}
})

t.Run("nil card", func(t *testing.T) {
EnsureHITLExtension(nil)
})
}

func TestEnrichAgentCardDeclaresHITLExtension(t *testing.T) {
agent, err := adkagent.New(adkagent.Config{
Name: "adk_agent",
Run: func(adkagent.InvocationContext) iter.Seq2[*session.Event, error] {
return func(yield func(*session.Event, error) bool) {}
},
})
if err != nil {
t.Fatalf("adkagent.New: %v", err)
}
card := &a2atype.AgentCard{Name: "agent"}

EnrichAgentCard(card, agent)

if !hasHITLExtension(card.Capabilities.Extensions) {
t.Fatalf("extensions = %#v, want the HITL extension", card.Capabilities.Extensions)
}
}
75 changes: 69 additions & 6 deletions go/adk/pkg/a2a/hitl.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const (
HITLTypeToolApprovalResponse = apia2a.HITLTypeToolApprovalResponse
HITLTypeAskUserResponse = apia2a.HITLTypeAskUserResponse
KAgentMetadataKeyPrefix = "kagent_"

// genericHITLText is the status text for a pause that names no tool.
genericHITLText = "Human input is required before the agent can continue."
)

var hitlAgentExtension = apia2a.HITLExtension()
Expand Down Expand Up @@ -391,14 +394,67 @@ func (tool confirmationTool) asHitlTool() apia2a.HITLTool {
return apia2a.HITLTool{ID: tool.approvalID, CallID: tool.callID, Name: tool.name, Args: tool.args}
}

// askUserQuestions returns the answerable questions of an ask_user pause, or nil.
// A pause qualifies when it holds exactly one ask_user call, and that call carries
// at least one question with text. A question without text cannot be rendered, and
// an answer for it cannot be correlated, so it is dropped.
func askUserQuestions(tools []apia2a.HITLTool) []apia2a.HITLQuestion {
if len(tools) != 1 || tools[0].Name != "ask_user" {
return nil
}
questions := publicAskUserQuestions(tools[0].Args["questions"])
answerable := make([]apia2a.HITLQuestion, 0, len(questions))
for _, question := range questions {
if question.Question != "" {
answerable = append(answerable, question)
}
}
if len(answerable) == 0 {
return nil
}
return answerable
}

// pendingQuestionText joins the questions an ask_user call is waiting on.
func pendingQuestionText(questions []apia2a.HITLQuestion) string {
texts := make([]string, 0, len(questions))
for _, question := range questions {
texts = append(texts, question.Question)
}
return strings.Join(texts, "; ")
}

// hitlStatusText renders a pause as one human-readable line. An ask_user pause speaks
// for itself; every other pause names its tools so no pending tool stays hidden.
func hitlStatusText(tools []apia2a.HITLTool, hints []string) string {
if questions := pendingQuestionText(askUserQuestions(tools)); questions != "" {
return questions
}
names := make([]string, 0, len(tools))
for _, tool := range tools {
if tool.Name != "" {
names = append(names, tool.Name)
}
}
switch {
case len(hints) > 0 && len(names) > 0:
return fmt.Sprintf("%s (%s)", strings.Join(hints, "; "), strings.Join(names, ", "))
case len(hints) > 0:
return strings.Join(hints, "; ")
case len(names) > 0:
return fmt.Sprintf("Approval is required for tool(s): %s", strings.Join(names, ", "))
}
return genericHITLText
}

// BuildHITLStatusMessage: ADK confirmation DataParts → public HITL Message extension.
func BuildHITLStatusMessage(message *a2atype.Message, activated bool) *a2atype.Message {
if message == nil {
return nil
}
var tools []apia2a.HITLTool
var remote *RemoteHitlState
hint := "Human input is required before the agent can continue."
var hints []string
for _, part := range message.Parts {
data := asDataPart(part)
if data == nil || part.Metadata == nil {
Expand All @@ -412,7 +468,7 @@ func BuildHITLStatusMessage(message *a2atype.Message, activated bool) *a2atype.M
tool := parseConfirmationTool(data)
tools = append(tools, tool.asHitlTool())
if tool.hint != "" {
hint = tool.hint
hints = append(hints, tool.hint)
}
if candidate := ParseRemoteHitlState(tool.payload); candidate != nil {
remote = candidate
Expand All @@ -422,7 +478,11 @@ func BuildHITLStatusMessage(message *a2atype.Message, activated bool) *a2atype.M
return message
}

public := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart(hint))
// The text part carries the same information as the typed payload, so a client
// that did not activate the extension still learns what it is being asked.
text := hitlStatusText(tools, hints)

public := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart(text))
public.TaskID, public.ContextID = message.TaskID, message.ContextID
if !activated {
return public
Expand All @@ -444,14 +504,17 @@ func BuildHITLStatusMessage(message *a2atype.Message, activated bool) *a2atype.M
Questions: remote.AskUserRequest.Questions, Nested: nested,
})
}
if len(tools) == 1 && tools[0].Name == "ask_user" {
// An ask_user call with no answerable question becomes an approval: an empty
// question list gives a request that no response can satisfy, because resume
// requires answers.
if questions := askUserQuestions(tools); questions != nil {
return AttachHitlExtension(public, &apia2a.AskUserRequest{
Type: HITLTypeAskUserRequest, ID: tools[0].ID,
Questions: publicAskUserQuestions(tools[0].Args["questions"]),
Questions: questions,
})
}
return AttachHitlExtension(public, &apia2a.ToolApprovalRequest{
Type: HITLTypeToolApprovalRequest, Hint: hint, Tools: tools, Nested: nested,
Type: HITLTypeToolApprovalRequest, Hint: text, Tools: tools, Nested: nested,
})
}

Expand Down
Loading
Loading