Skip to content

maybeSaveOutputToState overwrites OutputKey with empty string on function-call events #577

@dannovikov

Description

@dannovikov

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:

  1. Create a sequential pipeline with two agents sharing the same output_key:
    • Agent A (e.g., a ToolAgent) writes "SECRET_42" to state via output_key
    • Agent B (an LLM agent) has a tool that reads the same state key, plus the same output_key
  2. Agent B calls its tool — this emits a function-call event (non-partial, no text)
  3. maybeSaveOutputToState fires on the function-call event: !event.Partial is true, event.Content.Parts has a FunctionCall part but no Text, so sb.String() is ""
  4. StateDelta[output_key] = "" — clobbering "SECRET_42"
  5. 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: main at 8250f6f
  • 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 OutputKey and the downstream agent uses tools will hit this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions