runner: RunLive silently drops buffered tool calls when the stream ends mid-transcription
- Dominant language
- Go
- Stars
- 8.8k
- Forks
- 1k
- Avg merge
- 3d 18h
- Merged PRs (30d)
- 88
Description
## 🔴 Required Information
**Describe the Bug:**
`Runner.RunLive` buffers tool calls that arrive while a transcription is in
flight (`runner/runner.go:982-985`). It flushes them only when a non-partial
transcription event arrives (`:987-1013`). If the stream ends before that event,
the buffer is dropped. The loop at `:931-1029` has no flush after it.
The consumer is never told. It sees a clean end of stream and no error. The tool
call is never delivered, and it is never written to the session, so it is
missing from the history on the next turn too.
**Steps to Reproduce:**
Save as `runner/bufdrop_test.go` and run
`go test ./runner/ -run TestRunLive_BufferedToolCallDroppedWhenStreamEnds -v`.
```go
func TestRunLive_BufferedToolCallDroppedWhenStreamEnds(t *testing.T) {
ctx := context.Background()
ss := session.InMemoryService()
if _, err := ss.Create(ctx, &session.CreateRequest{
AppName: "app", UserID: "u", SessionID: "s",
}); err != nil {
t.Fatal(err)
}
base := must(agent.New(agent.Config{Name: "live_agent"}))
mockLive := &mockLiveAgent{
Agent: base,
runLiveFn: func(ic agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) {
return &dummyLiveSession{}, func(yield func(*session.Event, error) bool) {
// 1. A partial transcription starts. isTranscribing = true.
partial := session.NewEvent(ic, ic.InvocationID())
partial.LLMResponse = model.LLMResponse{
Partial: true,
InputTranscription: &genai.Transcription{Text: "book me a "},
}
if !yield(partial, nil) {
return
}
// 2. A tool call arrives mid-transcription. It gets buffered.
call := session.NewEvent(ic, ic.InvocationID())
call.LLMResponse = model.LLMResponse{Content: &genai.Content{
Parts: []*genai.Part{{FunctionCall: &genai.FunctionCall{Name: "book_flight"}}},
}}
if !yield(call, nil) {
return
}
// 3. The stream ends before the final transcription arrives.
}, nil
},
}
r, err := New(Config{AppName: "app", Agent: mockLive, SessionService: ss})
if err != nil {
t.Fatal(err)
}
_, stream, err := r.RunLive(ctx, "u", "s", agent.LiveRunConfig{})
if err != nil {
t.Fatal(err)
}
var delivered []string
for ev, err := range stream {
if err != nil {
delivered = append(delivered, "error: "+err.Error())
continue
}
delivered = append(delivered, describe(ev))
}
got, _ := ss.Get(ctx, &session.GetRequest{AppName: "app", UserID: "u", SessionID: "s"})
persisted := 0
for range got.Session.Events().All() {
persisted++
}
t.Logf("consumer received: %v", delivered)
t.Logf("session persisted: %d event(s)", persisted)
for _, d := range delivered {
if d == "tool call book_flight" {
return
}
}
t.Fatalf("the tool call was never delivered and no error was reported; consumer got %v", delivered)
}
func describe(ev *session.Event) string {
if ev.LLMResponse.Content != nil {
for _, p := range ev.LLMResponse.Content.Parts {
if p.FunctionCall != nil {
return "tool call " + p.FunctionCall.Name
}
}
}
if ev.LLMResponse.InputTranscription != nil {
return fmt.Sprintf("transcription(partial=%v)", ev.LLMResponse.Partial)
}
return "other"
}
```
**Expected Behavior:**
Buffered events reach the consumer before `RunLive` returns, or the run ends
with an error naming what was dropped.
**Observed Behavior:**
```
--- FAIL: TestRunLive_BufferedToolCallDroppedWhenStreamEnds (0.00s)
bufdrop_test.go:76: consumer received: [transcription(partial=true)]
bufdrop_test.go:77: session persisted: 0 event(s)
bufdrop_test.go:85: the tool call was never delivered and no error was reported
```
**Environment Details:**
- ADK Library Version: `main` at ede87a2f
- OS: Linux
- Go Version: go1.26.6
**Model Information:** N/A, this is runner control flow.
---
## 🟡 Optional Information
**Severity:**
Only reachable when a live model interleaves a tool call with a transcription
and the stream then ends without a final non-partial transcription. A dropped
tool call is invisible: the user sees the model decline to act, and the session
history gives no clue why.
**Suggested fix:**
Flush after the loop. It runs on every exit path, including the early returns.
```go
defer func() {
for _, e := range bufferedEvents {
if err := r.sessionService.AppendEvent(iCtx, storedSession, e); err != nil {
yield(nil, fmt.Errorf("failed to add event to session: %w", err))
return
}
if !yield(e, nil) {
return
}
}
bufferedEvents = nil
}()
```
That is not correct as written. `yield` must not be called once it has returned
false, so the deferred flush has to know how the loop exited. A flag set at each
`return` that follows a false `yield` would carry it. Whoever picks this up
should decide between that and reporting the drop as an error instead.
**Note:**
Found while reviewing #1479, which changes the nil-event branch in the same
loop. Not caused by that PR; it reproduces on `main` with no nil event
involved.
Contributor guide
Research direction
Start in runner/runner.go at the RunLive loop around lines 931-1029, then add the reproducer as runner/bufdrop_test.go. Run go test ./runner/ -run TestRunLive_BufferedToolCallDroppedWhenStreamEnds -v; done means the buffered tool call reaches the consumer and is persisted, or the run reports an error describing the dropped event without yielding after termination.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend, testing-qa
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100