-
Notifications
You must be signed in to change notification settings - Fork 552
Description
Describe the Bug:
maybeSaveOutputToState in llmagent.go uses !event.Partial to gate writing the agent's OutputKey to StateDelta. This condition is true for any non-partial event, including function-call and function-response events. These events have no text content, so the method writes "" (empty string) to StateDelta[OutputKey], overwriting any value previously stored there by an upstream agent.
The Python ADK correctly uses event.is_final_response(), which returns False for function-call and function-response events, preventing the overwrite.
Steps to Reproduce:
- Create a sequential pipeline with two agents sharing the same
output_key:- Agent A (e.g., a ToolAgent) writes
"SECRET_42"to state viaoutput_key - Agent B (an LLM agent) has a tool that reads the same state key, plus the same
output_key
- Agent A (e.g., a ToolAgent) writes
- Agent B calls its tool — this emits a function-call event (non-partial, no text)
maybeSaveOutputToStatefires on the function-call event:!event.Partialistrue,event.Content.Partshas aFunctionCallpart but noText, sosb.String()is""StateDelta[output_key] = ""— clobbering"SECRET_42"- When the tool reads state, it gets
""instead of"SECRET_42"
Expected Behavior:
maybeSaveOutputToState should only write to StateDelta on the agent's final text response, not on intermediate function-call or function-response events. This is what event.IsFinalResponse() guarantees — it checks !hasFunctionCalls && !hasFunctionResponses && !Partial && !hasTrailingCodeExecutionResult.
Observed Behavior:
The state key written by an upstream agent is overwritten with "" on every function-call event, because !event.Partial does not exclude function-call/response events.
Environment Details:
- ADK Library Version:
mainat8250f6f - OS: macOS (darwin/arm64)
- Go Version: 1.25.1
Model Information:
- Any model that uses tool calling (reproduced with Azure OpenAI gpt-4o-mini and gpt-4o)
Regression:
N/A — the condition has been !event.Partial since the initial implementation.
Logs:
# Event trace showing the clobbering:
# 1. ToolAgent writes: StateDelta["shared_key"] = "SECRET_42" ✅
# 2. LLM emits FunctionCall event (Partial=false, no text)
# maybeSaveOutputToState: !event.Partial=true → writes StateDelta["shared_key"] = "" ❌
# 3. Tool reads state["shared_key"] → gets "" instead of "SECRET_42"
Additional Context:
The Python ADK's equivalent method uses event.is_final_response() (source), which returns False when the event contains function calls or function responses. The Go ADK already has IsFinalResponse() on session.Event with identical semantics — it just isn't being used here.
Fix:
One-line change in agent/llmagent/llmagent.go:392:
- if a.OutputKey != "" && !event.Partial && event.Content != nil && len(event.Content.Parts) > 0 {
+ if a.OutputKey != "" && event.IsFinalResponse() && event.Content != nil && len(event.Content.Parts) > 0 {Minimal Reproduction Code:
package main
import (
"context"
"fmt"
"strings"
"google.golang.org/adk/agent/llmagent"
"google.golang.org/adk/agent/workflowagents/sequentialagent"
"google.golang.org/adk/runner"
"google.golang.org/adk/session"
"google.golang.org/adk/tool"
"google.golang.org/adk/tool/functiontool"
)
func main() {
// Agent 1: writes "SECRET_42" to state via OutputKey
agent1, _ := llmagent.New(llmagent.Config{
Name: "writer",
Model: yourModel, // any model
OutputKey: "shared_key",
Instruction: "Always respond with exactly: SECRET_42",
})
// Tool that reads state
type Args struct {
Key string `json:"key" jsonschema:"The state key to read"`
}
readTool, _ := functiontool.New(functiontool.Config{
Name: "read_state",
Description: "Read a value from session state by key.",
}, func(ctx tool.Context, args Args) (string, error) {
val, _ := ctx.State().Get(args.Key)
return fmt.Sprintf("%v", val), nil
})
// Agent 2: reads state via tool, same OutputKey
agent2, _ := llmagent.New(llmagent.Config{
Name: "reader",
Model: yourModel,
OutputKey: "shared_key",
Tools: []tool.Tool{readTool},
Instruction: "Use read_state with key 'shared_key' and repeat the value.",
})
// Sequential: writer → reader
seq, _ := sequentialagent.New(sequentialagent.Config{
Name: "pipeline",
SubAgents: []agent.Agent{agent1, agent2},
})
r, _ := runner.New(&runner.Config{Agent: seq})
sess, _ := session.NewInMemory().Create(context.Background(), &session.CreateRequest{})
for ev, _ := range r.RunStream(context.Background(), sess, "Go!") {
if ev.Content != nil {
for _, p := range ev.Content.Parts {
if p.Text != "" {
fmt.Printf("[%s] %s\n", ev.Author, p.Text)
}
}
}
}
// BUG: reader's tool gets "" instead of "SECRET_42"
// because the FunctionCall event wrote "" to shared_key
val, _ := sess.State().Get("shared_key")
fmt.Printf("Final state: %v\n", val)
if !strings.Contains(fmt.Sprintf("%v", val), "SECRET_42") {
fmt.Println("BUG: state was clobbered by function-call event")
}
}How often has this issue occurred?:
- Always (100%) — any sequential pipeline where two agents share an
OutputKeyand the downstream agent uses tools will hit this.