Skip to content

Commit 465fe1b

Browse files
authored
feat: add platform TaskRunner seam for caller-controlled tool fan-out (#1050)
Add a platform.TaskRunner seam -- the concurrency companion to the WithTimeProvider / WithUUIDProvider seams -- so a caller can control how ADK runs its internal tool-call fan-out, and route handleFunctionCalls' parallel tool execution through it. - platform.RunTasks(ctx, tasks) runs a batch of independent tasks and blocks until all complete; with no runner installed it runs one goroutine per task (the unchanged default). - platform.WithTaskRunner(ctx, runner) lets a caller install its own runner -- for example to bound concurrency, dispatch tasks onto an existing executor, or run them sequentially in a single goroutine. - Each task is invoked with its own context.Context, so a runner can derive a distinct per-task context. The default runner passes the parent ctx to every task. - internal/llminternal tool fan-out (handleFunctionCalls) now executes via platform.RunTasks.
1 parent f73905c commit 465fe1b

3 files changed

Lines changed: 268 additions & 8 deletions

File tree

internal/llminternal/base_flow.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,14 +1057,13 @@ func (f *Flow) handleFunctionCalls(ctx agent.InvocationContext, toolsDict map[st
10571057
}
10581058

10591059
fnResponseEvents := make([]*session.Event, len(fnCalls))
1060-
var wg sync.WaitGroup
10611060

1061+
// Tool calls run via the context's task runner: concurrent goroutines by
1062+
// default, or a caller-installed runner (platform.WithTaskRunner).
1063+
tasks := make([]func(context.Context), len(fnCalls))
10621064
for i, fnCall := range fnCalls {
1063-
wg.Add(1)
1064-
go func(i int, fnCall *genai.FunctionCall) {
1065-
defer wg.Done()
1066-
1067-
sctx, span := telemetry.StartExecuteToolSpan(ctx, telemetry.StartExecuteToolSpanParams{
1065+
tasks[i] = func(taskCtx context.Context) {
1066+
sctx, span := telemetry.StartExecuteToolSpan(taskCtx, telemetry.StartExecuteToolSpanParams{
10681067
ToolName: fnCall.Name,
10691068
Args: fnCall.Args,
10701069
})
@@ -1210,9 +1209,9 @@ func (f *Flow) handleFunctionCalls(ctx agent.InvocationContext, toolsDict map[st
12101209
})
12111210

12121211
fnResponseEvents[i] = ev
1213-
}(i, fnCall)
1212+
}
12141213
}
1215-
wg.Wait()
1214+
platform.RunTasks(ctx, tasks)
12161215
mergedEvent, err = mergeParallelFunctionResponseEvents(fnResponseEvents)
12171216
if err != nil {
12181217
return mergedEvent, err

platform/exec.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package platform
16+
17+
import (
18+
"context"
19+
"sync"
20+
)
21+
22+
// TaskRunner runs a batch of independent tasks, invoking each one exactly
23+
// once and passing it a context.Context. An implementation must block until
24+
// every task has completed.
25+
//
26+
// Each task receives a context so the runner can give it its own per-task
27+
// context rather than sharing one. The default runner passes the same ctx it
28+
// was given to every task; a custom runner may instead derive a distinct
29+
// context per task.
30+
//
31+
// The default runner (used when none is installed on the context) runs the
32+
// tasks concurrently, one goroutine per task. A caller that wants to control
33+
// how the fan-out executes — for example to bound concurrency, dispatch onto
34+
// its own executor, or run the tasks sequentially in a single goroutine — can
35+
// install its own runner with WithTaskRunner.
36+
type TaskRunner func(ctx context.Context, tasks []func(context.Context))
37+
38+
// taskRunnerKey is the context key under which a TaskRunner is stored.
39+
type taskRunnerKey struct{}
40+
41+
// WithTaskRunner returns a copy of ctx that carries runner. RunTasks called
42+
// with the returned context, or any context derived from it, uses runner
43+
// instead of the default goroutine-based runner. A nil runner is ignored by
44+
// RunTasks, which then falls back to the default.
45+
//
46+
// WithTaskRunner is the concurrency analog of WithTimeProvider and
47+
// WithUUIDProvider: it lets a host environment substitute its own execution
48+
// strategy for ADK's internal fan-out without the ADK runtime taking a
49+
// dependency on that environment.
50+
func WithTaskRunner(ctx context.Context, runner TaskRunner) context.Context {
51+
return context.WithValue(ctx, taskRunnerKey{}, runner)
52+
}
53+
54+
// RunTasks runs every task in tasks and blocks until all of them complete. If
55+
// ctx carries a TaskRunner installed with WithTaskRunner, that runner is used;
56+
// otherwise RunTasks runs the tasks concurrently on one goroutine each.
57+
//
58+
// Each task is invoked with a context.Context. Under the default runner every
59+
// task receives ctx; an installed runner may instead pass each task its own
60+
// per-task context. Under the default runner, the tasks run concurrently and
61+
// must be safe to do so. An empty or nil slice returns without doing anything.
62+
//
63+
// RunTasks enforces the completion barrier itself rather than relying on the
64+
// runner: it wraps each task to signal a sync.WaitGroup on return and waits on
65+
// that group after the runner returns. Callers may therefore read per-task
66+
// results as soon as RunTasks returns, even if a custom runner does not block
67+
// until its tasks finish.
68+
func RunTasks(ctx context.Context, tasks []func(context.Context)) {
69+
if len(tasks) == 0 {
70+
return
71+
}
72+
var wg sync.WaitGroup
73+
wg.Add(len(tasks))
74+
if ctx != nil {
75+
if r, ok := ctx.Value(taskRunnerKey{}).(TaskRunner); ok && r != nil {
76+
wrapped := make([]func(context.Context), len(tasks))
77+
for i, task := range tasks {
78+
wrapped[i] = func(c context.Context) {
79+
defer wg.Done()
80+
task(c)
81+
}
82+
}
83+
r(ctx, wrapped)
84+
wg.Wait()
85+
return
86+
}
87+
}
88+
for _, task := range tasks {
89+
go func() {
90+
defer wg.Done()
91+
task(ctx)
92+
}()
93+
}
94+
wg.Wait()
95+
}

platform/exec_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package platform_test
16+
17+
import (
18+
"context"
19+
"sync"
20+
"sync/atomic"
21+
"testing"
22+
23+
"google.golang.org/adk/v2/platform"
24+
)
25+
26+
func TestRunTasksDefaultRunsAllTasks(t *testing.T) {
27+
const n = 16
28+
var count atomic.Int64
29+
seen := make([]bool, n)
30+
var mu sync.Mutex
31+
32+
tasks := make([]func(context.Context), n)
33+
for i := range tasks {
34+
tasks[i] = func(context.Context) {
35+
count.Add(1)
36+
mu.Lock()
37+
seen[i] = true
38+
mu.Unlock()
39+
}
40+
}
41+
platform.RunTasks(context.Background(), tasks)
42+
43+
if got := count.Load(); got != n {
44+
t.Fatalf("ran %d tasks, want %d", got, n)
45+
}
46+
for i, ok := range seen {
47+
if !ok {
48+
t.Errorf("task %d did not run", i)
49+
}
50+
}
51+
}
52+
53+
func TestRunTasksEmptyIsNoOp(t *testing.T) {
54+
// Neither a nil nor an empty slice should block or panic.
55+
platform.RunTasks(context.Background(), nil)
56+
platform.RunTasks(context.Background(), []func(context.Context){})
57+
}
58+
59+
func TestWithTaskRunnerIsUsed(t *testing.T) {
60+
const n = 5
61+
var order []int
62+
used := false
63+
64+
// A sequential runner: runs every task inline, in slice order, on the
65+
// caller's goroutine. This is the shape a caller installs to keep ADK's
66+
// tool fan-out off goroutines, e.g. to bound concurrency or run on its own
67+
// executor.
68+
seq := func(ctx context.Context, tasks []func(context.Context)) {
69+
used = true
70+
for _, task := range tasks {
71+
task(ctx)
72+
}
73+
}
74+
75+
ctx := platform.WithTaskRunner(context.Background(), seq)
76+
tasks := make([]func(context.Context), n)
77+
for i := range tasks {
78+
tasks[i] = func(context.Context) {
79+
order = append(order, i) // safe: no concurrency under the sequential runner
80+
}
81+
}
82+
platform.RunTasks(ctx, tasks)
83+
84+
if !used {
85+
t.Fatal("installed TaskRunner was not used")
86+
}
87+
for i := 0; i < n; i++ {
88+
if order[i] != i {
89+
t.Fatalf("sequential runner ran out of order: got %v", order)
90+
}
91+
}
92+
}
93+
94+
func TestWithTaskRunnerNilFallsBack(t *testing.T) {
95+
var count atomic.Int64
96+
ctx := platform.WithTaskRunner(context.Background(), nil)
97+
tasks := []func(context.Context){
98+
func(context.Context) { count.Add(1) },
99+
func(context.Context) { count.Add(1) },
100+
func(context.Context) { count.Add(1) },
101+
}
102+
platform.RunTasks(ctx, tasks)
103+
if got := count.Load(); got != 3 {
104+
t.Fatalf("ran %d tasks, want 3", got)
105+
}
106+
}
107+
108+
// sentinelKey is a context key used by the per-task context tests.
109+
type sentinelKey struct{}
110+
111+
func TestRunTasksDefaultPassesContext(t *testing.T) {
112+
// With no installed runner, the default runner must invoke every task with
113+
// a context derived from (carrying the values of) the parent ctx.
114+
const n = 8
115+
const want = "sentinel-value"
116+
parent := context.WithValue(context.Background(), sentinelKey{}, want)
117+
118+
var mu sync.Mutex
119+
got := make([]any, n)
120+
tasks := make([]func(context.Context), n)
121+
for i := range tasks {
122+
tasks[i] = func(taskCtx context.Context) {
123+
v := taskCtx.Value(sentinelKey{})
124+
mu.Lock()
125+
got[i] = v
126+
mu.Unlock()
127+
}
128+
}
129+
platform.RunTasks(parent, tasks)
130+
131+
for i, v := range got {
132+
if v != want {
133+
t.Errorf("task %d received sentinel %v, want %q", i, v, want)
134+
}
135+
}
136+
}
137+
138+
func TestWithTaskRunnerReceivesPerTaskContext(t *testing.T) {
139+
// An installed runner can hand each task its own distinct context, e.g. to
140+
// scope per-task cancellation, deadlines, or values to each fanned-out task
141+
// instead of sharing one.
142+
const n = 6
143+
144+
// perTask gives task i a context carrying the value i.
145+
perTask := func(ctx context.Context, tasks []func(context.Context)) {
146+
for i, task := range tasks {
147+
task(context.WithValue(ctx, sentinelKey{}, i))
148+
}
149+
}
150+
151+
ctx := platform.WithTaskRunner(context.Background(), perTask)
152+
got := make([]any, n)
153+
tasks := make([]func(context.Context), n)
154+
for i := range tasks {
155+
tasks[i] = func(taskCtx context.Context) {
156+
got[i] = taskCtx.Value(sentinelKey{}) // safe: sequential, no concurrency
157+
}
158+
}
159+
platform.RunTasks(ctx, tasks)
160+
161+
for i, v := range got {
162+
if v != i {
163+
t.Errorf("task %d observed per-task value %v, want %d", i, v, i)
164+
}
165+
}
166+
}

0 commit comments

Comments
 (0)