Skip to content

Commit 2a4fd24

Browse files
committed
feat(workflow): park parent when a WaitForOutput child yields no output
RunNode now honors NodeConfig.WaitForOutput: a child that opts in and finishes without producing output parks the parent with ErrNodeInterrupted instead of returning the zero value, so it re-runs and re-invokes RunNode once the child can produce output. Mirrors adk-python's ctx.run_node(raise_on_wait=True). Tracked via a sawOutput flag (nil is a valid output) and a waitsForOutput helper reading the tri-state config.
1 parent afa7eef commit 2a4fd24

3 files changed

Lines changed: 98 additions & 0 deletions

File tree

workflow/dynamic_scheduler.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ func (s *dynamicSubScheduler) runNode(child Node, input any, opts runNodeOptions
186186

187187
var (
188188
out any
189+
sawOutput bool
189190
interrupted bool
190191
)
191192
for ev, evErr := range child.Run(childCtx, input) {
@@ -216,6 +217,7 @@ func (s *dynamicSubScheduler) runNode(child Node, input any, opts runNodeOptions
216217
}
217218
if childOut, ok := childEventOutput(ev); ok {
218219
out = childOut
220+
sawOutput = true
219221
// Stamp OutputFor so resume can attribute the output: the
220222
// emitter's own path plus, under delegation, this parent and
221223
// its ancestors (the parent then suppresses its own terminal
@@ -247,11 +249,29 @@ func (s *dynamicSubScheduler) runNode(child Node, input any, opts runNodeOptions
247249
}
248250
}
249251

252+
// A WaitForOutput child that produced no output is not done: park the
253+
// parent (not terminal, not cached) so it re-runs and re-invokes
254+
// RunNode once the child can produce output. Mirrors adk-python
255+
// ctx.run_node(raise_on_wait=True).
256+
if !sawOutput && waitsForOutput(child) {
257+
return nil, &NodeRunError{
258+
ChildName: name, ChildPath: childPath, RunID: runID,
259+
Cause: ErrNodeInterrupted,
260+
}
261+
}
262+
250263
s.storeCachedOutput(childPath, out)
251264
s.commitDelegation(childPath, out) // no-op unless this child claimed the delegation
252265
return out, nil
253266
}
254267

268+
// waitsForOutput reports whether node opts into WaitForOutput (tri-state
269+
// pointer; nil means the engine default of false).
270+
func waitsForOutput(node Node) bool {
271+
w := node.Config().WaitForOutput
272+
return w != nil && *w
273+
}
274+
255275
func (s *dynamicSubScheduler) lookupCachedOutput(childPath string) (any, bool) {
256276
s.mu.Lock()
257277
defer s.mu.Unlock()

workflow/dynamic_scheduler_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,41 @@ func (n *interruptThenFailNode) Run(agent.InvocationContext, any) iter.Seq2[*ses
288288
yield(nil, errors.New("boom"))
289289
}
290290
}
291+
292+
// waitForOutputNode has WaitForOutput=true and emits a state-only event
293+
// (no Output), modeling an LlmAgent task/chat node that has not yet
294+
// produced its final output.
295+
type waitForOutputNode struct{ BaseNode }
296+
297+
func newWaitForOutputNode(name string) *waitForOutputNode {
298+
t := true
299+
return &waitForOutputNode{BaseNode: NewBaseNode(name, "", NodeConfig{WaitForOutput: &t})}
300+
}
301+
302+
func (n *waitForOutputNode) Run(agent.InvocationContext, any) iter.Seq2[*session.Event, error] {
303+
return func(yield func(*session.Event, error) bool) {
304+
yield(&session.Event{}, nil) // state-only: no Output, no RequestedInput
305+
}
306+
}
307+
308+
// waitForOutputWithValueNode has WaitForOutput=true and does emit an
309+
// Output, so RunNode must complete it normally.
310+
type waitForOutputWithValueNode struct {
311+
BaseNode
312+
out any
313+
}
314+
315+
func newWaitForOutputWithValueNode(name string, out any) *waitForOutputWithValueNode {
316+
t := true
317+
return &waitForOutputWithValueNode{
318+
BaseNode: NewBaseNode(name, "", NodeConfig{WaitForOutput: &t}),
319+
out: out,
320+
}
321+
}
322+
323+
func (n *waitForOutputWithValueNode) Run(agent.InvocationContext, any) iter.Seq2[*session.Event, error] {
324+
out := n.out
325+
return func(yield func(*session.Event, error) bool) {
326+
yield(&session.Event{Output: out}, nil)
327+
}
328+
}

workflow/run_node_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,46 @@ func TestRunNode_PropagatesErrNodeInterrupted(t *testing.T) {
6767
}
6868
}
6969

70+
func TestRunNode_WaitForOutputChildWithNoOutput_ParksParent(t *testing.T) {
71+
// A WaitForOutput child that finishes without producing output must
72+
// park the parent (ErrNodeInterrupted), not falsely complete it with
73+
// the zero value. Mirrors adk-python ctx.run_node(raise_on_wait=True).
74+
child := newWaitForOutputNode("waiter")
75+
_, err := runInOrchestratorWithErr[string](t, func(ctx NodeContext) (string, error) {
76+
return RunNode[string](ctx, child, nil)
77+
})
78+
if !errors.Is(err, ErrNodeInterrupted) {
79+
t.Errorf("err = %v, want errors.Is ErrNodeInterrupted", err)
80+
}
81+
}
82+
83+
func TestRunNode_WaitForOutputChildWithOutput_Completes(t *testing.T) {
84+
// A WaitForOutput child that does produce output completes normally;
85+
// the gate must only fire on missing output.
86+
child := newWaitForOutputWithValueNode("waiter", "done")
87+
got := runInOrchestrator[string](t, func(ctx NodeContext) (string, error) {
88+
return RunNode[string](ctx, child, nil)
89+
})
90+
if got != "done" {
91+
t.Errorf("RunNode output = %q, want %q", got, "done")
92+
}
93+
}
94+
95+
func TestRunNode_NoWaitForOutputChildWithNoOutput_ReturnsZero(t *testing.T) {
96+
// Without WaitForOutput, a child that emits no output still completes
97+
// and yields the zero value — the gate must not change this default.
98+
child := newStubNode("c", nil)
99+
got, err := runInOrchestratorWithErr[string](t, func(ctx NodeContext) (string, error) {
100+
return RunNode[string](ctx, child, nil)
101+
})
102+
if err != nil {
103+
t.Fatalf("unexpected error: %v", err)
104+
}
105+
if got != "" {
106+
t.Errorf("RunNode output = %q, want zero value", got)
107+
}
108+
}
109+
70110
func TestRunNode_PropagatesErrNodeFailed(t *testing.T) {
71111
failer := newFailingNode("failer", errors.New("boom"))
72112
_, err := runInOrchestratorWithErr[string](t, func(ctx NodeContext) (string, error) {

0 commit comments

Comments
 (0)