#1568·adk-go

Runner.Run does not resume direct WorkflowAgent graph root after RequestInput interrupt with native join

Author: chriskuyCreated Sep 11, 2026Updated Sep 12, 2026

Summary

Runner.Run does not resume a direct WorkflowAgent graph root after a RequestInput interrupt when the workflow uses a native join. Supplying the matching FunctionResponse on a subsequent Runner.Run resolves nothing at the workflow level: the prior child agent is selected instead of the root, Workflow.Resume at the root is never reached, the native join never reevaluates, and downstream nodes never execute.

Versions

Reproduced on google.golang.org/adk/v2 v2.1.0 and v2.3.0. The reproduction module below pins v2.3.0.

Reproduction

Graph (fake agents, no models, no network):

B ----\
       → join → D
C ----/

b interrupts with RequestInput, c completes normally, d should run after the join once b is resumed.

bash
cd /tmp/adk_root_repro   # module adk-root-repro, requires adk/v2 v2.3.0
GOCACHE=/tmp/digital-garden-gocache \
  go test -run TestRunnerResumeDoesNotResumeGraphRoot -count=1 -v
main_test.go (self-contained; only ADK + genai deps)
go
package repro

import (
	"context"
	"iter"
	"testing"

	"google.golang.org/adk/v2/agent"
	"google.golang.org/adk/v2/agent/workflowagent"
	"google.golang.org/adk/v2/model"
	"google.golang.org/adk/v2/runner"
	"google.golang.org/adk/v2/session"
	"google.golang.org/adk/v2/workflow"
	"google.golang.org/genai"
)

func scripted(name, output string, interrupt bool) agent.Agent {
	a, err := agent.New(agent.Config{Name: name, Run: func(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] {
		return func(yield func(*session.Event, error) bool) {
			if interrupt {
				if _, ok := workflow.ResumeOrRequestInput(agent.Promote(ctx), func(ev *session.Event) error { yield(ev, nil); return nil }, session.RequestInput{InterruptID: name, Message: "approve"}); ok != nil {
					return
				}
				yield(&session.Event{LLMResponse: model.LLMResponse{Content: genai.NewContentFromText(output, genai.RoleModel)}}, nil)
				return
			}
			ev := session.NewEvent(ctx, ctx.InvocationID())
			ev.Content = genai.NewContentFromText(output, genai.RoleModel)
			yield(ev, nil)
		}
	}})
	if err != nil {
		panic(err)
	}
	return a
}

func TestRunnerResumeDoesNotResumeGraphRoot(t *testing.T) {
	b := scripted("b", "B", true)
	c := scripted("c", "C", false)
	d := scripted("d", "D", false)
	bNode, _ := workflow.NewAgentNode(b, workflow.NodeConfig{})
	cNode, _ := workflow.NewAgentNode(c, workflow.NodeConfig{})
	dNode, _ := workflow.NewAgentNode(d, workflow.NodeConfig{})
	join := workflow.NewJoinNode("join")
	root, err := workflowagent.New(workflowagent.Config{Name: "root", SubAgents: []agent.Agent{b, c, d}, Edges: []workflow.Edge{
		{From: workflow.Start, To: bNode}, {From: workflow.Start, To: cNode},
		{From: bNode, To: join}, {From: cNode, To: join}, {From: join, To: dNode},
	}})
	if err != nil {
		t.Fatal(err)
	}
	svc := session.InMemoryService()
	r, err := runner.New(runner.Config{AppName: "repro", Agent: root, SessionService: svc, AutoCreateSession: true})
	if err != nil {
		t.Fatal(err)
	}
	ctx := context.Background()
	for ev, err := range r.Run(ctx, "u", "s", genai.NewContentFromText("start", genai.RoleUser), agent.RunConfig{}) {
		if err != nil {
			t.Fatal(err)
		}
	}
	msg := genai.NewContentFromFunctionResponse(workflow.WorkflowInputFunctionCallName, map[string]any{"response": "approved"}, genai.RoleUser)
	var sawD bool
	for ev, err := range r.Run(ctx, "u", "s", msg, agent.RunConfig{}) {
		if err != nil {
			t.Fatal(err)
		}
		if ev.Author == "d" {
			sawD = true
		}
	}
	if !sawD {
		t.Fatal("D never executed; workflow root was not resumed after RequestInput")
	}
}

Actual session history after the resume turn (from the test log):

before resume author=c ... waiting=<nil>
before resume author=b ... waiting=&{b approve <nil> <nil>}
after resume  author=c ... waiting=<nil>
after resume  author=b ... waiting=&{b approve <nil> <nil>}   # still waiting; nothing resolved

No event authored by d is ever produced.

Expected vs actual

Expected: the workflow root receives the FunctionResponse, Workflow.Resume resolves b, b's output is forwarded, the native join reevaluates, and d executes.

Actual: Runner.Run selects the prior child agent, the workflow root is bypassed, Workflow.Resume at the root is not reached, the native graph frontier does not advance, and d never executes.

Second failing shape

A multiple-wait diamond also fails:

    A
   / \
  B   C

b and c both enter waiting state; resuming b does not correctly preserve/return the remaining c continuation through the expected root workflow path. (Reported from the same investigation; the attached module covers the join case above.)

Observations (v2.1.0 line refs; same shape verified in v2.3.0)

  • workflow/workflow.go:281Workflow.Run
  • workflow/workflow.go:309Workflow.RunNode
  • agent/workflowagent/workflow.go:96 — workflow-agent dispatch
  • runner/runner.go:181Runner.Run
  • runner/run_node.go:132 — state reconstruction / resume selection
  • workflow/scheduler.go:822 — waiting/completion transition
  • workflow/scheduler.go:855 — successor scheduling
  • runner/runner.go:704-736findAgentToRun
  • runner/runner.go:706-712FunctionResponse-associated event selects an agent
  • agent/workflowagent/workflow.go:96-110Workflow.Resume dispatch
  • agent/workflowagent/workflow.go:125-155detectResume

Concretely, in v2.3.0 runner/runner.go:1133 (findAgentToRun), handleUserFunctionCallResponse matches the FunctionResponse to b's event and then r.rootAgent.FindAgent(event.Author) returns the child agent directly — so the root WorkflowAgent (and therefore detectResumeWorkflow.Resume) is bypassed on the resume turn.

Related work

  • Issue #1125
  • PR #1129 — fixes a related nested-WorkflowAgent HITL resume problem in runner/agent_node.go, but does not fix this direct workflow-root graph/join case.

Possible fix area (not guaranteed)

Runner resume dispatch could detect when a FunctionResponse resolves a pending RequestInput owned by a workflow root/node, route the turn back through the root WorkflowAgent, preserve the current FunctionResponse input, let workflowagent.detectResume invoke Workflow.Resume, and retain unmatched pending waits/native join state. This is a suggested area to look, not a confirmed fix.