Runner.Run does not resume direct WorkflowAgent graph root after RequestInput interrupt with native join
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.
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 -vmain_test.go (self-contained; only ADK + genai deps)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 resolvedNo 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 Cb 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:281—Workflow.Runworkflow/workflow.go:309—Workflow.RunNodeagent/workflowagent/workflow.go:96— workflow-agent dispatchrunner/runner.go:181—Runner.Runrunner/run_node.go:132— state reconstruction / resume selectionworkflow/scheduler.go:822— waiting/completion transitionworkflow/scheduler.go:855— successor schedulingrunner/runner.go:704-736—findAgentToRunrunner/runner.go:706-712—FunctionResponse-associated event selects an agentagent/workflowagent/workflow.go:96-110—Workflow.Resumedispatchagent/workflowagent/workflow.go:125-155—detectResume
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 detectResume → Workflow.Resume) is bypassed on the resume turn.
Related work
- Issue #1125
- PR #1129 — fixes a related nested-
WorkflowAgentHITL resume problem inrunner/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.
Source: google/adk-go