From d2bedd0d8fc3caded45f2f1f7af1ac69feb9d66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Mon, 23 Mar 2026 13:46:45 +0000 Subject: [PATCH 01/86] Conformance fixes for change to yaml format and progressive sse (#672) Co-authored-by: Mikalai Senkevich --- .../configurable/conformance/functions.go | 68 +++++++++---------- .../replayplugin/invocation_replay_state.go | 4 ++ .../replayplugin/recording/recording.go | 2 +- .../conformance/replayplugin/replay_plugin.go | 65 ++++++++++++++---- .../replayplugin/replay_plugin_test.go | 10 +-- tool/exitlooptool/tool.go | 2 +- 6 files changed, 95 insertions(+), 56 deletions(-) diff --git a/internal/configurable/conformance/functions.go b/internal/configurable/conformance/functions.go index e66f4c0f4..a89eceff6 100644 --- a/internal/configurable/conformance/functions.go +++ b/internal/configurable/conformance/functions.go @@ -236,15 +236,15 @@ func RegisterFunctions() error { Name: "create_booking", Description: `Creates a booking for a user. - Args: - user_id: The unique identifier for the user. - is_confirmed: Whether the booking is confirmed. - details: Any additional details for the booking. - - Returns: - A dictionary containing the booking information and the types of the - received arguments. - `, +Args: + user_id: The unique identifier for the user. + is_confirmed: Whether the booking is confirmed. + details: Any additional details for the booking. + +Returns: + A dictionary containing the booking information and the types of the + received arguments. +`, }, createBooking) if err != nil { return fmt.Errorf("error creating create booking tool: %w", err) @@ -254,18 +254,18 @@ func RegisterFunctions() error { Name: "search_flights", Description: `Search for flights based on trip details and preferences. - This function demonstrates advanced parameter handling: - - Pydantic models as parameters (trip, preferences) - - Optional/nullable parameters (preferences, return_date, preferred_airline) - - Default values (cabin_class, max_stops, flexible_dates) +This function demonstrates advanced parameter handling: +- Pydantic models as parameters (trip, preferences) +- Optional/nullable parameters (preferences, return_date, preferred_airline) +- Default values (cabin_class, max_stops, flexible_dates) - Args: - trip: Core trip information including origin, destination, and dates. - preferences: Optional flight preferences. If not provided, uses defaults. +Args: + trip: Core trip information including origin, destination, and dates. + preferences: Optional flight preferences. If not provided, uses defaults. - Returns: - A dictionary containing search results and parameters received. - `, +Returns: + A dictionary containing search results and parameters received. +`, }, searchFlights) if err != nil { return fmt.Errorf("error creating search flights tool: %w", err) @@ -275,21 +275,21 @@ func RegisterFunctions() error { Name: "calculate_trip_cost", Description: `Calculate total trip cost with various optional charges. - This function demonstrates: - - Mix of required and optional parameters - - Default values for common cases - - Nullable parameter that affects calculation logic - - Args: - base_fare: Base ticket price per passenger. - num_passengers: Number of passengers (default: 1). - insurance: Whether to add travel insurance (default: False). - baggage_count: Number of checked bags per passenger, or None for carry-on - only. - - Returns: - A dictionary with cost breakdown. - `, +This function demonstrates: +- Mix of required and optional parameters +- Default values for common cases +- Nullable parameter that affects calculation logic + +Args: + base_fare: Base ticket price per passenger. + num_passengers: Number of passengers (default: 1). + insurance: Whether to add travel insurance (default: False). + baggage_count: Number of checked bags per passenger, or None for carry-on + only. + +Returns: + A dictionary with cost breakdown. +`, }, calculateTripCost) if err != nil { return fmt.Errorf("error creating calculate trip cost tool: %w", err) diff --git a/internal/configurable/conformance/replayplugin/invocation_replay_state.go b/internal/configurable/conformance/replayplugin/invocation_replay_state.go index 898de7443..6b4d4408e 100644 --- a/internal/configurable/conformance/replayplugin/invocation_replay_state.go +++ b/internal/configurable/conformance/replayplugin/invocation_replay_state.go @@ -30,6 +30,9 @@ type invocationReplayState struct { // key: agent_name -> current replay index for that agent agentReplayIndices map[string]int + // Track consumed recordings by index in recordings.Recordings + consumedRecordings map[int]bool + curIndex int mu sync.Mutex cond *sync.Cond @@ -42,6 +45,7 @@ func newInvocationReplayState(testCasePath string, userMessageIndex int, recs *r userMessageIndex: userMessageIndex, recordings: recs, agentReplayIndices: make(map[string]int), + consumedRecordings: make(map[int]bool), curIndex: 0, mu: sync.Mutex{}, } diff --git a/internal/configurable/conformance/replayplugin/recording/recording.go b/internal/configurable/conformance/replayplugin/recording/recording.go index b4bc56dad..98eaed777 100644 --- a/internal/configurable/conformance/replayplugin/recording/recording.go +++ b/internal/configurable/conformance/replayplugin/recording/recording.go @@ -54,7 +54,7 @@ type LLMRecording struct { LLMRequest *model.LLMRequest `yaml:"llmrequest,omitempty"` // Required. The LLM response. - LLMResponse *model.LLMResponse `yaml:"llmresponse,omitempty"` + LLMResponses []*model.LLMResponse `yaml:"llmresponses,omitempty"` } // ToolRecording represents a paired tool call and response. diff --git a/internal/configurable/conformance/replayplugin/replay_plugin.go b/internal/configurable/conformance/replayplugin/replay_plugin.go index 43af9250a..ffda62baf 100644 --- a/internal/configurable/conformance/replayplugin/replay_plugin.go +++ b/internal/configurable/conformance/replayplugin/replay_plugin.go @@ -129,7 +129,7 @@ func (p *replayPlugin) beforeModel(ctx agent.CallbackContext, req *model.LLMRequ return nil, err } - return recording.LLMResponse, nil + return recording.LLMResponses[0], nil } // beforeTool intercepts tool calls, verifies them against the recording, and returns the recorded response. @@ -328,8 +328,12 @@ func (p *replayPlugin) loadInvocationState(ctx agent.InvocationContext) (*invoca prevMessageId = recordings.Recordings[i].UserMessageIndex index = 0 } - recordings.Recordings[i].Index = index - index++ + if recordings.Recordings[i].LLMRecording != nil { + recordings.Recordings[i].Index = index + index++ + } else { + recordings.Recordings[i].Index = -1 // Not used for sync + } } // 4. Create and Store State @@ -507,27 +511,58 @@ func modifyString(input string) string { }) } -// verifyAndGetNextToolRecordingForAgent ensures the next recording is a tool call and matches the actual call. -func (p *replayPlugin) verifyAndGetNextToolRecordingForAgent(state *invocationReplayState, agentName string, t tool.Tool, args map[string]any) (*recording.ToolRecording, error) { - currentAgentIndex, ok := state.GetAgentReplayIndex(agentName) - if !ok { - currentAgentIndex = 0 +// getNextToolRecordingForAgent retrieves the next unconsumed tool recording that matches the given function. +func getNextToolRecordingForAgent(state *invocationReplayState, agentName string, matchFn func(*recording.Recording) (bool, error)) (*recording.Recording, error) { + state.mu.Lock() + defer state.mu.Unlock() + + var firstError error + + for i := range state.recordings.Recordings { + rec := &state.recordings.Recordings[i] + if rec.UserMessageIndex != state.userMessageIndex || rec.AgentName != agentName { + continue + } + if state.consumedRecordings[i] { + continue + } + + matched, err := matchFn(rec) + if matched { + state.consumedRecordings[i] = true + return rec, nil + } + if firstError == nil && err != nil { + firstError = err + } } - expectedRecording, err := getNextRecordingForAgent(state, agentName) - if err != nil { - return nil, err + + if firstError != nil { + return nil, firstError } - if expectedRecording.ToolRecording == nil { - return nil, fmt.Errorf("expected tool recording for agent '%s' at index %d, but found LLM recording", agentName, currentAgentIndex) + return nil, fmt.Errorf("no matching tool recording found for agent '%s' at user_message_index %d", agentName, state.userMessageIndex) +} + +// verifyAndGetNextToolRecordingForAgent ensures the next recording is a tool call and matches the actual call. +func (p *replayPlugin) verifyAndGetNextToolRecordingForAgent(state *invocationReplayState, agentName string, t tool.Tool, args map[string]any) (*recording.ToolRecording, error) { + matchFn := func(rec *recording.Recording) (bool, error) { + if rec.ToolRecording == nil { + return false, fmt.Errorf("expected tool recording for agent '%s', but found LLM recording", agentName) + } + err := verifyToolCallMatch(rec.ToolRecording.ToolCall, t.Name(), args, agentName, state.agentReplayIndices[agentName]) + return err == nil, err } - // Strict verification of tool call - err = verifyToolCallMatch(expectedRecording.ToolRecording.ToolCall, t.Name(), args, agentName, currentAgentIndex) + expectedRecording, err := getNextToolRecordingForAgent(state, agentName, matchFn) if err != nil { return nil, err } + state.mu.Lock() + state.agentReplayIndices[agentName]++ + state.mu.Unlock() + return expectedRecording.ToolRecording, nil } diff --git a/internal/configurable/conformance/replayplugin/replay_plugin_test.go b/internal/configurable/conformance/replayplugin/replay_plugin_test.go index 4e66e9666..83dc625d8 100644 --- a/internal/configurable/conformance/replayplugin/replay_plugin_test.go +++ b/internal/configurable/conformance/replayplugin/replay_plugin_test.go @@ -60,11 +60,11 @@ recordings: - role: "user" parts: - text: "Hello" - llm_response: - content: - role: "model" - parts: - - text: "Recorded response" + llm_responses: + - content: + role: "model" + parts: + - text: "Recorded response" ` createRecordingsFile(t, tempDir, recordingsYaml) diff --git a/tool/exitlooptool/tool.go b/tool/exitlooptool/tool.go index 06c5e787b..bb42ad2a7 100644 --- a/tool/exitlooptool/tool.go +++ b/tool/exitlooptool/tool.go @@ -32,7 +32,7 @@ func exitLoop(ctx tool.Context, myArgs struct{}) (map[string]string, error) { func New() (tool.Tool, error) { exitLoopTool, err := functiontool.New(functiontool.Config{ Name: "exit_loop", - Description: "Exits the loop.\n\n Call this function only when you are instructed to do so.\n ", + Description: "Exits the loop.\n\nCall this function only when you are instructed to do so.\n", }, exitLoop) if err != nil { return nil, fmt.Errorf("error creating exit loop tool: %w", err) From 68be1e5cc6b03814177602c80b32b5c055862260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Tue, 24 Mar 2026 16:53:23 +0000 Subject: [PATCH 02/86] fix: preserve error details and finish reason in streaming response aggregator and update test expectations (#678) --- .../parallel_function_call_test.go | 12 ++++++ internal/llminternal/stream_aggregator.go | 13 ++---- .../llminternal/stream_aggregator_test.go | 41 +++++++++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/internal/llminternal/parallel_function_call_test.go b/internal/llminternal/parallel_function_call_test.go index 1b0320f7d..80e1a767e 100644 --- a/internal/llminternal/parallel_function_call_test.go +++ b/internal/llminternal/parallel_function_call_test.go @@ -68,6 +68,7 @@ var expectedNonPartialLLMResponse25Flash = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, { Partial: false, @@ -94,6 +95,7 @@ var expectedNonPartialLLMResponse25Flash = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, { Partial: false, @@ -114,6 +116,7 @@ var expectedNonPartialLLMResponse25Flash = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, { Partial: false, @@ -140,6 +143,7 @@ var expectedNonPartialLLMResponse25Flash = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, } @@ -163,6 +167,7 @@ var expectedNonPartialLLMResponse3FlashPreview = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, { Partial: false, @@ -189,6 +194,7 @@ var expectedNonPartialLLMResponse3FlashPreview = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, { Partial: false, @@ -209,6 +215,7 @@ var expectedNonPartialLLMResponse3FlashPreview = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, { Partial: false, @@ -235,6 +242,7 @@ var expectedNonPartialLLMResponse3FlashPreview = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, } @@ -258,6 +266,7 @@ var expectedNonPartialLLMResponse3ProPreview = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, { Partial: false, @@ -285,6 +294,7 @@ var expectedNonPartialLLMResponse3ProPreview = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, { Partial: false, @@ -305,6 +315,7 @@ var expectedNonPartialLLMResponse3ProPreview = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, { Partial: false, @@ -332,6 +343,7 @@ var expectedNonPartialLLMResponse3ProPreview = []*model.LLMResponse{ }, Role: "model", }, + FinishReason: genai.FinishReasonStop, }, } diff --git a/internal/llminternal/stream_aggregator.go b/internal/llminternal/stream_aggregator.go index e579f8462..ac7b2d0af 100644 --- a/internal/llminternal/stream_aggregator.go +++ b/internal/llminternal/stream_aggregator.go @@ -303,16 +303,8 @@ func (s *streamingResponseAggregator) Close() *model.LLMResponse { errorCode := "" errorMessage := "" if s.finishReason != genai.FinishReasonStop { - if s.response.ErrorCode != "" { - errorCode = s.response.ErrorCode - } else { - errorCode = "error" - } - if s.response.ErrorMessage != "" { - errorMessage = s.response.ErrorMessage - } else { - errorMessage = "error" - } + errorCode = s.response.ErrorCode + errorMessage = s.response.ErrorMessage } return &model.LLMResponse{ @@ -325,6 +317,7 @@ func (s *streamingResponseAggregator) Close() *model.LLMResponse { CitationMetadata: s.citationMetadata, ErrorCode: errorCode, ErrorMessage: errorMessage, + FinishReason: s.finishReason, } } return nil diff --git a/internal/llminternal/stream_aggregator_test.go b/internal/llminternal/stream_aggregator_test.go index 550a97f37..0e18153ee 100644 --- a/internal/llminternal/stream_aggregator_test.go +++ b/internal/llminternal/stream_aggregator_test.go @@ -846,3 +846,44 @@ func TestPartialFunctionCallsNotExecutedInNoneStreamingMode(t *testing.T) { t.Errorf("Expected 1 function response event, got %d", functionResponseEvents) } } + +func TestFinishReasonUnexpectedToolCallPreservesErrorCode(t *testing.T) { + aggregator := llminternal.NewStreamingResponseAggregator() + ctx := t.Context() + + // Simulate an LLM chunk that reports UNEXPECTED_TOOL_CALL + chunk := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + { + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{{Text: "Some text"}}, + }, + FinishReason: genai.FinishReasonUnexpectedToolCall, + }, + }, + } + + for _, err := range aggregator.ProcessResponse(ctx, chunk) { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + finalResponse := aggregator.Close() + if finalResponse == nil { + t.Fatalf("Close should return a valid response") + } + + if finalResponse.FinishReason != genai.FinishReasonUnexpectedToolCall { + t.Errorf("Expected FinishReason '%s', got '%s'", genai.FinishReasonUnexpectedToolCall, finalResponse.FinishReason) + } + + if finalResponse.ErrorCode != "" { + t.Errorf("ErrorCode was unexpectedly overwritten to '%s'", finalResponse.ErrorCode) + } + + if finalResponse.ErrorMessage != "" { + t.Errorf("ErrorMessage was unexpectedly overwritten to '%s'", finalResponse.ErrorMessage) + } +} From 5d2993b7a36f01566087b402a292f963b032c1c1 Mon Sep 17 00:00:00 2001 From: Mikalai Senkevich Date: Wed, 25 Mar 2026 17:37:16 +0100 Subject: [PATCH 03/86] feat: Implement and test recursive agent lookup by name. (#626) * feat: Implement and test recursive agent lookup by name. * refactor: convert `TestFindAgent` to a table-driven test and enable parallel execution. * feat: Introduce a `FindAgent` method on `llmAgent` to locate sub-agents by name, replacing the `runner` package's internal `findAgent` helper. --- agent/agent.go | 18 ++++++++ agent/agent_test.go | 66 +++++++++++++++++++++++++++ agent/llmagent/llmagent.go | 8 ++++ agent/llmagent/llmagent_test.go | 79 +++++++++++++++++++++++++++++++++ agent/loader_test.go | 8 ++++ runner/runner.go | 17 +------ runner/runner_test.go | 47 -------------------- 7 files changed, 181 insertions(+), 62 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index cbe04e243..450389743 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -45,6 +45,8 @@ type Agent interface { Description() string Run(InvocationContext) iter.Seq2[*session.Event, error] SubAgents() []Agent + FindAgent(name string) Agent + FindSubAgent(name string) Agent internal() *agent } @@ -216,6 +218,22 @@ func (a *agent) internal() *agent { return a } +func (a *agent) FindAgent(name string) Agent { + if a.Name() == name { + return a + } + return a.FindSubAgent(name) +} + +func (a *agent) FindSubAgent(name string) Agent { + for _, subAgent := range a.SubAgents() { + if result := subAgent.FindAgent(name); result != nil { + return result + } + } + return nil +} + func getAuthorForEvent(ctx InvocationContext, event *session.Event) string { if event.LLMResponse.Content != nil && event.LLMResponse.Content.Role == genai.RoleUser { return genai.RoleUser diff --git a/agent/agent_test.go b/agent/agent_test.go index 6d7a4086b..5fbba5b3b 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -285,3 +285,69 @@ type mockSession struct { } func (m *mockSession) ID() string { return m.sessionID } + +func TestFindAgent(t *testing.T) { + t.Parallel() + + noOpRun := func(InvocationContext) iter.Seq2[*session.Event, error] { + return func(func(*session.Event, error) bool) {} + } + + createAgent := func(name string, subAgents ...Agent) Agent { + t.Helper() + a, err := New(Config{Name: name, Run: noOpRun, SubAgents: subAgents}) + if err != nil { + t.Fatalf("failed to create agent %s: %v", name, err) + } + return a + } + + // Setup hierarchy: + // root -> child1 + // root -> child2 -> grandchild + grandchild := createAgent("grandchild") + child2 := createAgent("child2", grandchild) + child1 := createAgent("child1") + root := createAgent("root", child1, child2) + + tests := []struct { + name string + agentName string + want Agent + }{ + { + name: "Find self", + agentName: "root", + want: root, + }, + { + name: "Find direct child1", + agentName: "child1", + want: child1, + }, + { + name: "Find direct child2", + agentName: "child2", + want: child2, + }, + { + name: "Find nested grandchild", + agentName: "grandchild", + want: grandchild, + }, + { + name: "Find non-existent agent", + agentName: "unknown", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := root.FindAgent(tt.agentName) + if got != tt.want { + t.Errorf("FindAgent(%q) = %v, want %v", tt.agentName, got, tt.want) + } + }) + } +} diff --git a/agent/llmagent/llmagent.go b/agent/llmagent/llmagent.go index de9955a4f..fab84e6ca 100644 --- a/agent/llmagent/llmagent.go +++ b/agent/llmagent/llmagent.go @@ -430,6 +430,14 @@ func (a *llmAgent) maybeSaveOutputToState(event *session.Event) { } } +// FindAgent finds a sub-agent by name. +func (a *llmAgent) FindAgent(name string) agent.Agent { + if a.Name() == name { + return a + } + return a.Agent.FindSubAgent(name) +} + // InstructionProvider allows to create instructions dynamically. It is called // on each agent invocation. // diff --git a/agent/llmagent/llmagent_test.go b/agent/llmagent/llmagent_test.go index b15ea51ef..1d434ad19 100644 --- a/agent/llmagent/llmagent_test.go +++ b/agent/llmagent/llmagent_test.go @@ -1114,3 +1114,82 @@ type roundTripperFunc func(*http.Request) (*http.Response, error) func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } + +func TestFindAgent(t *testing.T) { + t.Parallel() + + // Create a nested agent structure: + // root -> [child1, child2 -> [grandchild]] + grandchild, err := llmagent.New(llmagent.Config{Name: "grandchild"}) + if err != nil { + t.Fatalf("failed to create grandchild agent: %v", err) + } + + child1, err := llmagent.New(llmagent.Config{Name: "child1"}) + if err != nil { + t.Fatalf("failed to create child1 agent: %v", err) + } + + child2, err := llmagent.New(llmagent.Config{ + Name: "child2", + SubAgents: []agent.Agent{grandchild}, + }) + if err != nil { + t.Fatalf("failed to create child2 agent: %v", err) + } + + root, err := llmagent.New(llmagent.Config{ + Name: "root", + SubAgents: []agent.Agent{child1, child2}, + }) + if err != nil { + t.Fatalf("failed to create root agent: %v", err) + } + + tests := []struct { + name string + agentToSearch agent.Agent + targetName string + wantAgent agent.Agent + }{ + { + name: "find root itself", + agentToSearch: root, + targetName: "root", + wantAgent: root, + }, + { + name: "find direct child", + agentToSearch: root, + targetName: "child1", + wantAgent: child1, + }, + { + name: "find nested child", + agentToSearch: root, + targetName: "grandchild", + wantAgent: grandchild, + }, + { + name: "find non-existent agent", + agentToSearch: root, + targetName: "non_existent", + wantAgent: nil, + }, + { + name: "find child from child", + agentToSearch: child2, + targetName: "grandchild", + wantAgent: grandchild, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.agentToSearch.FindAgent(tc.targetName) + if got != tc.wantAgent { + t.Errorf("FindAgent(%q) = %v, want %v", tc.targetName, got, tc.wantAgent) + } + }) + } +} diff --git a/agent/loader_test.go b/agent/loader_test.go index b2cc3e768..a07c78a03 100644 --- a/agent/loader_test.go +++ b/agent/loader_test.go @@ -47,6 +47,14 @@ func (a *testAgent) internal() *agent { panic("not implemented") } +func (a *testAgent) FindAgent(name string) Agent { + panic("not implemented") +} + +func (a *testAgent) FindSubAgent(name string) Agent { + panic("not implemented") +} + func TestDuplicateName(t *testing.T) { agent1 := &testAgent{name: "weather_time_agent"} // duplicate name diff --git a/runner/runner.go b/runner/runner.go index 81dd9e6c3..3e29a7568 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -328,7 +328,7 @@ func (r *Runner) appendMessageToSession(ctx agent.InvocationContext, storedSessi // session history. func (r *Runner) findAgentToRun(session session.Session, msg *genai.Content) (agent.Agent, error) { if event := handleUserFunctionCallResponse(session.Events(), msg); event != nil { - subAgent := findAgent(r.rootAgent, event.Author) + subAgent := r.rootAgent.FindAgent(event.Author) if subAgent != nil { return subAgent, nil } @@ -343,7 +343,7 @@ func (r *Runner) findAgentToRun(session session.Session, msg *genai.Content) (ag continue } - subAgent := findAgent(r.rootAgent, event.Author) + subAgent := r.rootAgent.FindAgent(event.Author) // Agent not found, continue looking for the other event. if subAgent == nil { log.Printf("Event from an unknown agent: %s, event id: %s", event.Author, event.ID) @@ -401,16 +401,3 @@ func (r *Runner) isTransferableAcrossAgentTree(agentToRun agent.Agent) bool { return true } - -func findAgent(curAgent agent.Agent, targetName string) agent.Agent { - if curAgent == nil || curAgent.Name() == targetName { - return curAgent - } - - for _, subAgent := range curAgent.SubAgents() { - if agent := findAgent(subAgent, targetName); agent != nil { - return agent - } - } - return nil -} diff --git a/runner/runner_test.go b/runner/runner_test.go index 1df3c3f93..03eb70ec4 100644 --- a/runner/runner_test.go +++ b/runner/runner_test.go @@ -122,53 +122,6 @@ func TestRunner_findAgentToRun(t *testing.T) { } } -func Test_findAgent(t *testing.T) { - agentTree := agentTree(t) - - oneAgent := must(llmagent.New(llmagent.Config{ - Name: "test", - })) - - tests := []struct { - name string - root agent.Agent - target string - wantAgent agent.Agent - }{ - { - name: "ok", - root: agentTree.root, - target: agentTree.allowsTransferAgent.Name(), - wantAgent: agentTree.allowsTransferAgent, - }, - { - name: "finds in one node tree", - root: oneAgent, - target: oneAgent.Name(), - wantAgent: oneAgent, - }, - { - name: "doesn't fail if agent is missing in the tree", - root: agentTree.root, - target: "random", - wantAgent: nil, - }, - { - name: "doesn't fail on the empty tree", - root: nil, - target: "random", - wantAgent: nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if gotAgent := findAgent(tt.root, tt.target); gotAgent != tt.wantAgent { - t.Errorf("Runner.findAgent() = %+v, want %+v", gotAgent.Name(), tt.wantAgent.Name()) - } - }) - } -} - func Test_isTransferrableAcrossAgentTree(t *testing.T) { tests := []struct { name string From b8eb8c5f07cf43889a02bce2eac2d104a81fb927 Mon Sep 17 00:00:00 2001 From: Mikalai Senkevich Date: Wed, 25 Mar 2026 17:41:02 +0100 Subject: [PATCH 04/86] feat: add GetArtifactVersion method to artifact service (#575) * feat: add GetArtifactVersion method to artifact service * chore: remove adk-go main executable. * Update artifact/service.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Extract resolveVersion helper in gcsartifact. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- artifact/gcsartifact/service.go | 81 ++++++++++++++++++++++++----- artifact/inmemory.go | 39 ++++++++++++++ artifact/request_validation_test.go | 53 +++++++++++++++++++ artifact/service.go | 50 ++++++++++++++++++ 4 files changed, 210 insertions(+), 13 deletions(-) diff --git a/artifact/gcsartifact/service.go b/artifact/gcsartifact/service.go index f60ad0bfc..029379fab 100644 --- a/artifact/gcsartifact/service.go +++ b/artifact/gcsartifact/service.go @@ -188,19 +188,9 @@ func (s *gcsService) Load(ctx context.Context, req *artifact.LoadRequest) (_ *ar return nil, fmt.Errorf("request validation failed: %w", err) } appName, userID, sessionID, fileName := req.AppName, req.UserID, req.SessionID, req.FileName - version := req.Version - - if version == 0 { - response, err := s.versions(ctx, &artifact.VersionsRequest{ - AppName: req.AppName, UserID: req.UserID, SessionID: req.SessionID, FileName: req.FileName, - }) - if err != nil { - return nil, fmt.Errorf("failed to list artifact versions: %w", err) - } - if len(response.Versions) == 0 { - return nil, fmt.Errorf("artifact not found: %w", fs.ErrNotExist) - } - version = slices.Max(response.Versions) + version, err := s.resolveVersion(ctx, appName, userID, sessionID, fileName, req.Version) + if err != nil { + return nil, err } blobName := buildBlobName(appName, userID, sessionID, fileName, version) @@ -350,3 +340,68 @@ func (s *gcsService) Versions(ctx context.Context, req *artifact.VersionsRequest } return response, nil } + +// resolveVersion returns the provided version if non-zero, otherwise finds the latest version. +func (s *gcsService) resolveVersion(ctx context.Context, appName, userID, sessionID, fileName string, version int64) (int64, error) { + if version == 0 { + response, err := s.versions(ctx, &artifact.VersionsRequest{ + AppName: appName, UserID: userID, SessionID: sessionID, FileName: fileName, + }) + if err != nil { + return 0, fmt.Errorf("failed to list artifact versions: %w", err) + } + if len(response.Versions) == 0 { + return 0, fmt.Errorf("artifact not found: %w", fs.ErrNotExist) + } + version = slices.Max(response.Versions) + } + return version, nil +} + +// GetArtifactVersion implements [artifact.Service] and returns the metadata for a specific version. +func (s *gcsService) GetArtifactVersion(ctx context.Context, req *artifact.GetArtifactVersionRequest) (*artifact.GetArtifactVersionResponse, error) { + err := req.Validate() + if err != nil { + return nil, fmt.Errorf("request validation failed: %w", err) + } + appName, userID, sessionID, fileName := req.AppName, req.UserID, req.SessionID, req.FileName + version, err := s.resolveVersion(ctx, appName, userID, sessionID, fileName, req.Version) + if err != nil { + return nil, err + } + + blobName := buildBlobName(appName, userID, sessionID, fileName, version) + blob := s.bucket.object(blobName) + + attrs, err := blob.attrs(ctx) + if err != nil { + if err == storage.ErrObjectNotExist { + return nil, fmt.Errorf("artifact '%s' not found: %w", blobName, fs.ErrNotExist) + } + return nil, fmt.Errorf("could not get blob attributes: %w", err) + } + + var canonicalURI string + if attrs.MediaLink != "" { + canonicalURI = attrs.MediaLink + } else { + canonicalURI = fmt.Sprintf("gs://%s/%s", s.bucketName, blobName) + } + + customMeta := make(map[string]any) + if attrs.Metadata != nil { + for k, v := range attrs.Metadata { + customMeta[k] = v + } + } + + return &artifact.GetArtifactVersionResponse{ + ArtifactVersion: &artifact.ArtifactVersion{ + Version: version, + CanonicalURI: canonicalURI, + CustomMetadata: customMeta, + CreateTime: float64(attrs.Created.Unix()), + MimeType: attrs.ContentType, + }, + }, nil +} diff --git a/artifact/inmemory.go b/artifact/inmemory.go index 66d73b5a4..c1f4356da 100644 --- a/artifact/inmemory.go +++ b/artifact/inmemory.go @@ -285,4 +285,43 @@ func (s *inMemoryService) Versions(ctx context.Context, req *VersionsRequest) (* return &VersionsResponse{Versions: versions}, nil } +// GetArtifactVersion implements [artifact.Service] and returns the metadata for a specific version. +func (s *inMemoryService) GetArtifactVersion(ctx context.Context, req *GetArtifactVersionRequest) (*GetArtifactVersionResponse, error) { + err := req.Validate() + if err != nil { + return nil, fmt.Errorf("request validation failed: %w", err) + } + appName, userID, sessionID, fileName, version := req.AppName, req.UserID, req.SessionID, req.FileName, req.Version + if fileHasUserNamespace(fileName) { + sessionID = userScopedArtifactKey + } + + s.mu.RLock() + defer s.mu.RUnlock() + + var artifact *genai.Part + var ok bool + if version > 0 { + artifact, ok = s.get(appName, userID, sessionID, fileName, version) + } else { + version, artifact, ok = s.find(appName, userID, sessionID, fileName) + } + + if !ok { + return nil, fmt.Errorf("artifact not found: %w", fs.ErrNotExist) + } + + mimeType := "text/plain" + if artifact != nil && artifact.InlineData != nil { + mimeType = artifact.InlineData.MIMEType + } + + return &GetArtifactVersionResponse{ + ArtifactVersion: &ArtifactVersion{ + Version: version, + MimeType: mimeType, + }, + }, nil +} + var _ Service = (*inMemoryService)(nil) diff --git a/artifact/request_validation_test.go b/artifact/request_validation_test.go index 40856f778..60a2daf6b 100644 --- a/artifact/request_validation_test.go +++ b/artifact/request_validation_test.go @@ -325,6 +325,59 @@ func TestVersionsRequest_Validate(t *testing.T) { executeValidatorTestCases(t, "VersionsRequest", testCases) } +// Test suite for the GetArtifactVersionRequest Validate method +func TestGetArtifactVersionRequest_Validate(t *testing.T) { + // Define test cases + testCases := []ValidatorTestCase{ + { + name: "Valid request", + req: &GetArtifactVersionRequest{ + AppName: "MyApp", + UserID: "user-123", + SessionID: "sess-abc", + FileName: "file.txt", + }, + wantErr: false, + }, + { + name: "Missing AppName", + req: &GetArtifactVersionRequest{ + UserID: "user-123", + SessionID: "sess-abc", + FileName: "file.txt", + }, + wantErr: true, + wantErrMsg: "invalid get artifact version request: missing required fields: AppName", + }, + { + name: "Missing multiple fields", + req: &GetArtifactVersionRequest{ + AppName: "MyApp", + }, + wantErr: true, + wantErrMsg: "invalid get artifact version request: missing required fields: UserID, SessionID, FileName", + }, + { + name: "Completely empty request", + req: &GetArtifactVersionRequest{}, + wantErr: true, + wantErrMsg: "invalid get artifact version request: missing required fields: AppName, UserID, SessionID, FileName", + }, + { + name: "FileName with path separator", + req: &GetArtifactVersionRequest{ + AppName: "MyApp", + UserID: "user-123", + SessionID: "sess-abc", + FileName: "folder/file.txt", + }, + wantErr: true, + wantErrMsg: "invalid name: filename cannot contain path separators", + }, + } + executeValidatorTestCases(t, "GetArtifactVersionRequest", testCases) +} + func executeValidatorTestCases(t *testing.T, requestTypeName string, testCases []ValidatorTestCase) { // Run the tests for _, tc := range testCases { diff --git a/artifact/service.go b/artifact/service.go index e63952406..85636d574 100644 --- a/artifact/service.go +++ b/artifact/service.go @@ -42,6 +42,8 @@ type Service interface { List(ctx context.Context, req *ListRequest) (*ListResponse, error) // Versions lists all versions of an artifact. Versions(ctx context.Context, req *VersionsRequest) (*VersionsResponse, error) + // GetArtifactVersion gets the metadata for a specific version of an artifact. + GetArtifactVersion(ctx context.Context, req *GetArtifactVersionRequest) (*GetArtifactVersionResponse, error) } // requiredField is an internal type to use on validate operations @@ -259,3 +261,51 @@ func (req *VersionsRequest) Validate() error { type VersionsResponse struct { Versions []int64 } + +// ArtifactVersion contains metadata describing a specific version of an artifact. +type ArtifactVersion struct { + Version int64 + CanonicalURI string + CustomMetadata map[string]any + CreateTime float64 + MimeType string +} + +// GetArtifactVersionRequest is the parameter for [ArtifactService.GetArtifactVersion]. +type GetArtifactVersionRequest struct { + AppName, UserID, SessionID, FileName string + + // Below are optional fields. + Version int64 +} + +// Validate checks if the struct is valid or if it is missing a field. +func (req *GetArtifactVersionRequest) Validate() error { + // Define the fields to check in the desired order + fieldsToCheck := []requiredField{ + {Name: "AppName", Value: req.AppName}, + {Name: "UserID", Value: req.UserID}, + {Name: "SessionID", Value: req.SessionID}, + {Name: "FileName", Value: req.FileName}, + } + + // Use the helper function for all required string fields + missingFields := validateRequiredStrings(fieldsToCheck) + + // If the slice has any items, it means fields were missing. + if len(missingFields) > 0 { + return fmt.Errorf("invalid get artifact version request: missing required fields: %s", strings.Join(missingFields, ", ")) + } + + // Validate that FileName doesn't contain path separators + if err := validateFileName(req.FileName); err != nil { + return err + } + + return nil +} + +// GetArtifactVersionResponse is the return type of [ArtifactService.GetArtifactVersion]. +type GetArtifactVersionResponse struct { + ArtifactVersion *ArtifactVersion +} From 9a6efeaf35e7e7b56196b8a29aa2b440ba9879b1 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Thu, 26 Mar 2026 17:32:44 +0100 Subject: [PATCH 05/86] feat: custom runner provider for adka2a executor (#680) * custom runner provider for adka2a executor * remove the mandatory dependency on runnerConfig --- server/adka2a/executor.go | 115 +++++++++++++++++++++++++++---- server/adka2a/executor_plugin.go | 12 ---- server/adka2a/executor_test.go | 52 +++++++++++++- server/adka2a/metadata.go | 6 +- 4 files changed, 153 insertions(+), 32 deletions(-) diff --git a/server/adka2a/executor.go b/server/adka2a/executor.go index d4d6e38e7..f17e344f0 100644 --- a/server/adka2a/executor.go +++ b/server/adka2a/executor.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "iter" "slices" "github.com/a2aproject/a2a-go/a2a" @@ -30,6 +31,7 @@ import ( "google.golang.org/adk/agent" iremoteagent "google.golang.org/adk/internal/agent/remoteagent" + "google.golang.org/adk/plugin" "google.golang.org/adk/runner" "google.golang.org/adk/session" ) @@ -59,6 +61,17 @@ type A2AExecutionCleanupCallback func(ctx context.Context, reqCtx *a2asrv.Reques // OutputMode controls how artifacts are produced. type OutputMode string +// Runner is an interface matching [runner.Runner] API. +// It exists to let users use custom runner implementations with A2A agent executor. +type Runner interface { + // Run runs the agent for the given user input, yielding events from agents. + Run(ctx context.Context, userID, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] +} + +// RunnerProvider is a [Runner] factory function. The provided plugin must be installed in the returned [Runner] for +// callbacks taking [ExecutorContext] to work correctly. +type RunnerProvider func(ctx context.Context, reqCtx *a2asrv.RequestContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) + const ( // OutputArtifactPerRun produces a single artifact per [runner.Runner.Run]. OutputArtifactPerRun OutputMode = "artifact-per-run" @@ -69,10 +82,24 @@ const ( OutputArtifactPerEvent OutputMode = "artifact-per-event" ) +// RunnerConfig is part of the runner configuration executor code depends on. +// Custom [RunnerProvider] needs to return it back to callers. +type RunnerConfig struct { + // AppName is the name of the application used in [session.Service] keys and A2A event metadata. + AppName string + // Agent is the root agent. It isued + Agent agent.Agent + // SessionService is the session service to use. + SessionService session.Service +} + // ExecutorConfig allows to configure Executor. type ExecutorConfig struct { - // RunnerConfig is the configuration which will be used for [runner.New] during A2A Execute invocation. + // RunnerConfig is used for creating a default RunnerProvider. The field is ignored when RunnerProvider is set. RunnerConfig runner.Config + // RunnerProvider is a function which allows to control how a runner is created. + // If not provided the default provider is used which calls [runner.New] with the RunnerConfig field. + RunnerProvider RunnerProvider // RunConfig is the configuration which will be passed to [runner.Runner.Run] during A2A Execute invocation. RunConfig agent.RunConfig @@ -127,6 +154,9 @@ type Executor struct { // NewExecutor creates an initialized [Executor] instance. func NewExecutor(config ExecutorConfig) *Executor { + if config.RunnerProvider == nil { + config.RunnerProvider = newDefaultRunnerProvider(config.RunnerConfig) + } return &Executor{config: config} } @@ -140,15 +170,16 @@ func (e *Executor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, q return fmt.Errorf("a2a message conversion failed: %w", err) } - runnerCfg, executorPlugin, err := withExecutorPlugin(e.config.RunnerConfig) + executorPlugin, err := newExecutorPlugin() if err != nil { - return fmt.Errorf("failed to install a2a-executor plugin: %w", err) + return fmt.Errorf("failed to create a2a-executor plugin: %w", err) } - r, err := runner.New(runnerCfg) + cfg, r, err := e.config.RunnerProvider(ctx, reqCtx, executorPlugin.plugin) if err != nil { return fmt.Errorf("failed to create a runner: %w", err) } + if e.config.BeforeExecuteCallback != nil { ctx, err = e.config.BeforeExecuteCallback(ctx, reqCtx) if err != nil { @@ -170,9 +201,9 @@ func (e *Executor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, q } } - invocationMeta := toInvocationMeta(ctx, e.config, reqCtx) + invocationMeta := toInvocationMeta(ctx, cfg, reqCtx) - err = e.prepareSession(ctx, invocationMeta) + err = e.prepareSession(ctx, cfg, invocationMeta) if err != nil { event := toTaskFailedUpdateEvent(reqCtx, err, invocationMeta.eventMeta) execCtx := newExecutorContext(ctx, invocationMeta, executorPlugin, content) @@ -204,7 +235,13 @@ func (e *Executor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, qu } func (e *Executor) Cleanup(ctx context.Context, reqCtx *a2asrv.RequestContext, result a2a.SendMessageResult, cause error) { - remoteSubagents := findRemoteSubagents(e.config.RunnerConfig.Agent) + cfg, err := e.createRunnerConfig(ctx, reqCtx) + if err != nil { + log.Error(ctx, "failed to create runner config", err) + return + } + + remoteSubagents := findRemoteSubagents(cfg.Agent) // If task was in input-required and got successfully cancelled - run the cleanup logic if reqCtx.StoredTask != nil && reqCtx.StoredTask.Status.State == a2a.TaskStateInputRequired { @@ -235,9 +272,14 @@ func (e *Executor) cancelChildInputRequiredTasks(ctx context.Context, reqCtx *a2 return nil } - meta := toInvocationMeta(ctx, e.config, reqCtx) - getSessionResponse, err := e.config.RunnerConfig.SessionService.Get(ctx, &session.GetRequest{ - AppName: e.config.RunnerConfig.AppName, + cfg, err := e.createRunnerConfig(ctx, reqCtx) + if err != nil { + return fmt.Errorf("failed to create runner config: %w", err) + } + + meta := toInvocationMeta(ctx, cfg, reqCtx) + getSessionResponse, err := cfg.SessionService.Get(ctx, &session.GetRequest{ + AppName: cfg.AppName, UserID: meta.userID, SessionID: meta.sessionID, }) @@ -286,7 +328,7 @@ func (e *Executor) cancelChildInputRequiredTasks(ctx context.Context, reqCtx *a2 } // Processing failures should be delivered as Task failed events. An error is returned from this method if an event write fails. -func (e *Executor) process(ctx ExecutorContext, r *runner.Runner, processor *eventProcessor, q eventqueue.Queue) error { +func (e *Executor) process(ctx ExecutorContext, r Runner, processor *eventProcessor, q eventqueue.Queue) error { meta := processor.meta for adkEvent, adkErr := range r.Run(ctx, meta.userID, meta.sessionID, ctx.UserContent(), e.config.RunConfig) { if adkErr != nil { @@ -338,11 +380,11 @@ func (e *Executor) writeFinalTaskStatus( return nil } -func (e *Executor) prepareSession(ctx context.Context, meta invocationMeta) error { - service := e.config.RunnerConfig.SessionService +func (e *Executor) prepareSession(ctx context.Context, cfg RunnerConfig, meta invocationMeta) error { + service := cfg.SessionService _, err := service.Get(ctx, &session.GetRequest{ - AppName: e.config.RunnerConfig.AppName, + AppName: cfg.AppName, UserID: meta.userID, SessionID: meta.sessionID, }) @@ -351,7 +393,7 @@ func (e *Executor) prepareSession(ctx context.Context, meta invocationMeta) erro } _, err = service.Create(ctx, &session.CreateRequest{ - AppName: e.config.RunnerConfig.AppName, + AppName: cfg.AppName, UserID: meta.userID, SessionID: meta.sessionID, State: make(map[string]any), @@ -362,3 +404,46 @@ func (e *Executor) prepareSession(ctx context.Context, meta invocationMeta) erro return nil } + +func (e *Executor) createRunnerConfig(ctx context.Context, reqCtx *a2asrv.RequestContext) (RunnerConfig, error) { + executorPlugin, err := newExecutorPlugin() + if err != nil { + return RunnerConfig{}, fmt.Errorf("failed to create a2a-plugin: %w", err) + } + cfg, _, err := e.config.RunnerProvider(ctx, reqCtx, executorPlugin.plugin) + if err != nil { + return RunnerConfig{}, fmt.Errorf("runner provider failed: %w", err) + } + return cfg, nil +} + +func newDefaultRunnerProvider(baseConfig runner.Config) RunnerProvider { + return func(ctx context.Context, reqCtx *a2asrv.RequestContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) { + if baseConfig.Agent == nil { + return RunnerConfig{}, nil, fmt.Errorf("runner.Config.Agent is not provided") + } + if baseConfig.Agent == nil { + return RunnerConfig{}, nil, fmt.Errorf("runner.Config.SessionService is not provided") + } + + cfg := baseConfig + cfg.PluginConfig.Plugins = append(slices.Clone(cfg.PluginConfig.Plugins), plugin) + r, err := runner.New(cfg) + if err != nil { + return RunnerConfig{}, nil, err + } + return toInternalRunnerConfig(cfg), &defaultRunner{runner: r}, nil + } +} + +type defaultRunner struct { + runner *runner.Runner +} + +func (r *defaultRunner) Run(ctx context.Context, userID, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] { + return r.runner.Run(ctx, userID, sessionID, msg, cfg) +} + +func toInternalRunnerConfig(cfg runner.Config) RunnerConfig { + return RunnerConfig{Agent: cfg.Agent, AppName: cfg.AppName, SessionService: cfg.SessionService} +} diff --git a/server/adka2a/executor_plugin.go b/server/adka2a/executor_plugin.go index 8b9813923..479dd0e29 100644 --- a/server/adka2a/executor_plugin.go +++ b/server/adka2a/executor_plugin.go @@ -15,13 +15,10 @@ package adka2a import ( - "slices" - "google.golang.org/genai" "google.golang.org/adk/agent" "google.golang.org/adk/plugin" - "google.golang.org/adk/runner" "google.golang.org/adk/session" ) @@ -31,15 +28,6 @@ type executorPlugin struct { invocationSession session.Session } -func withExecutorPlugin(cfg runner.Config) (runner.Config, *executorPlugin, error) { - executorPlugin, err := newExecutorPlugin() - if err != nil { - return cfg, nil, err - } - cfg.PluginConfig.Plugins = append(slices.Clone(cfg.PluginConfig.Plugins), executorPlugin.plugin) - return cfg, executorPlugin, nil -} - func newExecutorPlugin() (*executorPlugin, error) { execPlugin := &executorPlugin{} plugin, err := plugin.New(plugin.Config{ diff --git a/server/adka2a/executor_test.go b/server/adka2a/executor_test.go index 56fcb4353..60525bb92 100644 --- a/server/adka2a/executor_test.go +++ b/server/adka2a/executor_test.go @@ -31,7 +31,9 @@ import ( "google.golang.org/genai" "google.golang.org/adk/agent" + "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" + "google.golang.org/adk/plugin" "google.golang.org/adk/runner" "google.golang.org/adk/session" ) @@ -300,7 +302,7 @@ func TestExecutor_SessionReuse(t *testing.T) { t.Fatalf("executor.Execute() error = %v, want nil", err) } - meta := toInvocationMeta(ctx, config, reqCtx) + meta := toInvocationMeta(ctx, toInternalRunnerConfig(config.RunnerConfig), reqCtx) sessions, err := sessionService.List(ctx, &session.ListRequest{AppName: runnerConfig.AppName, UserID: meta.userID}) if err != nil { t.Fatalf("sessionService.List() error = %v, want nil", err) @@ -310,7 +312,7 @@ func TestExecutor_SessionReuse(t *testing.T) { } reqCtx.ContextID = a2a.NewContextID() - otherContextMeta := toInvocationMeta(ctx, config, reqCtx) + otherContextMeta := toInvocationMeta(ctx, toInternalRunnerConfig(config.RunnerConfig), reqCtx) if meta.sessionID == otherContextMeta.sessionID { t.Fatal("want sessionID to be different for different contextIDs") } @@ -935,3 +937,49 @@ func TestExecutor_OutputArtifactPerEvent(t *testing.T) { }) } } + +func TestExecutor_RunnerProvider(t *testing.T) { + wantText := "Hello" + ctx := t.Context() + task := &a2a.Task{ID: a2a.NewTaskID(), ContextID: a2a.NewContextID()} + hiMsg := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.TextPart{Text: "hi"}) + reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} + + runnerConfig := runner.Config{ + AppName: "test", + SessionService: session.InMemoryService(), + Agent: utils.Must(agent.New(agent.Config{Name: "agent"})), + } + executor := NewExecutor(ExecutorConfig{ + RunnerConfig: runnerConfig, + RunnerProvider: func(pCtx context.Context, pReqCtx *a2asrv.RequestContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) { + return toInternalRunnerConfig(runnerConfig), &testRunner{ + runFunc: func(ctx context.Context, userID, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + yield(&session.Event{LLMResponse: modelResponseFromParts(genai.NewPartFromText(wantText))}, nil) + } + }, + }, nil + }, + }) + + queue := &testQueue{Queue: newInMemoryQueue(t)} + if err := executor.Execute(ctx, reqCtx, queue); err != nil { + t.Fatalf("executor.Execute() error = %v", err) + } + ta, ok := queue.events[1].(*a2a.TaskArtifactUpdateEvent) + if !ok { + t.Fatalf("queue.events[1] = %T, want a2a.TaskArtifactUpdateEvent", queue.events[1]) + } + if tp, ok := ta.Artifact.Parts[0].(a2a.TextPart); !ok || tp.Text != wantText { + t.Fatalf("ta.Artifact.Parts[0] = %v, want text part with text = %q", tp, wantText) + } +} + +type testRunner struct { + runFunc func(ctx context.Context, userID, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] +} + +func (r *testRunner) Run(ctx context.Context, userID, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] { + return r.runFunc(ctx, userID, sessionID, msg, cfg) +} diff --git a/server/adka2a/metadata.go b/server/adka2a/metadata.go index 2844f0f0e..fb29367be 100644 --- a/server/adka2a/metadata.go +++ b/server/adka2a/metadata.go @@ -57,7 +57,7 @@ type invocationMeta struct { eventMeta map[string]any } -func toInvocationMeta(ctx context.Context, config ExecutorConfig, reqCtx *a2asrv.RequestContext) invocationMeta { +func toInvocationMeta(ctx context.Context, config RunnerConfig, reqCtx *a2asrv.RequestContext) invocationMeta { userID, sessionID := "A2A_USER_"+reqCtx.ContextID, reqCtx.ContextID // a2a sdk attaches authn info to the call context, use it when provided @@ -68,7 +68,7 @@ func toInvocationMeta(ctx context.Context, config ExecutorConfig, reqCtx *a2asrv } meta := map[string]any{ - ToA2AMetaKey("app_name"): config.RunnerConfig.AppName, + ToA2AMetaKey("app_name"): config.AppName, ToA2AMetaKey("user_id"): userID, ToA2AMetaKey("session_id"): sessionID, } @@ -76,7 +76,7 @@ func toInvocationMeta(ctx context.Context, config ExecutorConfig, reqCtx *a2asrv return invocationMeta{ userID: userID, sessionID: sessionID, - agentName: config.RunnerConfig.Agent.Name(), + agentName: config.Agent.Name(), eventMeta: meta, reqCtx: reqCtx, } From e44b228459a20dbc6390644d89c9ffbb695c3ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Tue, 31 Mar 2026 10:47:46 +0100 Subject: [PATCH 06/86] Add session service test suite (#675) * Add session service test suite * lint fix * lint fix --------- Co-authored-by: Dmitry Pasiukevich <20398573+dpasiukevich@users.noreply.github.com> --- session/database/service_test.go | 938 +------------ session/inmemory_test.go | 1007 +------------- session/session_test/service_suite.go | 743 +++++++++++ session/vertexai/service_test.go | 1165 +---------------- session/vertexai/testdata/Delete.replay | Bin 0 -> 3645 bytes session/vertexai/testdata/List.replay | Bin 0 -> 8760 bytes ...Test_vertexaiService_AppendEvent_ok.replay | Bin 0 -> 3915 bytes ...nt_partial_events_are_not_persisted.replay | Bin 0 -> 3382 bytes ..._when_session_not_found_should_fail.replay | Bin 0 -> 1330 bytes ...Service_AppendEvent_with_all_fields.replay | Bin 0 -> 7263 bytes ...vice_AppendEvent_with_bytes_content.replay | Bin 0 -> 3994 bytes ...ce_AppendEvent_with_existing_events.replay | Bin 0 -> 4435 bytes ...Service_Create_generated_session_id.replay | Bin 0 -> 6668 bytes ...reate_when_already_exists__it_fails.replay | Bin 0 -> 3519 bytes .../Test_vertexaiService_Delete.replay | Bin 0 -> 3649 bytes ...aiService_Get_error_when_not_found.replay} | Bin ...ce_Get_get_session_respects_user_id.replay | Bin 0 -> 3924 bytes .../Test_vertexaiService_Get_ok.replay | Bin 0 -> 3390 bytes ...exaiService_Get_with_config_filters.replay | Bin 0 -> 11049 bytes .../testdata/Test_vertexaiService_List.replay | Bin 0 -> 12888 bytes ...StateManagement_app_state_is_shared.replay | Bin 0 -> 5845 bytes ...agement_session_state_is_not_shared.replay | Bin 0 -> 4868 bytes ...agement_temp_state_is_not_persisted.replay | Bin 0 -> 5084 bytes ...agement_user_state_is_user_specific.replay | Bin 0 -> 9811 bytes .../testdata/app_state_is_shared.replay | Bin 5150 -> 4812 bytes ...he_session_and_overwrite_in_storage.replay | Bin 9692 -> 0 bytes ...ith_events_and_overwrite_in_storage.replay | Bin 10840 -> 0 bytes ..._when_session_not_found_should_fail.replay | Bin 8299 -> 0 bytes .../append_event_with_all_fields.replay | Bin 9953 -> 0 bytes .../append_event_with_bytes_content.replay | Bin 9807 -> 0 bytes .../empty_list_for_non-existent_user.replay | Bin 8639 -> 0 bytes .../testdata/error_when_not_found.replay | Bin 8298 -> 635 bytes .../testdata/generated_session_id.replay | Bin 2505 -> 2530 bytes .../get_session_respects_user_id.replay | Bin 10362 -> 5988 bytes .../testdata/list_all_users_for_app.replay | Bin 9194 -> 0 bytes .../vertexai/testdata/list_for_user1.replay | Bin 9014 -> 0 bytes .../vertexai/testdata/list_for_user2.replay | Bin 8817 -> 0 bytes .../vertexai/testdata/missing_author.replay | Bin 635 -> 1731 bytes .../testdata/missing_invocation_id.replay | Bin 635 -> 1738 bytes .../testdata/missing_session_id.replay | Bin 635 -> 0 bytes session/vertexai/testdata/nil_event.replay | Bin 635 -> 0 bytes session/vertexai/testdata/ok.replay | Bin 9156 -> 7020 bytes .../partial_events_are_not_persisted.replay | Bin 9147 -> 3382 bytes .../session_state_is_not_shared.replay | Bin 5845 -> 4865 bytes .../temp_state_is_not_persisted.replay | Bin 3986 -> 5083 bytes .../user_state_is_user_specific.replay | Bin 9018 -> 8774 bytes .../when_already_exists__it_fails.replay | Bin 8302 -> 2483 bytes .../when_session_not_found_should_fail.replay | Bin 0 -> 1330 bytes .../vertexai/testdata/with_all_fields.replay | Bin 0 -> 6237 bytes .../testdata/with_bytes_content.replay | Bin 0 -> 4003 bytes .../with_config_after_timestamp.replay | Bin 5409 -> 0 bytes .../with_config_combined_filters.replay | Bin 5409 -> 0 bytes .../testdata/with_config_filters.replay | Bin 0 -> 11083 bytes ...config_no_config_returns_all_events.replay | Bin 5880 -> 0 bytes .../with_config_num_recent_events.replay | Bin 5884 -> 0 bytes .../testdata/with_existing_events.replay | Bin 0 -> 4446 bytes 56 files changed, 809 insertions(+), 3044 deletions(-) create mode 100644 session/session_test/service_suite.go create mode 100644 session/vertexai/testdata/Delete.replay create mode 100644 session/vertexai/testdata/List.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_AppendEvent_ok.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_AppendEvent_partial_events_are_not_persisted.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_AppendEvent_when_session_not_found_should_fail.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_AppendEvent_with_all_fields.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_AppendEvent_with_bytes_content.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_AppendEvent_with_existing_events.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_Create_generated_session_id.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_Create_when_already_exists__it_fails.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_Delete.replay rename session/vertexai/testdata/{full_key.replay => Test_vertexaiService_Get_error_when_not_found.replay} (100%) create mode 100644 session/vertexai/testdata/Test_vertexaiService_Get_get_session_respects_user_id.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_Get_ok.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_Get_with_config_filters.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_List.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_StateManagement_app_state_is_shared.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_StateManagement_session_state_is_not_shared.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_StateManagement_temp_state_is_not_persisted.replay create mode 100644 session/vertexai/testdata/Test_vertexaiService_StateManagement_user_state_is_user_specific.replay delete mode 100644 session/vertexai/testdata/append_event_to_the_session_and_overwrite_in_storage.replay delete mode 100644 session/vertexai/testdata/append_event_to_the_session_with_events_and_overwrite_in_storage.replay delete mode 100644 session/vertexai/testdata/append_event_when_session_not_found_should_fail.replay delete mode 100644 session/vertexai/testdata/append_event_with_all_fields.replay delete mode 100644 session/vertexai/testdata/append_event_with_bytes_content.replay delete mode 100644 session/vertexai/testdata/empty_list_for_non-existent_user.replay delete mode 100644 session/vertexai/testdata/list_all_users_for_app.replay delete mode 100644 session/vertexai/testdata/list_for_user1.replay delete mode 100644 session/vertexai/testdata/list_for_user2.replay delete mode 100644 session/vertexai/testdata/missing_session_id.replay delete mode 100644 session/vertexai/testdata/nil_event.replay create mode 100644 session/vertexai/testdata/when_session_not_found_should_fail.replay create mode 100644 session/vertexai/testdata/with_all_fields.replay create mode 100644 session/vertexai/testdata/with_bytes_content.replay delete mode 100644 session/vertexai/testdata/with_config_after_timestamp.replay delete mode 100644 session/vertexai/testdata/with_config_combined_filters.replay create mode 100644 session/vertexai/testdata/with_config_filters.replay delete mode 100644 session/vertexai/testdata/with_config_no_config_returns_all_events.replay delete mode 100644 session/vertexai/testdata/with_config_num_recent_events.replay create mode 100644 session/vertexai/testdata/with_existing_events.replay diff --git a/session/database/service_test.go b/session/database/service_test.go index 9e993028f..90de7adcb 100644 --- a/session/database/service_test.go +++ b/session/database/service_test.go @@ -15,948 +15,20 @@ package database import ( - "maps" - "strconv" "testing" - "time" "github.com/glebarez/sqlite" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "google.golang.org/genai" "gorm.io/gorm" - "google.golang.org/adk/model" "google.golang.org/adk/session" + "google.golang.org/adk/session/session_test" ) -func Test_databaseService_Create(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T) *databaseService - req *session.CreateRequest - want session.Session - wantErr bool - }{ - { - name: "full key", - setup: emptyService, - req: &session.CreateRequest{ - AppName: "testApp", - UserID: "testUserID", - SessionID: "testSessionID", - State: map[string]any{ - "k": 5, - }, - }, - }, - { - name: "generated session id", - setup: emptyService, - req: &session.CreateRequest{ - AppName: "testApp", - UserID: "testUserID", - State: map[string]any{ - "k": 5, - }, - }, - }, - { - name: "when already exists, it fails", // this differs from inmemmory impl - setup: serviceDbWithData, - req: &session.CreateRequest{ - AppName: "app1", - UserID: "user1", - SessionID: "session1", - State: map[string]any{ - "k": 10, - }, - }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := tt.setup(t) - - got, err := s.Create(t.Context(), tt.req) - if (err != nil) != tt.wantErr { - t.Fatalf("databaseService.Create() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if err != nil { - return - } - - if got.Session.AppName() != tt.req.AppName { - t.Errorf("AppName got: %v, want: %v", got.Session.AppName(), tt.wantErr) - } - - if got.Session.UserID() != tt.req.UserID { - t.Errorf("UserID got: %v, want: %v", got.Session.UserID(), tt.wantErr) - } - - if tt.req.SessionID != "" { - if got.Session.ID() != tt.req.SessionID { - t.Errorf("SessionID got: %v, want: %v", got.Session.ID(), tt.wantErr) - } - } else { - if got.Session.ID() == "" { - t.Errorf("SessionID was not generated on empty user input.") - } - } - - gotState := maps.Collect(got.Session.State().All()) - wantState := tt.req.State - - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("Create State mismatch: (-want +got):\n%s", diff) - } - }) - } -} - -func Test_databaseService_Delete(t *testing.T) { - tests := []struct { - name string - req *session.DeleteRequest - setup func(t *testing.T) *databaseService - wantErr bool - }{ - { - name: "delete ok", - setup: serviceDbWithData, - req: &session.DeleteRequest{ - AppName: "app1", - UserID: "user1", - SessionID: "session1", - }, - }, - { - name: "no error when not found", - setup: serviceDbWithData, - req: &session.DeleteRequest{ - AppName: "appTest", - UserID: "user1", - SessionID: "session1", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := tt.setup(t) - if err := s.Delete(t.Context(), tt.req); (err != nil) != tt.wantErr { - t.Errorf("databaseService.Delete() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func Test_databaseService_Get(t *testing.T) { - // This setup function is required for a test case. - // It creates the specific scenario from 'test_get_session_respects_user_id'. - setupGetRespectsUserID := func(t *testing.T) *databaseService { - t.Helper() - s := serviceDbWithData(t) // Starts with the standard data - - // u1 creates s1 and adds an event. - // 'serviceDbWithData' already created - // (app1, user1, session1) - // (app1, user2, session1) - // We just need to add an event to it. - session1, err := s.Get(t.Context(), &session.GetRequest{ - AppName: "app1", - UserID: "user1", - SessionID: "session1", - }) - if err != nil { - t.Fatalf("setupGetRespectsUserID failed to get session1: %v", err) - } - - // Update 'updatedAt' to pass stale validation on append - session1.Session.(*localSession).updatedAt = time.Now() - - err = s.AppendEvent(t.Context(), session1.Session.(*localSession), &session.Event{ - ID: "event_for_user1", - Author: "user", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }) - if err != nil { - t.Fatalf("setupGetRespectsUserID failed to append event: %v", err) - } - return s - } - - setupGetWithConfig := func(t *testing.T) *databaseService { - t.Helper() - s := emptyService(t) - ctx := t.Context() - numTestEvents := 5 - created, err := s.Create(ctx, &session.CreateRequest{ - AppName: "my_app", - UserID: "user", - SessionID: "s1", - }) - if err != nil { - t.Fatalf("setupGetWithConfig failed to create session: %v", err) - } - - for i := 1; i <= numTestEvents; i++ { - created.Session.(*localSession).updatedAt = time.Now() - event := &session.Event{ - ID: strconv.Itoa(i), - Author: "user", - Timestamp: time.Time{}.Add(time.Duration(i) * time.Microsecond), - LLMResponse: model.LLMResponse{}, - } - if err := s.AppendEvent(ctx, created.Session.(*localSession), event); err != nil { - t.Fatalf("setupGetWithConfig failed to append event %d: %v", i, err) - } - } - return s - } - - tests := []struct { - name string - req *session.GetRequest - setup func(t *testing.T) *databaseService - wantResponse *session.GetResponse - wantEvents []*session.Event - wantErr bool - }{ - { - name: "ok", - setup: serviceDbWithData, - req: &session.GetRequest{ - AppName: "app1", - UserID: "user1", - SessionID: "session1", - }, - wantResponse: &session.GetResponse{ - Session: &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session1", - state: map[string]any{ - "k1": "v1", - }, - events: []*session.Event{}, - }, - }, - }, - { - name: "error when not found", - setup: serviceDbWithData, - req: &session.GetRequest{ - AppName: "testApp", - UserID: "user1", - SessionID: "session1", - }, - wantErr: true, - }, - { - name: "get session respects user id", - setup: setupGetRespectsUserID, - req: &session.GetRequest{ - AppName: "app1", - UserID: "user2", - SessionID: "session1", - }, - wantResponse: &session.GetResponse{ - Session: &localSession{ - appName: "app1", - userID: "user2", - sessionID: "session1", - // This is user2's session, which should have its own state - state: map[string]any{ - "k1": "v2", - }, - // Critically, it should NOT have the event from user1's session - events: []*session.Event{}, - }, - }, - wantErr: false, - }, - { - name: "with config_no config returns all events", - setup: setupGetWithConfig, - req: &session.GetRequest{ - AppName: "my_app", UserID: "user", SessionID: "s1", - }, - wantEvents: []*session.Event{ - {ID: "1", Author: "user", Timestamp: time.Time{}.Add(1 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - {ID: "2", Author: "user", Timestamp: time.Time{}.Add(2 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - {ID: "3", Author: "user", Timestamp: time.Time{}.Add(3 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - {ID: "4", Author: "user", Timestamp: time.Time{}.Add(4 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - {ID: "5", Author: "user", Timestamp: time.Time{}.Add(5 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - }, - }, - { - name: "with config_num recent events", - setup: setupGetWithConfig, - req: &session.GetRequest{ - AppName: "my_app", UserID: "user", SessionID: "s1", - NumRecentEvents: 3, - }, - wantEvents: []*session.Event{ - {ID: "3", Author: "user", Timestamp: time.Time{}.Add(3 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - {ID: "4", Author: "user", Timestamp: time.Time{}.Add(4 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - {ID: "5", Author: "user", Timestamp: time.Time{}.Add(5 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - }, - }, - { - name: "with config_after timestamp", - setup: setupGetWithConfig, - req: &session.GetRequest{ - AppName: "my_app", UserID: "user", SessionID: "s1", - After: time.Time{}.Add(4 * time.Microsecond), - }, - wantEvents: []*session.Event{ - {ID: "4", Author: "user", Timestamp: time.Time{}.Add(4 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - {ID: "5", Author: "user", Timestamp: time.Time{}.Add(5 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - }, - }, - { - name: "with config_combined filters", - setup: setupGetWithConfig, - req: &session.GetRequest{ - AppName: "my_app", UserID: "user", SessionID: "s1", - NumRecentEvents: 3, - After: time.Time{}.Add(4 * time.Microsecond), - }, - wantEvents: []*session.Event{ - {ID: "4", Author: "user", Timestamp: time.Time{}.Add(4 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - {ID: "5", Author: "user", Timestamp: time.Time{}.Add(5 * time.Microsecond), LLMResponse: model.LLMResponse{}}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := tt.setup(t) - - got, err := s.Get(t.Context(), tt.req) - if (err != nil) != tt.wantErr { - t.Fatalf("databaseService.Get() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if err != nil { - return - } - - if tt.wantResponse != nil { - if diff := cmp.Diff(tt.wantResponse, got, - cmp.AllowUnexported(localSession{}), - cmpopts.IgnoreFields(localSession{}, "mu", "updatedAt")); diff != "" { - t.Errorf("Get session mismatch: (-want +got):\n%s", diff) - } - } - - if tt.wantEvents != nil { - opts := []cmp.Option{ - cmpopts.SortSlices(func(a, b *session.Event) bool { return a.Timestamp.Before(b.Timestamp) }), - } - if diff := cmp.Diff(events(tt.wantEvents), got.Session.Events(), opts...); diff != "" { - t.Errorf("Get session events mismatch: (-want +got):\n%s", diff) - } - } - }) - } -} - -func Test_databaseService_List(t *testing.T) { - tests := []struct { - name string - req *session.ListRequest - setup func(t *testing.T) *databaseService - wantResponse *session.ListResponse - wantErr bool - }{ - { - name: "list for user1", - setup: serviceDbWithData, - req: &session.ListRequest{ - AppName: "app1", - UserID: "user1", - }, - wantResponse: &session.ListResponse{ - Sessions: []session.Session{ - &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session1", - state: map[string]any{ - "k1": "v1", - }, - }, - &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session2", - state: map[string]any{ - "k1": "v2", - }, - }, - }, - }, - }, - { - name: "empty list for non-existent user", - setup: serviceDbWithData, - req: &session.ListRequest{ - AppName: "app1", - UserID: "custom_user", - }, - wantResponse: &session.ListResponse{ - Sessions: []session.Session{}, - }, - }, - { - name: "list for user2", - setup: serviceDbWithData, - req: &session.ListRequest{ - AppName: "app1", - UserID: "user2", - }, - wantResponse: &session.ListResponse{ - Sessions: []session.Session{ - &localSession{ - appName: "app1", - userID: "user2", - sessionID: "session1", - state: map[string]any{ - "k1": "v2", - }, - }, - }, - }, - }, - { - name: "list all users for app", - setup: serviceDbWithData, - req: &session.ListRequest{AppName: "app1", UserID: ""}, - wantResponse: &session.ListResponse{ - Sessions: []session.Session{ - &localSession{appName: "app1", userID: "user1", sessionID: "session1", state: map[string]any{"k1": "v1"}}, - &localSession{appName: "app1", userID: "user1", sessionID: "session2", state: map[string]any{"k1": "v2"}}, - &localSession{appName: "app1", userID: "user2", sessionID: "session1", state: map[string]any{"k1": "v2"}}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := tt.setup(t) - got, err := s.List(t.Context(), tt.req) - if (err != nil) != tt.wantErr { - t.Errorf("databaseService.List() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if err == nil { - // Sort slices for stable comparison - opts := []cmp.Option{ - cmp.AllowUnexported(localSession{}), - cmpopts.IgnoreFields(localSession{}, "mu", "updatedAt"), - cmpopts.SortSlices(func(a, b session.Session) bool { - return a.ID() < b.ID() - }), - } - if diff := cmp.Diff(tt.wantResponse, got, opts...); diff != "" { - t.Errorf("databaseService.List() = %v (-want +got):\n%s", got, diff) - } - } - }) - } -} - -func Test_databaseService_AppendEvent(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T) *databaseService - session *localSession - event *session.Event - wantStoredSession *localSession // State of the session after Get - wantEventCount int // Expected event count in storage - wantErr bool - }{ - { - name: "append event to the session and overwrite in storage", - setup: serviceDbWithData, - session: &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - event: &session.Event{ - ID: "new_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - wantStoredSession: &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session1", - events: []*session.Event{ - { - ID: "new_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - }, - state: map[string]any{ - "k1": "v1", - }, - }, - wantEventCount: 1, - }, - { - name: "append event to the session with events and overwrite in storage", - setup: serviceDbWithData, - session: &localSession{ - appName: "app2", - userID: "user2", - sessionID: "session2", - }, - event: &session.Event{ - ID: "new_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - wantStoredSession: &localSession{ - appName: "app2", - userID: "user2", - sessionID: "session2", - events: []*session.Event{ - { - ID: "existing_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - { - ID: "new_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - }, - state: map[string]any{ - "k2": "v2", - }, - }, - wantEventCount: 2, - }, - { - name: "append event when session not found should fail", - setup: serviceDbWithData, - session: &localSession{ - appName: "app1", - userID: "user1", - sessionID: "custom_session", - }, - event: &session.Event{ - ID: "new_event2", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - wantErr: true, - }, - { - name: "append event with bytes content", - setup: serviceDbWithData, - session: &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - event: &session.Event{ - ID: "event_with_bytes", - Author: "user", - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromBytes([]byte("test_image_data"), "image/png", "user"), - GroundingMetadata: &genai.GroundingMetadata{ - SearchEntryPoint: &genai.SearchEntryPoint{ - SDKBlob: []byte("test_sdk_blob"), - }, - }, - }, - }, - wantStoredSession: &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session1", - events: []*session.Event{ - { - ID: "event_with_bytes", - Author: "user", - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromBytes([]byte("test_image_data"), "image/png", "user"), - GroundingMetadata: &genai.GroundingMetadata{ - SearchEntryPoint: &genai.SearchEntryPoint{ - SDKBlob: []byte("test_sdk_blob"), - }, - }, - }, - }, - }, - state: map[string]any{ - "k1": "v1", - }, - }, - wantEventCount: 1, - }, - { - name: "append event with all fields", - setup: serviceDbWithData, - session: &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - event: &session.Event{ - ID: "event_complete", - Author: "user", - LongRunningToolIDs: []string{"tool123"}, - Actions: session.EventActions{StateDelta: map[string]any{"k2": "v2"}}, - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromText("test_text", "user"), - TurnComplete: true, - Partial: false, - ErrorCode: "error_code", - ErrorMessage: "error_message", - Interrupted: true, - GroundingMetadata: &genai.GroundingMetadata{ - WebSearchQueries: []string{"query1"}, - }, - UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: 1, - CandidatesTokenCount: 1, - TotalTokenCount: 2, - }, - CitationMetadata: &genai.CitationMetadata{ - Citations: []*genai.Citation{{Title: "test", URI: "google.com"}}, - }, - CustomMetadata: map[string]any{ - "custom_key": "custom_value", - }, - }, - }, - wantStoredSession: &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session1", - events: []*session.Event{ - { - ID: "event_complete", - Author: "user", - LongRunningToolIDs: []string{"tool123"}, - Actions: session.EventActions{StateDelta: map[string]any{"k2": "v2"}}, - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromText("test_text", "user"), - TurnComplete: true, - Partial: false, - ErrorCode: "error_code", - ErrorMessage: "error_message", - Interrupted: true, - GroundingMetadata: &genai.GroundingMetadata{ - WebSearchQueries: []string{"query1"}, - }, - UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: 1, - CandidatesTokenCount: 1, - TotalTokenCount: 2, - }, - CitationMetadata: &genai.CitationMetadata{ - Citations: []*genai.Citation{{Title: "test", URI: "google.com"}}, - }, - CustomMetadata: map[string]any{ - "custom_key": "custom_value", - }, - }, - }, - }, - state: map[string]any{ - "k1": "v1", - "k2": "v2", - }, - }, - wantEventCount: 1, - }, - { - name: "partial events are not persisted", - setup: serviceDbWithData, - session: &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - event: &session.Event{ - ID: "partial_event", - Author: "user", - LLMResponse: model.LLMResponse{ - Partial: true, // This is the key field - }, - }, - wantStoredSession: &localSession{ - appName: "app1", - userID: "user1", - sessionID: "session1", - events: []*session.Event{}, // No event should be stored - state: map[string]any{ - "k1": "v1", - }, - }, - wantEventCount: 0, // Expect 0 events - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := t.Context() - - s := tt.setup(t) - - tt.session.updatedAt = time.Now() // set updatedAt value to pass stale validation - err := s.AppendEvent(ctx, tt.session, tt.event) - if (err != nil) != tt.wantErr { - t.Errorf("databaseService.AppendEvent() error = %v, wantErr %v", err, tt.wantErr) - } - - if err != nil { - return - } - - resp, err := s.Get(ctx, &session.GetRequest{ - AppName: tt.session.AppName(), - UserID: tt.session.UserID(), - SessionID: tt.session.ID(), - }) - if err != nil { - t.Fatalf("databaseService.Get() error = %v, wantErr %v", err, tt.wantErr) - return - } - - // Check event count first - if resp.Session.Events().Len() != tt.wantEventCount { - t.Errorf("AppendEvent returned %d events, want %d", resp.Session.Events().Len(), tt.wantEventCount) - } - - // Define comparison options - opts := []cmp.Option{ - cmp.AllowUnexported(localSession{}), - cmpopts.IgnoreFields(localSession{}, "mu", "updatedAt"), - cmpopts.IgnoreFields(session.Event{}, "Timestamp"), - // Add sorters if event order is not guaranteed - cmpopts.SortSlices(func(a, b *session.Event) bool { - return a.ID < b.ID - }), - } - - if diff := cmp.Diff(tt.wantStoredSession, resp.Session, opts...); diff != "" { - t.Errorf("AppendEvent session mismatch: (-want +got):\n%s", diff) - } - }) - } -} - -func Test_databaseService_StateManagement(t *testing.T) { - ctx := t.Context() - appName := "my_app" - - t.Run("app_state_is_shared", func(t *testing.T) { - s := emptyService(t) - s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1", State: map[string]any{"app:k1": "v1"}}) - s1.Session.(*localSession).updatedAt = time.Now() - err := s.AppendEvent(ctx, s1.Session.(*localSession), &session.Event{ - ID: "event1", - Actions: session.EventActions{StateDelta: map[string]any{"app:k2": "v2"}}, - LLMResponse: model.LLMResponse{}, - }) - if err != nil { - t.Fatalf("Failed to appendEvent: %v", err) - } - - s2, err := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u2", SessionID: "s2"}) - if err != nil { - t.Fatalf("Failed to create session for user 2: %v", err) - } - - wantState := map[string]any{"app:k1": "v1", "app:k2": "v2"} - gotState := maps.Collect(s2.Session.State().All()) - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("User 2 state mismatch (-want +got):\n%s", diff) - } +func Test_databaseService(t *testing.T) { + opts := session_test.SuiteOptions{SupportsUserProvidedSessionID: true} + session_test.RunServiceTests(t, opts, func(t *testing.T) session.Service { + return emptyService(t) }) - - t.Run("user_state_is_user_specific", func(t *testing.T) { - s := emptyService(t) - s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1", State: map[string]any{"user:k1": "v1"}}) - s1.Session.(*localSession).updatedAt = time.Now() - err := s.AppendEvent(ctx, s1.Session.(*localSession), &session.Event{ - ID: "event1", - Actions: session.EventActions{StateDelta: map[string]any{"user:k2": "v2"}}, - LLMResponse: model.LLMResponse{}, - }) - if err != nil { - t.Fatalf("Failed to appendEvent: %v", err) - } - - s1b, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1b"}) - wantStateU1 := map[string]any{"user:k1": "v1", "user:k2": "v2"} - gotStateU1 := maps.Collect(s1b.Session.State().All()) - if diff := cmp.Diff(wantStateU1, gotStateU1); diff != "" { - t.Errorf("User 1 second session state mismatch (-want +got):\n%s", diff) - } - - s2, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u2", SessionID: "s2"}) - gotStateU2 := maps.Collect(s2.Session.State().All()) - if len(gotStateU2) != 0 { - t.Errorf("User 2 should have empty state, but got: %v", gotStateU2) - } - }) - - t.Run("session_state_is_not_shared", func(t *testing.T) { - s := emptyService(t) - s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1", State: map[string]any{"sk1": "v1"}}) - s1.Session.(*localSession).updatedAt = time.Now() - err := s.AppendEvent(ctx, s1.Session.(*localSession), &session.Event{ - ID: "event1", - Actions: session.EventActions{StateDelta: map[string]any{"sk2": "v2"}}, - LLMResponse: model.LLMResponse{}, - }) - if err != nil { - t.Fatalf("Failed to appendEvent: %v", err) - } - - s1_got, _ := s.Get(ctx, &session.GetRequest{AppName: appName, UserID: "u1", SessionID: "s1"}) - wantState := map[string]any{"sk1": "v1", "sk2": "v2"} - gotState := maps.Collect(s1_got.Session.State().All()) - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("Refetched s1 state mismatch (-want +got):\n%s", diff) - } - - s1b, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1b"}) - gotStateS1b := maps.Collect(s1b.Session.State().All()) - if len(gotStateS1b) != 0 { - t.Errorf("Session s1b should have empty state, but got: %v", gotStateS1b) - } - }) - - t.Run("temp_state_is_not_persisted", func(t *testing.T) { - s := emptyService(t) - s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1"}) - s1.Session.(*localSession).updatedAt = time.Now() - event := &session.Event{ - ID: "event1", - Actions: session.EventActions{StateDelta: map[string]any{"temp:k1": "v1", "sk": "v2"}}, - LLMResponse: model.LLMResponse{}, - } - err := s.AppendEvent(ctx, s1.Session.(*localSession), event) - if err != nil { - t.Fatalf("Failed to appendEvent: %v", err) - } - invocationSession := s1.Session.(*localSession) - wantInvocationState := map[string]any{"sk": "v2", "temp:k1": "v1"} - gotInvocationState := maps.Collect(invocationSession.State().All()) - if diff := cmp.Diff(wantInvocationState, gotInvocationState); diff != "" { - t.Errorf("Invocation session state mismatch (-want +got):\n%s", diff) - } - - s1_got, _ := s.Get(ctx, &session.GetRequest{AppName: appName, UserID: "u1", SessionID: "s1"}) - wantState := map[string]any{"sk": "v2"} - gotState := maps.Collect(s1_got.Session.State().All()) - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("Persisted state mismatch (-want +got):\n%s", diff) - } - - storedEvents := s1_got.Session.Events() - if storedEvents.Len() != 1 { - t.Fatalf("Expected 1 stored event, got %d", storedEvents.Len()) - } - storedDelta := storedEvents.At(0).Actions.StateDelta - if _, exists := storedDelta["temp:k1"]; exists { - t.Errorf("temp:k1 key was found in the stored event's state delta") - } - if storedDelta["sk"] != "v2" { - t.Errorf("Expected 'sk' key in stored event, but was missing or wrong value") - } - }) -} - -func serviceDbWithData(t *testing.T) *databaseService { - t.Helper() - - service := emptyService(t) - - for _, storedSession := range []*localSession{ - { - appName: "app1", - userID: "user1", - sessionID: "session1", - state: map[string]any{ - "k1": "v1", - }, - }, - { - appName: "app1", - userID: "user2", - sessionID: "session1", - state: map[string]any{ - "k1": "v2", - }, - }, - { - appName: "app1", - userID: "user1", - sessionID: "session2", - state: map[string]any{ - "k1": "v2", - }, - }, - { - appName: "app2", - userID: "user2", - sessionID: "session2", - state: map[string]any{ - "k2": "v2", - }, - events: []*session.Event{ - { - ID: "existing_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - }, - }, - } { - // TODO: Consider changing to SQL insert - resp, err := service.Create(t.Context(), &session.CreateRequest{ - AppName: storedSession.appName, - UserID: storedSession.userID, - SessionID: storedSession.sessionID, - State: storedSession.state, - }) - if err != nil { - t.Fatalf("Failed to create sample sessions on db initialization: %v", err) - } - - for _, ev := range storedSession.events { - err = service.AppendEvent(t.Context(), resp.Session, ev) - if err != nil { - t.Fatalf("Failed to append event to session on db initialization: %v", err) - } - } - } - - return service } func emptyService(t *testing.T) *databaseService { diff --git a/session/inmemory_test.go b/session/inmemory_test.go index 29733e94c..1d178be89 100644 --- a/session/inmemory_test.go +++ b/session/inmemory_test.go @@ -12,1009 +12,28 @@ // See the License for the specific language governing permissions and // limitations under the License. -package session +package session_test import ( - "maps" - "strconv" "strings" "sync" "sync/atomic" "testing" "time" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "google.golang.org/genai" - - "google.golang.org/adk/model" + "google.golang.org/adk/session" + "google.golang.org/adk/session/session_test" ) -func Test_databaseService_Create(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T) Service - req *CreateRequest - want Session - wantErr bool - }{ - { - name: "full key", - setup: emptyService, - req: &CreateRequest{ - AppName: "testApp", - UserID: "testUserID", - SessionID: "testSessionID", - State: map[string]any{ - "k": 5, - }, - }, - }, - { - name: "generated session id", - setup: emptyService, - req: &CreateRequest{ - AppName: "testApp", - UserID: "testUserID", - State: map[string]any{ - "k": 5, - }, - }, - }, - { - name: "when already exists, it fails", - setup: serviceDbWithData, - req: &CreateRequest{ - AppName: "app1", - UserID: "user1", - SessionID: "session1", - State: map[string]any{ - "k": 10, - }, - }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := tt.setup(t) - - got, err := s.Create(t.Context(), tt.req) - if (err != nil) != tt.wantErr { - t.Fatalf("databaseService.Create() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if err != nil { - return - } - - if got.Session.AppName() != tt.req.AppName { - t.Errorf("AppName got: %v, want: %v", got.Session.AppName(), tt.wantErr) - } - - if got.Session.UserID() != tt.req.UserID { - t.Errorf("UserID got: %v, want: %v", got.Session.UserID(), tt.wantErr) - } - - if tt.req.SessionID != "" { - if got.Session.ID() != tt.req.SessionID { - t.Errorf("SessionID got: %v, want: %v", got.Session.ID(), tt.wantErr) - } - } else { - if got.Session.ID() == "" { - t.Errorf("SessionID was not generated on empty user input.") - } - } - - gotState := maps.Collect(got.Session.State().All()) - wantState := tt.req.State - - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("Create State mismatch: (-want +got):\n%s", diff) - } - }) - } -} - -func Test_databaseService_Delete(t *testing.T) { - tests := []struct { - name string - req *DeleteRequest - setup func(t *testing.T) Service - wantErr bool - }{ - { - name: "delete ok", - setup: serviceDbWithData, - req: &DeleteRequest{ - AppName: "app1", - UserID: "user1", - SessionID: "session1", - }, - }, - { - name: "no error when not found", - setup: serviceDbWithData, - req: &DeleteRequest{ - AppName: "appTest", - UserID: "user1", - SessionID: "session1", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := tt.setup(t) - if err := s.Delete(t.Context(), tt.req); (err != nil) != tt.wantErr { - t.Errorf("databaseService.Delete() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func Test_databaseService_Get(t *testing.T) { - // This setup function is required for a test case. - // It creates the specific scenario from 'test_get_session_respects_user_id'. - setupGetRespectsUserID := func(t *testing.T) Service { - t.Helper() - s := serviceDbWithData(t) // Starts with the standard data - - // u1 creates s1 and adds an event. - // 'serviceDbWithData' already created - // (app1, user1, session1) - // (app1, user2, session1) - // We just need to add an event to it. - session1, err := s.Get(t.Context(), &GetRequest{ - AppName: "app1", - UserID: "user1", - SessionID: "session1", - }) - if err != nil { - t.Fatalf("setupGetRespectsUserID failed to get session1: %v", err) - } - - // Update 'updatedAt' to pass stale validation on append - session1.Session.(*session).updatedAt = time.Now() - - err = s.AppendEvent(t.Context(), session1.Session.(*session), &Event{ - ID: "event_for_user1", - Author: "user", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }) - if err != nil { - t.Fatalf("setupGetRespectsUserID failed to append event: %v", err) - } - return s - } - - setupGetWithConfig := func(t *testing.T) Service { - t.Helper() - s := emptyService(t) - ctx := t.Context() - numTestEvents := 5 - created, err := s.Create(ctx, &CreateRequest{ - AppName: "my_app", - UserID: "user", - SessionID: "s1", - }) - if err != nil { - t.Fatalf("setupGetWithConfig failed to create session: %v", err) - } - - for i := 1; i <= numTestEvents; i++ { - created.Session.(*session).updatedAt = time.Now() - event := &Event{ - ID: strconv.Itoa(i), - Author: "user", - Timestamp: time.Time{}.Add(time.Duration(i)), - LLMResponse: model.LLMResponse{}, - } - if err := s.AppendEvent(ctx, created.Session.(*session), event); err != nil { - t.Fatalf("setupGetWithConfig failed to append event %d: %v", i, err) - } - } - return s - } - - tests := []struct { - name string - req *GetRequest - setup func(t *testing.T) Service - wantResponse *GetResponse - wantEvents []*Event - wantErr bool - }{ - { - name: "ok", - setup: serviceDbWithData, - req: &GetRequest{ - AppName: "app1", - UserID: "user1", - SessionID: "session1", - }, - wantResponse: &GetResponse{ - Session: &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - state: map[string]any{ - "k1": "v1", - }, - events: []*Event{}, - }, - }, - }, - { - name: "error when not found", - setup: serviceDbWithData, - req: &GetRequest{ - AppName: "testApp", - UserID: "user1", - SessionID: "session1", - }, - wantErr: true, - }, - { - name: "get session respects user id", - setup: setupGetRespectsUserID, - req: &GetRequest{ - AppName: "app1", - UserID: "user2", - SessionID: "session1", - }, - wantResponse: &GetResponse{ - Session: &session{ - id: id{ - appName: "app1", - userID: "user2", - sessionID: "session1", - }, - // This is user2's session, which should have its own state - state: map[string]any{ - "k1": "v2", - }, - // Critically, it should NOT have the event from user1's session - events: []*Event{}, - }, - }, - wantErr: false, - }, - { - name: "with config_no config returns all events", - setup: setupGetWithConfig, - req: &GetRequest{ - AppName: "my_app", UserID: "user", SessionID: "s1", - }, - wantEvents: []*Event{ - {ID: "1", Author: "user", Timestamp: time.Time{}.Add(1), LLMResponse: model.LLMResponse{}}, - {ID: "2", Author: "user", Timestamp: time.Time{}.Add(2), LLMResponse: model.LLMResponse{}}, - {ID: "3", Author: "user", Timestamp: time.Time{}.Add(3), LLMResponse: model.LLMResponse{}}, - {ID: "4", Author: "user", Timestamp: time.Time{}.Add(4), LLMResponse: model.LLMResponse{}}, - {ID: "5", Author: "user", Timestamp: time.Time{}.Add(5), LLMResponse: model.LLMResponse{}}, - }, - }, - { - name: "with config_num recent events", - setup: setupGetWithConfig, - req: &GetRequest{ - AppName: "my_app", UserID: "user", SessionID: "s1", - NumRecentEvents: 3, - }, - wantEvents: []*Event{ - {ID: "3", Author: "user", Timestamp: time.Time{}.Add(3), LLMResponse: model.LLMResponse{}}, - {ID: "4", Author: "user", Timestamp: time.Time{}.Add(4), LLMResponse: model.LLMResponse{}}, - {ID: "5", Author: "user", Timestamp: time.Time{}.Add(5), LLMResponse: model.LLMResponse{}}, - }, - }, - { - name: "with config_after timestamp", - setup: setupGetWithConfig, - req: &GetRequest{ - AppName: "my_app", UserID: "user", SessionID: "s1", - After: time.Time{}.Add(4), - }, - wantEvents: []*Event{ - {ID: "4", Author: "user", Timestamp: time.Time{}.Add(4), LLMResponse: model.LLMResponse{}}, - {ID: "5", Author: "user", Timestamp: time.Time{}.Add(5), LLMResponse: model.LLMResponse{}}, - }, - }, - { - name: "with config_combined filters", - setup: setupGetWithConfig, - req: &GetRequest{ - AppName: "my_app", UserID: "user", SessionID: "s1", - NumRecentEvents: 3, - After: time.Time{}.Add(4), - }, - wantEvents: []*Event{ - {ID: "4", Author: "user", Timestamp: time.Time{}.Add(4), LLMResponse: model.LLMResponse{}}, - {ID: "5", Author: "user", Timestamp: time.Time{}.Add(5), LLMResponse: model.LLMResponse{}}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := tt.setup(t) - - got, err := s.Get(t.Context(), tt.req) - if (err != nil) != tt.wantErr { - t.Fatalf("databaseService.Get() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if err != nil { - return - } - - if tt.wantResponse != nil { - if diff := cmp.Diff(tt.wantResponse, got, - cmp.AllowUnexported(session{}), - cmp.AllowUnexported(id{}), - cmpopts.IgnoreFields(session{}, "mu", "updatedAt")); diff != "" { - t.Errorf("Get session mismatch: (-want +got):\n%s", diff) - } - } - - if tt.wantEvents != nil { - opts := []cmp.Option{ - cmpopts.SortSlices(func(a, b *Event) bool { return a.Timestamp.Before(b.Timestamp) }), - } - if diff := cmp.Diff(events(tt.wantEvents), got.Session.Events(), opts...); diff != "" { - t.Errorf("Get session events mismatch: (-want +got):\n%s", diff) - } - } - }) - } -} - -func Test_databaseService_List(t *testing.T) { - tests := []struct { - name string - req *ListRequest - setup func(t *testing.T) Service - wantResponse *ListResponse - wantErr bool - }{ - { - name: "list for user1", - setup: serviceDbWithData, - req: &ListRequest{ - AppName: "app1", - UserID: "user1", - }, - wantResponse: &ListResponse{ - Sessions: []Session{ - &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - state: map[string]any{ - "k1": "v1", - }, - }, - &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session2", - }, - state: map[string]any{ - "k1": "v2", - }, - }, - }, - }, - }, - { - name: "empty list for non-existent user", - setup: serviceDbWithData, - req: &ListRequest{ - AppName: "app1", - UserID: "custom_user", - }, - wantResponse: &ListResponse{ - Sessions: []Session{}, - }, - }, - { - name: "list for user2", - setup: serviceDbWithData, - req: &ListRequest{ - AppName: "app1", - UserID: "user2", - }, - wantResponse: &ListResponse{ - Sessions: []Session{ - &session{ - id: id{ - appName: "app1", - userID: "user2", - sessionID: "session1", - }, - state: map[string]any{ - "k1": "v2", - }, - }, - }, - }, - }, - { - name: "list all users for app", - setup: serviceDbWithData, - req: &ListRequest{AppName: "app1", UserID: ""}, - wantResponse: &ListResponse{ - Sessions: []Session{ - &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - state: map[string]any{"k1": "v1"}, - }, - &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session2", - }, - state: map[string]any{"k1": "v2"}, - }, - &session{ - id: id{ - appName: "app1", - userID: "user2", - sessionID: "session1", - }, - state: map[string]any{"k1": "v2"}, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := tt.setup(t) - got, err := s.List(t.Context(), tt.req) - if (err != nil) != tt.wantErr { - t.Errorf("databaseService.List() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if err == nil { - // Sort slices for stable comparison - opts := []cmp.Option{ - cmp.AllowUnexported(session{}), - cmp.AllowUnexported(id{}), - cmpopts.IgnoreFields(session{}, "mu", "updatedAt"), - cmpopts.SortSlices(func(a, b Session) bool { - return a.ID() < b.ID() - }), - } - if diff := cmp.Diff(tt.wantResponse, got, opts...); diff != "" { - t.Errorf("databaseService.List() = %v (-want +got):\n%s", got, diff) - } - } - }) - } -} - -func Test_databaseService_AppendEvent(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T) Service - session *session - event *Event - wantStoredSession *session // State of the session after Get - wantEventCount int // Expected event count in storage - wantErr bool - }{ - { - name: "append event to the session and overwrite in storage", - setup: serviceDbWithData, - session: &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - state: map[string]any{ - "k1": "v1", - }, - }, - event: &Event{ - ID: "new_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - wantStoredSession: &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - events: []*Event{ - { - ID: "new_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - }, - state: map[string]any{ - "k1": "v1", - }, - }, - wantEventCount: 1, - }, - { - name: "append event to the session with events and overwrite in storage", - setup: serviceDbWithData, - session: &session{ - id: id{ - appName: "app2", - userID: "user2", - sessionID: "session2", - }, - }, - event: &Event{ - ID: "new_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - wantStoredSession: &session{ - id: id{ - appName: "app2", - userID: "user2", - sessionID: "session2", - }, - events: []*Event{ - { - ID: "existing_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - { - ID: "new_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - }, - state: map[string]any{ - "k2": "v2", - }, - }, - wantEventCount: 2, - }, - { - name: "append event when session not found should fail", - setup: serviceDbWithData, - session: &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "custom_session", - }, - }, - event: &Event{ - ID: "new_event2", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - wantErr: true, - }, - { - name: "append event with bytes content", - setup: serviceDbWithData, - session: &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - state: map[string]any{ - "k1": "v1", - }, - }, - event: &Event{ - ID: "event_with_bytes", - Author: "user", - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromBytes([]byte("test_image_data"), "image/png", "user"), - GroundingMetadata: &genai.GroundingMetadata{ - SearchEntryPoint: &genai.SearchEntryPoint{ - SDKBlob: []byte("test_sdk_blob"), - }, - }, - }, - }, - wantStoredSession: &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - events: []*Event{ - { - ID: "event_with_bytes", - Author: "user", - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromBytes([]byte("test_image_data"), "image/png", "user"), - GroundingMetadata: &genai.GroundingMetadata{ - SearchEntryPoint: &genai.SearchEntryPoint{ - SDKBlob: []byte("test_sdk_blob"), - }, - }, - }, - }, - }, - state: map[string]any{ - "k1": "v1", - }, - }, - wantEventCount: 1, - }, - { - name: "append event with all fields", - setup: serviceDbWithData, - session: &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - state: map[string]any{ - "k1": "v1", - }, - }, - event: &Event{ - ID: "event_complete", - Author: "user", - LongRunningToolIDs: []string{"tool123"}, - Actions: EventActions{StateDelta: map[string]any{"k2": "v2"}}, - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromText("test_text", "user"), - TurnComplete: true, - Partial: false, - ErrorCode: "error_code", - ErrorMessage: "error_message", - Interrupted: true, - GroundingMetadata: &genai.GroundingMetadata{ - WebSearchQueries: []string{"query1"}, - }, - UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: 1, - CandidatesTokenCount: 1, - TotalTokenCount: 2, - }, - CitationMetadata: &genai.CitationMetadata{ - Citations: []*genai.Citation{{Title: "test", URI: "google.com"}}, - }, - CustomMetadata: map[string]any{ - "custom_key": "custom_value", - }, - }, - }, - wantStoredSession: &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - events: []*Event{ - { - ID: "event_complete", - Author: "user", - LongRunningToolIDs: []string{"tool123"}, - Actions: EventActions{StateDelta: map[string]any{"k2": "v2"}}, - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromText("test_text", "user"), - TurnComplete: true, - Partial: false, - ErrorCode: "error_code", - ErrorMessage: "error_message", - Interrupted: true, - GroundingMetadata: &genai.GroundingMetadata{ - WebSearchQueries: []string{"query1"}, - }, - UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: 1, - CandidatesTokenCount: 1, - TotalTokenCount: 2, - }, - CitationMetadata: &genai.CitationMetadata{ - Citations: []*genai.Citation{{Title: "test", URI: "google.com"}}, - }, - CustomMetadata: map[string]any{ - "custom_key": "custom_value", - }, - }, - }, - }, - state: map[string]any{ - "k1": "v1", - "k2": "v2", - }, - }, - wantEventCount: 1, - }, - { - name: "partial events are not persisted", - setup: serviceDbWithData, - session: &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - }, - event: &Event{ - ID: "partial_event", - Author: "user", - LLMResponse: model.LLMResponse{ - Partial: true, // This is the key field - }, - }, - wantStoredSession: &session{ - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - events: []*Event{}, // No event should be stored - state: map[string]any{ - "k1": "v1", - }, - }, - wantEventCount: 0, // Expect 0 events - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := t.Context() - - s := tt.setup(t) - - tt.session.updatedAt = time.Now() // set updatedAt value to pass stale validation - err := s.AppendEvent(ctx, tt.session, tt.event) - if (err != nil) != tt.wantErr { - t.Errorf("databaseService.AppendEvent() error = %v, wantErr %v", err, tt.wantErr) - } - - if err != nil { - return - } - - resp, err := s.Get(ctx, &GetRequest{ - AppName: tt.session.AppName(), - UserID: tt.session.UserID(), - SessionID: tt.session.ID(), - }) - if err != nil { - t.Fatalf("databaseService.Get() error = %v, wantErr %v", err, tt.wantErr) - return - } - - // Check event count first - if resp.Session.Events().Len() != tt.wantEventCount { - t.Errorf("AppendEvent returned %d events, want %d", resp.Session.Events().Len(), tt.wantEventCount) - } - - // Define comparison options - opts := []cmp.Option{ - cmp.AllowUnexported(session{}), - cmp.AllowUnexported(id{}), - cmpopts.IgnoreFields(session{}, "mu", "updatedAt"), - cmpopts.IgnoreFields(Event{}, "Timestamp"), - // Add sorters if event order is not guaranteed - cmpopts.SortSlices(func(a, b *Event) bool { - return a.ID < b.ID - }), - } - - if diff := cmp.Diff(tt.wantStoredSession, resp.Session, opts...); diff != "" { - t.Errorf("AppendEvent session mismatch: (-want +got):\n%s", diff) - } - }) - } -} - -func Test_inMemoryService_StateManagement(t *testing.T) { - ctx := t.Context() - appName := "my_app" - - t.Run("app_state_is_shared", func(t *testing.T) { - s := emptyService(t) - s1, _ := s.Create(ctx, &CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1", State: map[string]any{"app:k1": "v1"}}) - s1.Session.(*session).updatedAt = time.Now() - _ = s.AppendEvent(ctx, s1.Session.(*session), &Event{ - ID: "event1", - Actions: EventActions{StateDelta: map[string]any{"app:k2": "v2"}}, - LLMResponse: model.LLMResponse{}, - }) - - s2, err := s.Create(ctx, &CreateRequest{AppName: appName, UserID: "u2", SessionID: "s2"}) - if err != nil { - t.Fatalf("Failed to create session for user 2: %v", err) - } - - wantState := map[string]any{"app:k1": "v1", "app:k2": "v2"} - gotState := maps.Collect(s2.Session.State().All()) - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("User 2 state mismatch (-want +got):\n%s", diff) - } - }) - - t.Run("user_state_is_user_specific", func(t *testing.T) { - s := emptyService(t) - s1, _ := s.Create(ctx, &CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1", State: map[string]any{"user:k1": "v1"}}) - s1.Session.(*session).updatedAt = time.Now() - _ = s.AppendEvent(ctx, s1.Session.(*session), &Event{ - ID: "event1", - Actions: EventActions{StateDelta: map[string]any{"user:k2": "v2"}}, - LLMResponse: model.LLMResponse{}, - }) - - s1b, _ := s.Create(ctx, &CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1b"}) - wantStateU1 := map[string]any{"user:k1": "v1", "user:k2": "v2"} - gotStateU1 := maps.Collect(s1b.Session.State().All()) - if diff := cmp.Diff(wantStateU1, gotStateU1); diff != "" { - t.Errorf("User 1 second session state mismatch (-want +got):\n%s", diff) - } - - s2, _ := s.Create(ctx, &CreateRequest{AppName: appName, UserID: "u2", SessionID: "s2"}) - gotStateU2 := maps.Collect(s2.Session.State().All()) - if len(gotStateU2) != 0 { - t.Errorf("User 2 should have empty state, but got: %v", gotStateU2) - } - }) - - t.Run("session_state_is_not_shared", func(t *testing.T) { - s := emptyService(t) - s1, _ := s.Create(ctx, &CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1", State: map[string]any{"sk1": "v1"}}) - s1.Session.(*session).updatedAt = time.Now() - _ = s.AppendEvent(ctx, s1.Session.(*session), &Event{ - ID: "event1", - Actions: EventActions{StateDelta: map[string]any{"sk2": "v2"}}, - LLMResponse: model.LLMResponse{}, - }) - - s1_got, _ := s.Get(ctx, &GetRequest{AppName: appName, UserID: "u1", SessionID: "s1"}) - wantState := map[string]any{"sk1": "v1", "sk2": "v2"} - gotState := maps.Collect(s1_got.Session.State().All()) - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("Refetched s1 state mismatch (-want +got):\n%s", diff) - } - - s1b, _ := s.Create(ctx, &CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1b"}) - gotStateS1b := maps.Collect(s1b.Session.State().All()) - if len(gotStateS1b) != 0 { - t.Errorf("Session s1b should have empty state, but got: %v", gotStateS1b) - } - }) - - t.Run("temp_state_is_not_persisted", func(t *testing.T) { - s := emptyService(t) - s1, _ := s.Create(ctx, &CreateRequest{AppName: appName, UserID: "u1", SessionID: "s1"}) - s1.Session.(*session).updatedAt = time.Now() - event := &Event{ - ID: "event1", - Actions: EventActions{StateDelta: map[string]any{"temp:k1": "v1", "sk": "v2"}}, - LLMResponse: model.LLMResponse{}, - } - err := s.AppendEvent(ctx, s1.Session.(*session), event) - if err != nil { - t.Fatalf("Failed to append event: %v", err) - } - invocationSession := s1.Session.(*session) - wantInvocationState := map[string]any{"sk": "v2", "temp:k1": "v1"} - gotInvocationState := maps.Collect(invocationSession.State().All()) - if diff := cmp.Diff(wantInvocationState, gotInvocationState); diff != "" { - t.Errorf("Invocation session state mismatch (-want +got):\n%s", diff) - } - - s1_got, _ := s.Get(ctx, &GetRequest{AppName: appName, UserID: "u1", SessionID: "s1"}) - wantState := map[string]any{"sk": "v2"} - gotState := maps.Collect(s1_got.Session.State().All()) - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("Persisted state mismatch (-want +got):\n%s", diff) - } - - storedEvents := s1_got.Session.Events() - if storedEvents.Len() != 1 { - t.Fatalf("Expected 1 stored event, got %d", storedEvents.Len()) - } - storedDelta := storedEvents.At(0).Actions.StateDelta - if _, exists := storedDelta["temp:k1"]; exists { - t.Errorf("temp:k1 key was found in the stored event's state delta") - } - if storedDelta["sk"] != "v2" { - t.Errorf("Expected 'sk' key in stored event, but was missing or wrong value") - } +func Test_inMemoryService(t *testing.T) { + opts := session_test.SuiteOptions{SupportsUserProvidedSessionID: true} // InMemory supports custom IDs + session_test.RunServiceTests(t, opts, func(t *testing.T) session.Service { + return session.InMemoryService() }) } -func serviceDbWithData(t *testing.T) Service { - t.Helper() - - service := emptyService(t).(*inMemoryService) - - for _, storedSession := range []*session{ - { - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session1", - }, - state: map[string]any{ - "k1": "v1", - }, - }, - { - id: id{ - appName: "app1", - userID: "user2", - sessionID: "session1", - }, - state: map[string]any{ - "k1": "v2", - }, - }, - { - id: id{ - appName: "app1", - userID: "user1", - sessionID: "session2", - }, - state: map[string]any{ - "k1": "v2", - }, - }, - { - id: id{ - appName: "app2", - userID: "user2", - sessionID: "session2", - }, - state: map[string]any{ - "k2": "v2", - }, - events: []*Event{ - { - ID: "existing_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - }, - }, - } { - service.sessions.Set(storedSession.id.Encode(), storedSession) - } - - return service -} - -func emptyService(t *testing.T) Service { - t.Helper() - return InMemoryService() -} - -// TODO: test concurrency func Test_inMemoryService_CreateConcurrentAccess(t *testing.T) { - s := InMemoryService() + s := session.InMemoryService() const goroutines = 16 const attempts = 32 @@ -1022,7 +41,7 @@ func Test_inMemoryService_CreateConcurrentAccess(t *testing.T) { var wg sync.WaitGroup wg.Add(goroutines) - req := &CreateRequest{ + req := &session.CreateRequest{ AppName: "race-app", UserID: "race-user", SessionID: "race-session", @@ -1061,10 +80,10 @@ func Test_inMemoryService_CreateConcurrentAccess(t *testing.T) { func TestInMemorySession_AppendEvent_Deadlock(t *testing.T) { ctx := t.Context() - service := InMemoryService() + service := session.InMemoryService() // Create a session - createReq := &CreateRequest{ + createReq := &session.CreateRequest{ AppName: "testapp", UserID: "testuser", } @@ -1075,10 +94,10 @@ func TestInMemorySession_AppendEvent_Deadlock(t *testing.T) { sess := createResp.Session // Event with StateDelta to trigger updateSessionState - event := &Event{ + event := &session.Event{ ID: "event1", Timestamp: time.Now(), - Actions: EventActions{ + Actions: session.EventActions{ StateDelta: map[string]any{ "test_key": "test_value", }, diff --git a/session/session_test/service_suite.go b/session/session_test/service_suite.go new file mode 100644 index 000000000..ff3b51bb0 --- /dev/null +++ b/session/session_test/service_suite.go @@ -0,0 +1,743 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package session_test + +import ( + "strconv" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "google.golang.org/genai" + + "google.golang.org/adk/model" + "google.golang.org/adk/session" +) + +// ExpectedSession represents a snapshot of a session's public state for test comparisons. +type ExpectedSession struct { + AppName string + UserID string + SessionID string + State map[string]any + Events []*session.Event +} + +// Snapshot extracts values from ANY session.Session implementation. +func Snapshot(s session.Session) ExpectedSession { + if s == nil { + return ExpectedSession{} + } + + state := make(map[string]any) + if s.State() != nil { + for k, v := range s.State().All() { + state[k] = v + } + } + + var events []*session.Event + if s.Events() != nil { + for e := range s.Events().All() { + events = append(events, e) + } + } + + return ExpectedSession{ + AppName: s.AppName(), + UserID: s.UserID(), + SessionID: s.ID(), + State: state, + Events: events, + } +} + +// SuiteOptions holds configuration for adaptive test runs. +type SuiteOptions struct { + SupportsUserProvidedSessionID bool + ProvidesServerAssignedEventID bool + AppName string +} + +// RunServiceTests runs a battery of standard tests against a Session.Service. +func RunServiceTests(t *testing.T, opts SuiteOptions, setup func(t *testing.T) session.Service) { + testAppName := "testApp" + if opts.AppName != "" { + testAppName = opts.AppName + } + t.Run("Create", func(t *testing.T) { + t.Run("full_key", func(t *testing.T) { + if !opts.SupportsUserProvidedSessionID { + t.Skip("Skipping full key test: requires user provided session ID support") + } + s := setup(t) + req := &session.CreateRequest{ + AppName: testAppName, + UserID: "testUserID", + SessionID: "testSessionID", + State: map[string]any{ + "k": float64(5), + }, + } + + got, err := s.Create(t.Context(), req) + if opts.SupportsUserProvidedSessionID { + if err != nil { + t.Fatalf("Create() error = %v, wantErr %v", err, false) + } + if got.Session.AppName() != req.AppName { + t.Errorf("AppName got: %v, want: %v", got.Session.AppName(), req.AppName) + } + if got.Session.UserID() != req.UserID { + t.Errorf("UserID got: %v, want: %v", got.Session.UserID(), req.UserID) + } + if got.Session.ID() != req.SessionID { + t.Errorf("SessionID got: %v, want: %v", got.Session.ID(), req.SessionID) + } + } else { + if err == nil { + t.Fatalf("Expected error for user-provided SessionID") + } + } + }) + + t.Run("generated_session_id", func(t *testing.T) { + s := setup(t) + req := &session.CreateRequest{ + AppName: testAppName, + UserID: "testUserID", + State: map[string]any{"k": float64(5)}, + } + + got, err := s.Create(t.Context(), req) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if got.Session.ID() == "" { + t.Errorf("Expected generated SessionID, got empty") + } + }) + + t.Run("when_already_exists,_it_fails", func(t *testing.T) { + s := setup(t) + req := &session.CreateRequest{ + AppName: testAppName, + UserID: "testUserID", + } + + got1, err := s.Create(t.Context(), req) + if err != nil { + t.Fatalf("First Create() failed: %v", err) + } + + req2 := &session.CreateRequest{ + AppName: req.AppName, + UserID: req.UserID, + SessionID: got1.Session.ID(), + } + + _, err = s.Create(t.Context(), req2) + if opts.SupportsUserProvidedSessionID { + if err == nil { + t.Errorf("Expected failure when creating duplicate session") + } + } else { + if err == nil { + t.Errorf("Expected failure (unsupported or duplicate)") + } + } + }) + }) + + t.Run("Get", func(t *testing.T) { + t.Run("ok", func(t *testing.T) { + s := setup(t) + req := &session.CreateRequest{ + AppName: testAppName, + UserID: "testUserID", + State: map[string]any{"k1": "v1"}, + } + created, err := s.Create(t.Context(), req) + if err != nil { + t.Fatalf("Setup: Create failed: %v", err) + } + + got, err := s.Get(t.Context(), &session.GetRequest{ + AppName: req.AppName, + UserID: req.UserID, + SessionID: created.Session.ID(), + }) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + snap := Snapshot(got.Session) + if snap.AppName != req.AppName { + t.Errorf("Get AppName = %v, want %v", snap.AppName, req.AppName) + } + if snap.UserID != req.UserID { + t.Errorf("Get UserID = %v, want %v", snap.UserID, req.UserID) + } + if snap.State["k1"] != "v1" { + t.Errorf("Get State[k1] = %v, want v1", snap.State["k1"]) + } + }) + + t.Run("error_when_not_found", func(t *testing.T) { + s := setup(t) + _, err := s.Get(t.Context(), &session.GetRequest{ + AppName: "nonExistent", + UserID: "user", + SessionID: "s1", + }) + if err == nil { + t.Errorf("Expected error for non-existent session") + } + }) + + t.Run("get_session_respects_user_id", func(t *testing.T) { + s := setup(t) + ctx := t.Context() + + c1, err := s.Create(ctx, &session.CreateRequest{AppName: testAppName, UserID: "user1"}) + if err != nil { + t.Fatalf("Create user1 failed: %v", err) + } + + err = s.AppendEvent(ctx, c1.Session, &session.Event{ + ID: "event1", + Author: "user", + InvocationID: "inv1", + }) + if err != nil { + t.Fatalf("AppendEvent failed: %v", err) + } + + _, err = s.Get(ctx, &session.GetRequest{ + AppName: testAppName, + UserID: "user2", + SessionID: c1.Session.ID(), + }) + if err == nil { + t.Errorf("Expected error or not found when getting session with wrong UserID") + } + }) + + t.Run("with_config_filters", func(t *testing.T) { + s := setup(t) + ctx := t.Context() + req := &session.CreateRequest{AppName: testAppName, UserID: "user1"} + if opts.SupportsUserProvidedSessionID { + req.SessionID = "s1" + } + created, err := s.Create(ctx, req) + if err != nil { + t.Fatalf("Setup: Create failed: %v", err) + } + + numTestEvents := 5 + var timestamps []time.Time + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + for i := 1; i <= numTestEvents; i++ { + ts := baseTime.Add(time.Duration(i) * time.Second) + timestamps = append(timestamps, ts) + event := &session.Event{ + ID: strconv.Itoa(i), + Author: "user", + InvocationID: "inv1", + Timestamp: ts, + } + if err := s.AppendEvent(ctx, created.Session, event); err != nil { + t.Fatalf("AppendEvent failed: %v", err) + } + } + + gotNormal, err := s.Get(ctx, &session.GetRequest{AppName: testAppName, UserID: "user1", SessionID: created.Session.ID()}) + if err != nil { + t.Fatalf("Get error: %v", err) + } + if gotNormal.Session.Events().Len() != 5 { + t.Errorf("Get no filter events len = %d, want 5", gotNormal.Session.Events().Len()) + } + + gotLimit, err := s.Get(ctx, &session.GetRequest{AppName: testAppName, UserID: "user1", SessionID: created.Session.ID(), NumRecentEvents: 3}) + if err != nil { + t.Fatalf("Get error: %v", err) + } + if gotLimit.Session.Events().Len() != 3 { + t.Errorf("Get NumRecentEvents len = %d, want 3", gotLimit.Session.Events().Len()) + } + + gotAfter, err := s.Get(ctx, &session.GetRequest{AppName: testAppName, UserID: "user1", SessionID: created.Session.ID(), After: timestamps[1]}) + if err != nil { + t.Fatalf("Get error: %v", err) + } + if gotAfter.Session.Events().Len() != 4 { + t.Errorf("Get After filter events len = %d, want 4", gotAfter.Session.Events().Len()) + } + + gotCombined, err := s.Get(ctx, &session.GetRequest{AppName: testAppName, UserID: "user1", SessionID: created.Session.ID(), NumRecentEvents: 2, After: timestamps[0]}) + if err != nil { + t.Fatalf("Get error: %v", err) + } + if gotCombined.Session.Events().Len() != 2 { + t.Errorf("Get Combined filters len = %d, want 2", gotCombined.Session.Events().Len()) + } + }) + }) + + t.Run("List", func(t *testing.T) { + s := setup(t) + ctx := t.Context() + + _, err := s.Create(ctx, &session.CreateRequest{AppName: testAppName, UserID: "user1"}) + if err != nil { + t.Fatalf("Create user1 session1 failed: %v", err) + } + _, err = s.Create(ctx, &session.CreateRequest{AppName: testAppName, UserID: "user1"}) + if err != nil { + t.Fatalf("Create user1 session2 failed: %v", err) + } + + _, err = s.Create(ctx, &session.CreateRequest{AppName: testAppName, UserID: "user2"}) + if err != nil { + t.Fatalf("Create user2 failed: %v", err) + } + + got1, err := s.List(ctx, &session.ListRequest{AppName: testAppName, UserID: "user1"}) + if err != nil { + t.Fatalf("List user1 failed: %v", err) + } + if len(got1.Sessions) != 2 { + t.Errorf("List user1 len = %d, want 2", len(got1.Sessions)) + } + + got2, err := s.List(ctx, &session.ListRequest{AppName: testAppName, UserID: "user2"}) + if err != nil { + t.Fatalf("List user2 failed: %v", err) + } + if len(got2.Sessions) != 1 { + t.Errorf("List user2 len = %d, want 1", len(got2.Sessions)) + } + + got3, err := s.List(ctx, &session.ListRequest{AppName: testAppName, UserID: "nonExistent"}) + if err != nil { + t.Fatalf("List nonExistent failed: %v", err) + } + if len(got3.Sessions) != 0 { + t.Errorf("List nonExistent len = %d, want 0", len(got3.Sessions)) + } + + gotAll, err := s.List(ctx, &session.ListRequest{AppName: testAppName}) + if err != nil { + t.Fatalf("List all users failed: %v", err) + } + if len(gotAll.Sessions) != 3 { + t.Errorf("List all users len = %d, want 3", len(gotAll.Sessions)) + } + }) + + t.Run("Delete", func(t *testing.T) { + s := setup(t) + ctx := t.Context() + + created, err := s.Create(ctx, &session.CreateRequest{AppName: testAppName, UserID: "user1"}) + if err != nil { + t.Fatalf("Setup: Create failed: %v", err) + } + + err = s.Delete(ctx, &session.DeleteRequest{ + AppName: testAppName, + UserID: "user1", + SessionID: created.Session.ID(), + }) + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + + _, err = s.Get(ctx, &session.GetRequest{ + AppName: testAppName, + UserID: "user1", + SessionID: created.Session.ID(), + }) + if err == nil { + t.Errorf("Expected error when getting deleted session") + } + + if opts.SupportsUserProvidedSessionID { + err = s.Delete(ctx, &session.DeleteRequest{ + AppName: testAppName, + UserID: "user1", + SessionID: "nonExistent", + }) + if err != nil { + t.Errorf("Delete() non-existent error = %v, want nil", err) + } + } + }) + + t.Run("AppendEvent", func(t *testing.T) { + t.Run("when_session_not_found_should_fail", func(t *testing.T) { + s := setup(t) + ctx := t.Context() + + m := &mockSession{appName: testAppName, userID: "user1", id: "nonExistent"} + event := &session.Event{ID: "event1", Author: "user", InvocationID: "inv1"} + + err := s.AppendEvent(ctx, m, event) + if err == nil { + t.Errorf("AppendEvent() expected error for non-existent session, got nil") + } + }) + + t.Run("ok", func(t *testing.T) { + s := setup(t) + ctx := t.Context() + + created, err := s.Create(ctx, &session.CreateRequest{AppName: testAppName, UserID: "user1"}) + if err != nil { + t.Fatalf("Setup: Create failed: %v", err) + } + + event := &session.Event{ + ID: "new_event1", + Author: "user", + InvocationID: "inv1", + } + err = s.AppendEvent(ctx, created.Session, event) + if err != nil { + t.Fatalf("AppendEvent() error = %v", err) + } + + got, err := s.Get(ctx, &session.GetRequest{ + AppName: testAppName, + UserID: "user1", + SessionID: created.Session.ID(), + }) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + snap := Snapshot(got.Session) + if len(snap.Events) != 1 { + t.Errorf("Expected 1 event, got %d", len(snap.Events)) + } else { + if !opts.ProvidesServerAssignedEventID && snap.Events[0].ID != event.ID { + t.Errorf("Event ID mismatch: got %v, want %v", snap.Events[0].ID, event.ID) + } + } + }) + + t.Run("partial_events_are_not_persisted", func(t *testing.T) { + s := setup(t) + ctx := t.Context() + + created, err := s.Create(ctx, &session.CreateRequest{AppName: testAppName, UserID: "user1"}) + if err != nil { + t.Fatalf("Setup: Create failed: %v", err) + } + + event := &session.Event{ + ID: "partial_event", + Author: "user", + InvocationID: "inv1", + LLMResponse: model.LLMResponse{ + Partial: true, + }, + } + err = s.AppendEvent(ctx, created.Session, event) + if err != nil { + t.Fatalf("AppendEvent() error = %v", err) + } + + got, err := s.Get(ctx, &session.GetRequest{ + AppName: testAppName, + UserID: "user1", + SessionID: created.Session.ID(), + }) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + snap := Snapshot(got.Session) + if len(snap.Events) != 0 { + t.Errorf("Expected 0 events (Partial=true should be skipped), got %d", len(snap.Events)) + } + }) + + t.Run("with_bytes_content", func(t *testing.T) { + s := setup(t) + ctx := t.Context() + + created, err := s.Create(ctx, &session.CreateRequest{AppName: testAppName, UserID: "user1"}) + if err != nil { + t.Fatalf("Setup: Create failed: %v", err) + } + + event := &session.Event{ + ID: "event_with_bytes", + Author: "user", + InvocationID: "inv1", + LLMResponse: model.LLMResponse{ + Content: genai.NewContentFromBytes([]byte("test_image_data"), "image/png", "user"), + }, + } + err = s.AppendEvent(ctx, created.Session, event) + if err != nil { + t.Fatalf("AppendEvent() error = %v", err) + } + + got, err := s.Get(ctx, &session.GetRequest{ + AppName: testAppName, + UserID: "user1", + SessionID: created.Session.ID(), + }) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + snap := Snapshot(got.Session) + if len(snap.Events) != 1 { + t.Fatalf("Expected 1 event, got %d", len(snap.Events)) + } + gotEvent := snap.Events[0] + + if diff := cmp.Diff(event.LLMResponse.Content, gotEvent.LLMResponse.Content); diff != "" { + t.Errorf("Content mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("with_existing_events", func(t *testing.T) { + s := setup(t) + ctx := t.Context() + + created, err := s.Create(ctx, &session.CreateRequest{AppName: testAppName, UserID: "user1"}) + if err != nil { + t.Fatalf("Setup: Create failed: %v", err) + } + + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + event1 := &session.Event{ID: "event1", Author: "user", InvocationID: "inv1", Timestamp: baseTime.Add(1 * time.Second)} + if err := s.AppendEvent(ctx, created.Session, event1); err != nil { + t.Fatalf("AppendEvent(1) failed: %v", err) + } + + event2 := &session.Event{ID: "event2", Author: "user", InvocationID: "inv2", Timestamp: baseTime.Add(2 * time.Second)} + if err := s.AppendEvent(ctx, created.Session, event2); err != nil { + t.Fatalf("AppendEvent(2) failed: %v", err) + } + + got, err := s.Get(ctx, &session.GetRequest{ + AppName: testAppName, + UserID: "user1", + SessionID: created.Session.ID(), + }) + if err != nil { + t.Fatalf("Get() failed: %v", err) + } + + snap := Snapshot(got.Session) + if len(snap.Events) != 2 { + t.Fatalf("Expected 2 events, got %d", len(snap.Events)) + } + expectedOrder := []string{"inv1", "inv2"} + for i := range snap.Events { + if snap.Events[i].InvocationID != expectedOrder[i] { + t.Errorf("Expected event order for %v, got events with InvocationIDs", expectedOrder) + break + } + } + }) + + t.Run("with_all_fields", func(t *testing.T) { + s := setup(t) + ctx := t.Context() + + created, err := s.Create(ctx, &session.CreateRequest{AppName: testAppName, UserID: "user1"}) + if err != nil { + t.Fatalf("Setup: Create failed: %v", err) + } + + event := &session.Event{ + ID: "event_complete", + Author: "user", + InvocationID: "inv1", + LongRunningToolIDs: []string{"tool123"}, + Actions: session.EventActions{StateDelta: map[string]any{"user:k2": "v2"}}, + LLMResponse: model.LLMResponse{ + Content: genai.NewContentFromText("test_text", "user"), + TurnComplete: true, + Partial: false, + ErrorCode: "error_code", + ErrorMessage: "error_message", + Interrupted: true, + GroundingMetadata: &genai.GroundingMetadata{ + WebSearchQueries: []string{"query1"}, + }, + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 1, + CandidatesTokenCount: 1, + TotalTokenCount: 2, + }, + CitationMetadata: &genai.CitationMetadata{ + Citations: []*genai.Citation{{Title: "test", URI: "google.com"}}, + }, + CustomMetadata: map[string]any{ + "custom_key": "custom_value", + }, + }, + } + + err = s.AppendEvent(ctx, created.Session, event) + if err != nil { + t.Fatalf("AppendEvent() error = %v", err) + } + + got, err := s.Get(ctx, &session.GetRequest{ + AppName: testAppName, + UserID: "user1", + SessionID: created.Session.ID(), + }) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + snap := Snapshot(got.Session) + if len(snap.Events) != 1 { + t.Fatalf("Expected 1 event, got %d", len(snap.Events)) + } + gotEvent := snap.Events[0] + + cmpOpts := []cmp.Option{ + cmp.AllowUnexported(session.Event{}), + } + if opts.ProvidesServerAssignedEventID { + cmpOpts = append(cmpOpts, cmpopts.IgnoreFields(session.Event{}, "ID")) + cmpOpts = append(cmpOpts, cmpopts.IgnoreFields(model.LLMResponse{}, "CitationMetadata", "UsageMetadata")) + } + + if diff := cmp.Diff(event, gotEvent, cmpOpts...); diff != "" { + t.Errorf("Event mismatch (-want +got):\n%s", diff) + } + }) + }) + + t.Run("StateManagement", func(t *testing.T) { + ctx := t.Context() + appName := testAppName + + t.Run("app_state_is_shared", func(t *testing.T) { + s := setup(t) + s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", State: map[string]any{"app:k1": "v1"}}) + if err := s.AppendEvent(ctx, s1.Session, &session.Event{ + ID: "event1", + Author: "user", + InvocationID: "inv1", + Actions: session.EventActions{StateDelta: map[string]any{"app:k2": "v2"}}, + }); err != nil { + t.Fatalf("AppendEvent failed: %v", err) + } + + s2, err := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u2"}) + if err != nil { + t.Fatalf("Create user2 failed: %v", err) + } + + snap := Snapshot(s2.Session) + if snap.State["app:k1"] != "v1" || snap.State["app:k2"] != "v2" { + t.Errorf("App state not shared, got: %v", snap.State) + } + }) + + t.Run("user_state_is_user_specific", func(t *testing.T) { + s := setup(t) + s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", State: map[string]any{"user:k1": "v1"}}) + if err := s.AppendEvent(ctx, s1.Session, &session.Event{ + ID: "event1", + Author: "user", + InvocationID: "inv1", + Actions: session.EventActions{StateDelta: map[string]any{"user:k2": "v2"}}, + }); err != nil { + t.Fatalf("AppendEvent failed: %v", err) + } + + s1b, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1"}) + snap1b := Snapshot(s1b.Session) + if snap1b.State["user:k1"] != "v1" || snap1b.State["user:k2"] != "v2" { + t.Errorf("User state missing for same user, got: %v", snap1b.State) + } + + s2, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u2"}) + snap2 := Snapshot(s2.Session) + if _, exists := snap2.State["user:k1"]; exists { + t.Errorf("User state leaked to user2, got: %v", snap2.State) + } + }) + + t.Run("session_state_is_not_shared", func(t *testing.T) { + s := setup(t) + s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", State: map[string]any{"sk1": "v1"}}) + if err := s.AppendEvent(ctx, s1.Session, &session.Event{ + ID: "event1", + Author: "user", + InvocationID: "inv1", + Actions: session.EventActions{StateDelta: map[string]any{"sk2": "v2"}}, + }); err != nil { + t.Fatalf("AppendEvent failed: %v", err) + } + + s1b, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1"}) + snapS1b := Snapshot(s1b.Session) + if _, exists := snapS1b.State["sk1"]; exists { + t.Errorf("Session state leaked between sessions, got: %v", snapS1b.State) + } + }) + + t.Run("temp_state_is_not_persisted", func(t *testing.T) { + s := setup(t) + s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1"}) + _ = s.AppendEvent(ctx, s1.Session, &session.Event{ + ID: "event1", + Author: "user", + InvocationID: "inv1", + Actions: session.EventActions{StateDelta: map[string]any{"temp:k1": "v1", "sk": "v2"}}, + }) + + got, _ := s.Get(ctx, &session.GetRequest{AppName: appName, UserID: "u1", SessionID: s1.Session.ID()}) + snap := Snapshot(got.Session) + if _, exists := snap.State["temp:k1"]; exists { + t.Errorf("Temp state leaked to persist step, got: %v", snap.State) + } + if snap.State["sk"] != "v2" { + t.Errorf("Standard state update missing: got %v", snap.State) + } + }) + }) +} + +type mockSession struct { + appName string + userID string + id string +} + +func (m *mockSession) ID() string { return m.id } +func (m *mockSession) AppName() string { return m.appName } +func (m *mockSession) UserID() string { return m.userID } +func (m *mockSession) State() session.State { return nil } +func (m *mockSession) Events() session.Events { return nil } +func (m *mockSession) LastUpdateTime() time.Time { return time.Now() } diff --git a/session/vertexai/service_test.go b/session/vertexai/service_test.go index 346e1c77a..0f5286818 100644 --- a/session/vertexai/service_test.go +++ b/session/vertexai/service_test.go @@ -16,24 +16,19 @@ package vertexai import ( "context" - "maps" "os" "path/filepath" - "strconv" "strings" "testing" "time" "cloud.google.com/go/rpcreplay" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "google.golang.org/api/option" - "google.golang.org/genai" "google.golang.org/grpc" - "google.golang.org/adk/model" "google.golang.org/adk/session" + "google.golang.org/adk/session/session_test" ) const ( @@ -44,600 +39,44 @@ const ( UserID = "test-user" ) -func Test_vertexaiService_Create(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, name string) (session.Service, map[string]string) - req *session.CreateRequest - want session.Session - wantErr bool - errMessage string - }{ - { - name: "full key", - setup: emptyService, - req: &session.CreateRequest{ - AppName: EngineId, - UserID: "testUserID", - SessionID: "testSessionID", - State: map[string]any{ - "k": 5, - }, - }, - wantErr: true, - errMessage: "user-provided Session id is not supported for VertexAISessionService: \"testSessionID\"", - }, - { - name: "generated session id", - setup: emptyService, - req: &session.CreateRequest{ - AppName: EngineId, - UserID: "testUserID", - State: map[string]any{ - // TODO had to parse to float64, sending int was modified by vertex or by vertex client, int should work - "k": float64(5), - }, - }, - }, - { - name: "when already exists, it fails", - setup: serviceDbWithData, - req: &session.CreateRequest{ - AppName: EngineId, - UserID: "user1", - SessionID: "session1", - State: map[string]any{ - "k": 10, - }, - }, - wantErr: true, - errMessage: "user-provided Session id is not supported for VertexAISessionService: \"session1\"", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s, _ := tt.setup(t, tt.name) - - got, err := s.Create(t.Context(), tt.req) - if err != nil { - if tt.wantErr && err.Error() == tt.errMessage { - return - } - t.Fatalf("vertexAiService.Create() error = %v, wantErr %v", err, tt.errMessage) - return - } - - if got.Session.AppName() != tt.req.AppName { - t.Errorf("AppName got: %v, want: %v", got.Session.AppName(), tt.wantErr) - } - - if got.Session.UserID() != tt.req.UserID { - t.Errorf("UserID got: %v, want: %v", got.Session.UserID(), tt.wantErr) - } - - if tt.req.SessionID != "" { - if got.Session.ID() != tt.req.SessionID { - t.Errorf("SessionID got: %v, want: %v", got.Session.ID(), tt.wantErr) - } - } else { - if got.Session.ID() == "" { - t.Errorf("SessionID was not generated on empty user input.") - } - } - - gotState := maps.Collect(got.Session.State().All()) - wantState := tt.req.State - - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("Create State mismatch: (-want +got):\n%s", diff) - } - }) - } -} - -func Test_vertexaiService_Get(t *testing.T) { - // This setup function is required for a test case. - // It creates the specific scenario from 'test_get_session_respects_user_id'. - setupGetRespectsUserID := func(t *testing.T, name string) (session.Service, map[string]string) { - t.Helper() - s, l := serviceDbWithData(t, name) // Starts with the standard data - - // u1 creates s1 and adds an event. - // 'serviceDbWithData' already created - // (app1, user1, session1) - // (app1, user2, session1) - // We just need to add an event to it. - session1, err := s.Get(t.Context(), &session.GetRequest{ - AppName: EngineId, - UserID: "user1", - SessionID: l[EngineId+"user1session1"], - }) - if err != nil { - t.Fatalf("setupGetRespectsUserID failed to get session1: %v", err) - } - - // Update 'updatedAt' to pass stale validation on append - session1.Session.(*localSession).updatedAt = time.Now() - - err = s.AppendEvent(t.Context(), session1.Session.(*localSession), &session.Event{ - ID: "event_for_user1", - InvocationID: "test", - Author: "user", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }) - if err != nil { - t.Fatalf("setupGetRespectsUserID failed to append event: %v", err) - } - return s, l - } - - setupGetWithConfig := func(t *testing.T, name string) (session.Service, map[string]string) { - t.Helper() - s, l := emptyService(t, name) - ctx := t.Context() - numTestEvents := 5 - created, err := s.Create(ctx, &session.CreateRequest{ - AppName: EngineId2, - UserID: "user", - }) - if err != nil { - t.Fatalf("setupGetWithConfig failed to create session: %v", err) - } - - l[created.Session.AppName()+created.Session.UserID()+"s1"] = created.Session.ID() - - for i := 1; i <= numTestEvents; i++ { - created.Session.(*localSession).updatedAt = time.Now() - event := &session.Event{ - ID: strconv.Itoa(i), - InvocationID: "test", - Author: "user", - Timestamp: time.Time{}.Add(time.Duration(i) * time.Second), - LLMResponse: model.LLMResponse{}, - } - if err := s.AppendEvent(ctx, created.Session.(*localSession), event); err != nil { - t.Fatalf("setupGetWithConfig failed to append event %d: %v", i, err) - } - } - return s, l - } - - tests := []struct { - name string - req *session.GetRequest - setup func(t *testing.T, name string) (session.Service, map[string]string) - wantResponse *session.GetResponse - wantEvents []*session.Event - wantErr bool - }{ - { - name: "ok", - setup: serviceDbWithData, - req: &session.GetRequest{ - AppName: EngineId, - UserID: "user1", - SessionID: "session1", - }, - wantResponse: &session.GetResponse{ - Session: &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session1", - state: map[string]any{ - "k1": "v1", - }, - events: []*session.Event{}, - }, - }, - }, - { - name: "error when not found", - setup: serviceDbWithData, - req: &session.GetRequest{ - AppName: EngineId, - UserID: "user1", - SessionID: "session4", - }, - wantErr: true, - }, - { - name: "get session respects user id", - setup: setupGetRespectsUserID, - req: &session.GetRequest{ - AppName: EngineId, - UserID: "user2", - SessionID: "session1", - }, - wantResponse: &session.GetResponse{ - Session: &localSession{ - appName: EngineId, - userID: "user2", - sessionID: "session1", - // This is user2's session, which should have its own state - state: map[string]any{ - "k1": "v2", - }, - // Critically, it should NOT have the event from user1's session - events: []*session.Event{}, - }, - }, - wantErr: false, - }, - { - name: "with config_no config returns all events", - setup: setupGetWithConfig, - req: &session.GetRequest{ - AppName: EngineId2, UserID: "user", SessionID: "s1", - }, - wantEvents: []*session.Event{ - { - ID: "1", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(1 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - { - ID: "2", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(2 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - { - ID: "3", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(3 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - { - ID: "4", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(4 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - { - ID: "5", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(5 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - }, - }, - { - name: "with config_num recent events", - setup: setupGetWithConfig, - req: &session.GetRequest{ - AppName: EngineId2, UserID: "user", SessionID: "s1", - NumRecentEvents: 3, - }, - wantEvents: []*session.Event{ - { - ID: "3", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(3 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - { - ID: "4", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(4 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - { - ID: "5", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(5 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - }, - }, - { - name: "with config_after timestamp", - setup: setupGetWithConfig, - req: &session.GetRequest{ - AppName: EngineId2, UserID: "user", SessionID: "s1", - After: time.Time{}.Add(4 * time.Second), - }, - wantErr: false, - wantEvents: []*session.Event{ - { - ID: "4", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(4 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - { - ID: "5", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(5 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - }, - }, - { - name: "with config_combined filters", - setup: setupGetWithConfig, - req: &session.GetRequest{ - AppName: EngineId2, UserID: "user", SessionID: "s1", - NumRecentEvents: 3, - After: time.Time{}.Add(4 * time.Second), - }, - wantErr: false, - wantEvents: []*session.Event{ - { - ID: "4", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(4 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - { - ID: "5", Author: "user", InvocationID: "test", Timestamp: time.Time{}.Add(5 * time.Second), - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s, l := tt.setup(t, tt.name) - tt.req.SessionID = l[tt.req.AppName+tt.req.UserID+tt.req.SessionID] - got, err := s.Get(t.Context(), tt.req) - if (err != nil) != tt.wantErr { - t.Fatalf("vertexAiService.Get() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if err != nil { - return - } - - if tt.wantResponse != nil { - if diff := cmp.Diff(tt.wantResponse, got, - cmp.AllowUnexported(localSession{}), - cmpopts.IgnoreFields(localSession{}, "mu", "updatedAt", "sessionID")); diff != "" { - t.Errorf("Get session mismatch: (-want +got):\n%s", diff) - } - } - - if tt.wantEvents != nil { - opts := []cmp.Option{ - cmpopts.SortSlices(func(a, b *session.Event) bool { return a.Timestamp.Before(b.Timestamp) }), - cmpopts.IgnoreFields(session.Event{}, "ID"), - } - if diff := cmp.Diff(events(tt.wantEvents), got.Session.Events(), opts...); diff != "" { - t.Errorf("Get session events mismatch: (-want +got):\n%s", diff) - } - } - }) - } -} - -func Test_vertexaiService_List(t *testing.T) { - tests := []struct { - name string - req *session.ListRequest - setup func(t *testing.T, name string) (session.Service, map[string]string) - wantResponse *session.ListResponse - wantErr bool - }{ - { - name: "list for user1", - setup: serviceDbWithData, - req: &session.ListRequest{ - AppName: EngineId, - UserID: "user1", - }, - wantResponse: &session.ListResponse{ - Sessions: []session.Session{ - &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session1", - state: map[string]any{ - "k1": "v1", - }, - }, - &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session2", - state: map[string]any{ - "k1": "v2", - }, - }, - }, - }, - }, - { - name: "empty list for non-existent user", - setup: serviceDbWithData, - req: &session.ListRequest{ - AppName: EngineId, - UserID: "custom_user", - }, - wantResponse: &session.ListResponse{ - Sessions: []session.Session{}, - }, - }, - { - name: "list for user2", - setup: serviceDbWithData, - req: &session.ListRequest{ - AppName: EngineId, - UserID: "user2", - }, - wantResponse: &session.ListResponse{ - Sessions: []session.Session{ - &localSession{ - appName: EngineId, - userID: "user2", - sessionID: "session1", - state: map[string]any{ - "k1": "v2", - }, - }, - }, - }, - }, - { - name: "list all users for app", - setup: serviceDbWithData, - req: &session.ListRequest{AppName: EngineId, UserID: ""}, - wantResponse: &session.ListResponse{ - Sessions: []session.Session{ - &localSession{appName: EngineId, userID: "user1", sessionID: "session1", state: map[string]any{"k1": "v1"}}, - &localSession{appName: EngineId, userID: "user1", sessionID: "session2", state: map[string]any{"k1": "v2"}}, - &localSession{appName: EngineId, userID: "user2", sessionID: "session1", state: map[string]any{"k1": "v2"}}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s, l := tt.setup(t, tt.name) - got, err := s.List(t.Context(), tt.req) - if (err != nil) != tt.wantErr { - t.Errorf("vertexAiService.List() error = %v, wantErr %v", err, tt.wantErr) - return - } - - for _, s1 := range tt.wantResponse.Sessions { - ls := s1.(*localSession) - ls.sessionID = l[ls.appName+ls.userID+ls.sessionID] - } - - if err == nil { - // Sort slices for stable comparison - opts := []cmp.Option{ - cmp.AllowUnexported(localSession{}), - cmpopts.IgnoreFields(localSession{}, "mu", "updatedAt"), - cmpopts.SortSlices(func(a, b session.Session) bool { - return a.ID() < b.ID() - }), - } - if diff := cmp.Diff(tt.wantResponse, got, opts...); diff != "" { - t.Errorf("vertexAiService.List() = %v (-want +got):\n%s", got, diff) - } - } - }) - } +func Test_vertexaiService(t *testing.T) { + opts := session_test.SuiteOptions{ + SupportsUserProvidedSessionID: false, + ProvidesServerAssignedEventID: true, + AppName: EngineId, + } // VertexAI forbids custom IDs + session_test.RunServiceTests(t, opts, func(t *testing.T) session.Service { + name := strings.ReplaceAll(t.Name(), "/", "_") + s, _ := emptyService(t, name, false) + return s + }) } -func Test_vertexaiService_AppendEvent(t *testing.T) { +func Test_vertexaiService_AppendEvent_StructuralValidation(t *testing.T) { tests := []struct { - name string - setup func(t *testing.T, name string) (session.Service, map[string]string) - session *localSession - event *session.Event - wantStoredSession *localSession // State of the session after Get - wantEventCount int // Expected event count in storage - wantErr bool + name string + session *localSession + event *session.Event + wantErr bool + offline bool }{ { - name: "append event to the session and overwrite in storage", - setup: serviceDbWithData, - session: &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session1", - }, - event: &session.Event{ - ID: "new_event1", - Author: "test", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - wantStoredSession: &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session1", - events: []*session.Event{ - { - ID: "new_event1", - Author: "test", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - Partial: false, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - }, - state: map[string]any{ - "k1": "v1", - }, - }, - wantEventCount: 1, - }, - { - name: "missing session id", - setup: emptyService, + name: "missing_session_id", session: &localSession{appName: EngineId, userID: UserID}, event: &session.Event{}, wantErr: true, + offline: true, }, { - name: "nil event", - setup: emptyService, - session: &localSession{ - appName: EngineId2, - userID: "user2", - sessionID: "session2", - }, + name: "nil_event", + session: &localSession{appName: EngineId2, userID: "user2", sessionID: "session2"}, event: nil, wantErr: true, + offline: true, }, { - name: "missing author", - setup: emptyService, - session: &localSession{ - appName: EngineId2, - userID: "user2", - sessionID: "session2", - }, + name: "missing_author", + session: &localSession{appName: EngineId2, userID: "user2", sessionID: "session2"}, event: &session.Event{ Timestamp: time.Now(), InvocationID: uuid.NewString(), @@ -645,477 +84,47 @@ func Test_vertexaiService_AppendEvent(t *testing.T) { wantErr: true, }, { - name: "missing invocation id", - setup: emptyService, - session: &localSession{ - appName: EngineId2, - userID: "user2", - sessionID: "session2", - }, + name: "missing_invocation_id", + session: &localSession{appName: EngineId2, userID: "user2", sessionID: "session2"}, event: &session.Event{ Timestamp: time.Now(), Author: UserID, }, wantErr: true, }, - { - name: "append event to the session with events and overwrite in storage", - setup: serviceDbWithData, - session: &localSession{ - appName: EngineId2, - userID: "user2", - sessionID: "session2", - }, - event: &session.Event{ - ID: "new_event1", - Author: "test", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - wantStoredSession: &localSession{ - appName: EngineId2, - userID: "user2", - sessionID: "session2", - events: []*session.Event{ - { - ID: "existing_event1", - Author: "test", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - Partial: false, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - { - ID: "new_event1", - Author: "test", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Content: &genai.Content{}, - Partial: false, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - }, - state: map[string]any{ - "k2": "v2", - }, - }, - wantEventCount: 2, - }, - { - name: "append event when session not found should fail", - setup: serviceDbWithData, - session: &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "custom_session", - }, - event: &session.Event{ - ID: "new_event2", - Author: "test", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - wantErr: true, - }, - { - name: "append event with bytes content", - setup: serviceDbWithData, - session: &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session1", - }, - event: &session.Event{ - ID: "event_with_bytes", - Author: "user", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromBytes([]byte("test_image_data"), "image/png", "user"), - GroundingMetadata: &genai.GroundingMetadata{ - SearchEntryPoint: &genai.SearchEntryPoint{ - SDKBlob: []byte("test_sdk_blob"), - }, - }, - }, - }, - wantStoredSession: &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session1", - events: []*session.Event{ - { - ID: "event_with_bytes", - Author: "user", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromBytes([]byte("test_image_data"), "image/png", "user"), - GroundingMetadata: &genai.GroundingMetadata{ - SearchEntryPoint: &genai.SearchEntryPoint{ - SDKBlob: []byte("test_sdk_blob"), - }, - }, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{}, - }, - }, - }, - state: map[string]any{ - "k1": "v1", - }, - }, - wantEventCount: 1, - }, - { - name: "append event with all fields", - setup: serviceDbWithData, - session: &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session1", - }, - event: &session.Event{ - ID: "event_complete", - Author: "user", - InvocationID: "test", - LongRunningToolIDs: []string{"tool123"}, - Actions: session.EventActions{StateDelta: map[string]any{"k2": "v2"}}, - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromText("test_text", "user"), - TurnComplete: true, - Partial: false, - ErrorCode: "error_code", - ErrorMessage: "error_message", - Interrupted: true, - GroundingMetadata: &genai.GroundingMetadata{ - WebSearchQueries: []string{"query1"}, - }, - UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: 1, - CandidatesTokenCount: 1, - TotalTokenCount: 2, - }, - CitationMetadata: &genai.CitationMetadata{ - Citations: []*genai.Citation{{Title: "test", URI: "google.com"}}, - }, - CustomMetadata: map[string]any{ - "custom_key": "custom_value", - }, - }, - }, - wantStoredSession: &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session1", - events: []*session.Event{ - { - ID: "event_complete", - Author: "user", - InvocationID: "test", - LongRunningToolIDs: []string{"tool123"}, - Actions: session.EventActions{StateDelta: map[string]any{"k2": "v2"}}, - LLMResponse: model.LLMResponse{ - Content: genai.NewContentFromText("test_text", "user"), - TurnComplete: true, - Partial: false, - ErrorCode: "error_code", - ErrorMessage: "error_message", - Interrupted: true, - GroundingMetadata: &genai.GroundingMetadata{ - WebSearchQueries: []string{"query1"}, - }, - UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: 1, - CandidatesTokenCount: 1, - TotalTokenCount: 2, - }, - CitationMetadata: &genai.CitationMetadata{ - Citations: []*genai.Citation{{Title: "test", URI: "google.com"}}, - }, - CustomMetadata: map[string]any{ - "custom_key": "custom_value", - }, - }, - }, - }, - state: map[string]any{ - "k1": "v1", - "k2": "v2", - }, - }, - wantEventCount: 1, - }, - { - name: "partial events are not persisted", - setup: serviceDbWithData, - session: &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session1", - }, - event: &session.Event{ - ID: "partial_event", - Author: "user", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Partial: true, // This is the key field - }, - }, - wantStoredSession: &localSession{ - appName: EngineId, - userID: "user1", - sessionID: "session1", - events: []*session.Event{}, // No event should be stored - state: map[string]any{ - "k1": "v1", - }, - }, - wantEventCount: 0, // Expect 0 events - }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + s, _ := emptyService(t, tt.name, tt.offline) ctx := t.Context() - - s, l := tt.setup(t, tt.name) - - tt.session.sessionID = l[tt.session.appName+tt.session.userID+tt.session.sessionID] - if tt.wantStoredSession != nil { - tt.wantStoredSession.sessionID = tt.session.sessionID - } - tt.session.updatedAt = time.Now() // set updatedAt value to pass stale validation err := s.AppendEvent(ctx, tt.session, tt.event) if (err != nil) != tt.wantErr { - t.Errorf("vertexAiService.AppendEvent() error = %v, wantErr %v", err, tt.wantErr) - } - - if err != nil { - return - } - - resp, err := s.Get(ctx, &session.GetRequest{ - AppName: tt.session.AppName(), - UserID: tt.session.UserID(), - SessionID: tt.session.ID(), - }) - if err != nil { - t.Fatalf("vertexAiService.Get() error = %v, wantErr %v", err, tt.wantErr) - return - } - - // Check event count first - if resp.Session.Events().Len() != tt.wantEventCount { - t.Errorf("AppendEvent returned %d events, want %d", resp.Session.Events().Len(), tt.wantEventCount) - } - - // Define comparison options - opts := []cmp.Option{ - cmp.AllowUnexported(localSession{}), - cmpopts.IgnoreFields(localSession{}, "mu", "updatedAt"), - cmpopts.IgnoreFields(session.Event{}, "Timestamp", "ID"), - cmpopts.IgnoreFields(model.LLMResponse{}, "CitationMetadata", "UsageMetadata"), - // Add sorters if event order is not guaranteed - cmpopts.SortSlices(func(a, b *session.Event) bool { - return a.ID < b.ID - }), - } - - if diff := cmp.Diff(tt.wantStoredSession, resp.Session, opts...); diff != "" { - t.Errorf("AppendEvent session mismatch: (-want +got):\n%s", diff) + t.Errorf("AppendEvent() error = %v, wantErr %v", err, tt.wantErr) } }) } } -func Test_vertexaiService_StateManagement(t *testing.T) { - ctx := t.Context() - appName := EngineId - - t.Run("app_state_is_shared", func(t *testing.T) { - s, _ := emptyService(t, "app_state_is_shared") - s1, err := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", State: map[string]any{"app:k1": "v1"}}) - if err != nil { - t.Fatalf("Failed to create session for user 1: %v", err) - } - s1.Session.(*localSession).updatedAt = time.Now() - err = s.AppendEvent(ctx, s1.Session.(*localSession), &session.Event{ - ID: "event1", - Author: "test", - InvocationID: "test", - Actions: session.EventActions{StateDelta: map[string]any{"app:k2": "v2"}}, - LLMResponse: model.LLMResponse{}, - }) - if err != nil { - t.Fatalf("Failed to appendEvent: %v", err) - } - - s2, err := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u2"}) - if err != nil { - t.Fatalf("Failed to create session for user 2: %v", err) - } - - wantState := map[string]any{"app:k1": "v1", "app:k2": "v2"} - gotState := maps.Collect(s2.Session.State().All()) - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("User 2 state mismatch (-want +got):\n%s", diff) - } - - t.Cleanup(func() { - err := s.AppendEvent(ctx, s2.Session, &session.Event{ - ID: "clean_up_event", - Author: "test", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{ - "app:k1": nil, - "app:k2": nil, - }, - }, - }) - if err != nil { - t.Fatalf("Failed to appendEvent on cleanup: %v", err) - } - }) - }) - - t.Run("user_state_is_user_specific", func(t *testing.T) { - s, _ := emptyService(t, "user_state_is_user_specific") - s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", State: map[string]any{"user:k1": "v1"}}) - s1.Session.(*localSession).updatedAt = time.Now() - err := s.AppendEvent(ctx, s1.Session.(*localSession), &session.Event{ - ID: "event1", - Author: "test", - InvocationID: "test", - Actions: session.EventActions{StateDelta: map[string]any{"user:k2": "v2"}}, - LLMResponse: model.LLMResponse{}, - }) - if err != nil { - t.Fatalf("Failed to appendEvent: %v", err) - } - - s1b, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1"}) - wantStateU1 := map[string]any{"user:k1": "v1", "user:k2": "v2"} - gotStateU1 := maps.Collect(s1b.Session.State().All()) - if diff := cmp.Diff(wantStateU1, gotStateU1); diff != "" { - t.Errorf("User 1 second session state mismatch (-want +got):\n%s", diff) - } - - s2, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u2"}) - gotStateU2 := maps.Collect(s2.Session.State().All()) - if len(gotStateU2) != 0 { - t.Errorf("User 2 should have empty state, but got: %v", gotStateU2) - } - - t.Cleanup(func() { - err := s.AppendEvent(ctx, s1b.Session, &session.Event{ - ID: "clean_up_event", - Author: "test", - InvocationID: "test", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - Actions: session.EventActions{ - StateDelta: map[string]any{ - "user:k1": nil, - "user:k2": nil, - }, - }, - }) - if err != nil { - t.Fatalf("Failed to appendEvent on cleanup: %v", err) - } - }) - }) - - t.Run("session_state_is_not_shared", func(t *testing.T) { - s, _ := emptyService(t, "session_state_is_not_shared") - s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1", State: map[string]any{"sk1": "v1"}}) - s1.Session.(*localSession).updatedAt = time.Now() - err := s.AppendEvent(ctx, s1.Session.(*localSession), &session.Event{ - ID: "event1", - Author: "test", - InvocationID: "test", - Actions: session.EventActions{StateDelta: map[string]any{"sk2": "v2"}}, - LLMResponse: model.LLMResponse{}, - }) - if err != nil { - t.Fatalf("Failed to appendEvent: %v", err) - } - - s1_got, _ := s.Get(ctx, &session.GetRequest{AppName: appName, UserID: "u1", SessionID: s1.Session.ID()}) - wantState := map[string]any{"sk1": "v1", "sk2": "v2"} - gotState := maps.Collect(s1_got.Session.State().All()) - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("Refetched s1 state mismatch (-want +got):\n%s", diff) - } +func emptyService(t *testing.T, name string, offline bool) (session.Service, map[string]string) { + t.Helper() + replayFile := sanitizeFilename(name) - s1b, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1"}) - gotStateS1b := maps.Collect(s1b.Session.State().All()) - if len(gotStateS1b) != 0 { - t.Errorf("Session s1b should have empty state, but got: %v", gotStateS1b) - } - }) + var opts []option.ClientOption + teardown := func() {} + var err error - t.Run("temp_state_is_not_persisted", func(t *testing.T) { - s, _ := emptyService(t, "temp_state_is_not_persisted") - s1, _ := s.Create(ctx, &session.CreateRequest{AppName: appName, UserID: "u1"}) - s1.Session.(*localSession).updatedAt = time.Now() - event := &session.Event{ - ID: "event1", - Author: "test", - InvocationID: "test", - Actions: session.EventActions{StateDelta: map[string]any{"temp:k1": "v1", "sk": "v2"}}, - LLMResponse: model.LLMResponse{}, - } - err := s.AppendEvent(ctx, s1.Session.(*localSession), event) + if offline { + opts = []option.ClientOption{option.WithoutAuthentication()} + } else { + var rawOpts []option.ClientOption + var rawTeardown func() + rawOpts, rawTeardown, err = setupReplay(t, replayFile) if err != nil { - t.Fatalf("Failed to appendEvent: %v", err) - } - invocationSession := s1.Session.(*localSession) - wantInvocationState := map[string]any{"sk": "v2", "temp:k1": "v1"} - gotInvocationState := maps.Collect(invocationSession.State().All()) - if diff := cmp.Diff(wantInvocationState, gotInvocationState); diff != "" { - t.Errorf("Invocation session state mismatch (-want +got):\n%s", diff) + t.Fatalf("Failed to setup replay: %v", err) } - - s1_got, _ := s.Get(ctx, &session.GetRequest{AppName: appName, UserID: s1.Session.UserID(), SessionID: s1.Session.ID()}) - wantState := map[string]any{"sk": "v2"} - gotState := maps.Collect(s1_got.Session.State().All()) - if diff := cmp.Diff(wantState, gotState); diff != "" { - t.Errorf("Persisted state mismatch (-want +got):\n%s", diff) - } - - storedEvents := s1_got.Session.Events() - if storedEvents.Len() != 1 { - t.Fatalf("Expected 1 stored event, got %d", storedEvents.Len()) - } - storedDelta := storedEvents.At(0).Actions.StateDelta - if storedDelta["sk"] != "v2" { - t.Errorf("Expected 'sk' key in stored event, but was missing or wrong value") - } - }) -} - -func emptyService(t *testing.T, name string) (session.Service, map[string]string) { - t.Helper() - replayFile := sanitizeFilename(name) - opts, teardown, err := setupReplay(t, replayFile) - if err != nil { - t.Fatalf("Failed to setup replay: %v", err) + opts = rawOpts + teardown = rawTeardown } v, err := NewSessionService(t.Context(), VertexAIServiceConfig{ @@ -1128,7 +137,9 @@ func emptyService(t *testing.T, name string) (session.Service, map[string]string t.Cleanup(func() { t.Log("CLEANUP") - deleteAll(t, v) + if !offline { + deleteAll(t, v) + } defer teardown() }) @@ -1161,87 +172,11 @@ func deleteAllFromApp(t *testing.T, v session.Service, app string) { } } -func serviceDbWithData(t *testing.T, name string) (session.Service, map[string]string) { - t.Helper() - - service, _ := emptyService(t, name) - ids := make(map[string]string, 4) - - for _, storedSession := range []*localSession{ - { - appName: EngineId, - userID: "user1", - sessionID: "session1", - state: map[string]any{ - "k1": "v1", - }, - }, - { - appName: EngineId, - userID: "user2", - sessionID: "session1", - state: map[string]any{ - "k1": "v2", - }, - }, - { - appName: EngineId, - userID: "user1", - sessionID: "session2", - state: map[string]any{ - "k1": "v2", - }, - }, - { - appName: EngineId2, - userID: "user2", - sessionID: "session2", - state: map[string]any{ - "k2": "v2", - }, - events: []*session.Event{ - { - Author: "test", - InvocationID: "test", - ID: "existing_event1", - LLMResponse: model.LLMResponse{ - Partial: false, - }, - }, - }, - }, - } { - resp, err := service.Create(t.Context(), &session.CreateRequest{ - AppName: storedSession.appName, - UserID: storedSession.userID, - State: storedSession.state, - }) - if err != nil { - t.Fatalf("Failed to create sample sessions on db initialization: %v", err) - } - - ids[resp.Session.AppName()+resp.Session.UserID()+storedSession.sessionID] = resp.Session.ID() - - for _, ev := range storedSession.events { - err = service.AppendEvent(t.Context(), resp.Session, ev) - if err != nil { - t.Fatalf("Failed to append event to session on db initialization: %v", err) - } - } - } - - return service, ids -} - -// setupReplay determines if we are recording real traffic or replaying from a file. -// returns: client options, a teardown function, and an error. func setupReplay(t *testing.T, filename string) ([]option.ClientOption, func(), error) { filePath := filepath.Join("testdata", filename) - var grpcOpts []grpc.DialOption var teardown func() error - // 1. Determine mode (Record vs Replay) if os.Getenv("UPDATE_REPLAYS") == "true" { t.Logf("Recording payload to %s", filePath) _ = os.MkdirAll("testdata", 0o755) @@ -1253,7 +188,6 @@ func setupReplay(t *testing.T, filename string) ([]option.ClientOption, func(), grpcOpts = rec.DialOptions() teardown = rec.Close } else { - t.Logf("Replaying from %s", filePath) rep, err := rpcreplay.NewReplayer(filePath) if err != nil { return nil, nil, err @@ -1262,7 +196,6 @@ func setupReplay(t *testing.T, filename string) ([]option.ClientOption, func(), teardown = rep.Close } - // 2. CONVERSION STEP: Convert []grpc.DialOption -> []option.ClientOption var clientOpts []option.ClientOption for _, opt := range grpcOpts { clientOpts = append(clientOpts, option.WithGRPCDialOption(opt)) @@ -1271,7 +204,6 @@ func setupReplay(t *testing.T, filename string) ([]option.ClientOption, func(), } } - // 3. Return the SAFE client options return clientOpts, func() { if err := teardown(); err != nil { t.Errorf("Failed to close replayer/recorder: %v", err) @@ -1280,7 +212,6 @@ func setupReplay(t *testing.T, filename string) ([]option.ClientOption, func(), } func sanitizeFilename(name string) string { - // Replace spaces and special chars with underscores safe := strings.ReplaceAll(name, " ", "_") safe = strings.ReplaceAll(safe, ",", "_") safe = strings.ReplaceAll(safe, "/", "-") diff --git a/session/vertexai/testdata/Delete.replay b/session/vertexai/testdata/Delete.replay new file mode 100644 index 0000000000000000000000000000000000000000..4409fff1d61ca675dc3b3da11cd2d19d344eca22 GIT binary patch literal 3645 zcmds4+l$;(95%B%yThrMCHPQ6k+7g0wVO;bx!X}ncU-sDoi(d3ma=3sXF|G}oOsU3 z+EqkE1QB0+lOhxpM11ni;vb>-&_@w`lKufAikCB!%j_u5+E_baAP*;j?|1vocfR9p zpLbyrd58C~=N5Y#9N&=xgn|f4euUD#VlPr;mIpy`7zf=r2xCZOT{jKguv?m@>56T&+eV9D-m9ksV&y50Ra+Bo z)Ie)Sx)F*4oW>(ish2=Jj>^AP1MhKVVy2>~c3V>w#j^iJW-`gzN@PRVb@nQn-8Rg& zYMF*5qarwwP4-9CHN#>7OJPP%6*S|{3#&tCmKIqd;6=U zl}|tZ;Z@=2qRIV~5ym-owtphb2BZaBzTiN&O5ziUf+{hH%8A?=s>~e7$&5bl*T551 z^3Ut%zu~~JO#5ySX=;S4_vXX>{N^v8ajpU$0L!Dz;q0sYqEI_f!Tn&#OA=>a8LyRc zo&V4G(iO&wH{bnsY30VxzvAY?Zxw}P9Y$~>Bwtn(k|~rqvEz}99oMUf%PkIExE)Fy zQ3g#l6~j<%&DN}zX{#+mncDg@2C_*Y4I2%O>z)_m~J_tzGLsntpDvB$xqGXk>8 zDB4R0Qg@ip!)CpzhFlB@?H(}YCqh*XnLx~mB~@#mh+pN}ClGV0y|FMpet)lnhi9+_ zC%&{pJ(?2!X1!78-^g`SEH}tqka{sjR2-l*?n}bgi=cT-zUy!=4L0Ke;y+sCuP`t=2g4^cIG^fGotUY1FI9KQ6yfW$`uE zSLdAms`$FdHJ7`i^Rgy%{^?eDkh$~~AH|f_#TpR%?A+`#pvHIE!F%?0 z4cSNu^HvbyLk?`rpm;%_l0C((;3!BTD$n0>|Gu5|>O!?k#&nJ(VK)faJn?^Pn`N|A U+cIriV>cgLHw;t#588I$A2brz;{X5v literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/List.replay b/session/vertexai/testdata/List.replay new file mode 100644 index 0000000000000000000000000000000000000000..752208c3239415e6f3ca286f575fe25ad2b28ffe GIT binary patch literal 8760 zcmeHMOKclO7-rYagDJFS0Z~jr$P&mr;@z2@*_{nkfJ#(aKyBR{w~aHkoY=d@yN<}M z5fmigQaFGB@wlK0At530XrKookRl<`R$OucE^wfN@DQaH5*1^wcjLv0)2!EN6vvWf zjV;gqGygZ=eE;`n5AMmba?zZ+ioeeB_n;u{mh-lqFS1OoXjjKFW`Y0i9%p@FT zt_d@Tnd1~}>oBWK7IIA9Q(>md8eb;g6~H6zRGDSGSi+Pij#(B$+9P_%yFeB zz=3kb9%nh%k=SLRF|>Rihe~ZU5nptZG=(DJ2SjpbAx@V=Prbw#_P* z&17iPgY`Ymvpi8t=sLD*{nq)-Y|*EEv*_WbwLLKcGU zV4!w5h9{7;B`HoKunqK^o{N^Vz(m4d-O-a%N(90cC$+z95k|p8}GHlN_4;_eAYUb%>Vk&k79bDr^p07=wabx z6f${61TS<^l~4t9vcQNSNRUEQU27I41QQ*pgb=7wP0{&>)>SU~(-S50XTB0Rmo$yi zS6}E{mV6wWEGdDB;C_!=mb7MM(1Xu(hg9-~&?Kf}S0$}shcn2T#LMT=-)H_7vbP7K zqpb)*u(+m?q97seXQX4^3+MbFMz#K^j-5LzCtx)FJ@BPYHDrY z*zQ#A6Q(YC>A#SOR)U3tcFMfIV$ypFu-`0@xo|3zt!(H+7Hl$2Z#MAhLa-UxSiQHXkC;TzLLenV`t%%2?h)Qdq=%FCut%TsrX4pt zngZdoWH^p3&oxZM(>#c6rL1JsN~SDK_(()mB8o1^vK&FWCP|9G_RMu<33ky5K&xfA zRR#(T(lu=>>1M4$(D0Ol-Qri-8w|VyDv7BGiMk|<2xC7qiy&Fh6hRYI z8A+-l@OJs*gjXd!q98?7RYB4;9SMTQx-izDiY+Hu*Yrw#7wMD}hG(#cA;Vd;G%FKn4OAKqNO%ClDD6|H!qs_wNq~l;K5zSFLb6){G6Qr*-G0MPL0_{Zxdx+~S zbaALNW#!N*+MD~U?^ZVQ`8=5Im?}_);m*N{9al#js((1t6_kY+Qp77Zn{Yu{SPfBb zcAHrkwDzie1g^arVwY;q`%qdB$duaI`A}k?0r(WaTW#KKxIlQq%c`czsw_q%P1XfT zRwJU%qrncJNn*z)E`@rEO2csXb|}K1cUOe>mNx9lhc`bMs>0id?jI`sR~7F4?l0%D zW>vU=i_&%?+yf9ysA_|h>2ay$UM}SC;73d_$623bi348%{Kw$XlBL|Y;lrV6bAK9} zxN7cS1~m6|QOba5^R77Jqvc@`!@maLWHUCc#;wbmEDBW2G)J0Ff_9B(nS?|%~xdW{lwP1SXo-UI2) RNm0dVe|1i_PB%gaegl%7A0_|* literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_AppendEvent_partial_events_are_not_persisted.replay b/session/vertexai/testdata/Test_vertexaiService_AppendEvent_partial_events_are_not_persisted.replay new file mode 100644 index 0000000000000000000000000000000000000000..6ffdf6b885e73b44f328b260ba51c5915b209ed3 GIT binary patch literal 3382 zcmds3O;6N77`9zmaQq@YnZ|hVU{(xmr|t9u^l%_^YI1aqU z6YRIa{sV$^Q>{c%CBRBKh-$Nn=d<^8E=sCO9nN6tL1h{f;z!{$PU?OctD_0_C~hvw zD*_m&i!oMG6fgFPQjV&7f_9;l@Z%aLRC)v^<0P8LWlB_Uc44>@4O6zR22t6gtOTOg z$Z#2lH1PtcvN}i<`eCIQR{RhX6(QR~maCbjiJ2(%vM z22of^YGDIZnT&C=wX3uwg6Bf3FdIVMHB22s$Nh)ORFWPmQ60@O4HMa>?KrxtL8O^# zlxe0onOm-c5tH1k<%BDhQjYWJdO6cbjC!FwOMxSpY?z(%TT3SV9oAI5`-x^rS>i1@uY;*Lmu1_Ah-C zK($r&VMwW)!^;_?Usito$dw>C2l8GVk1XiYNm;Dx;5_JW%u9PA`v7iz0Gs19&v0HW zujdChKfL~!E97{UanC2TSZ5PWWL{;oA&y8HyuG(2&E0z&;)r^G&~OA%9tU@LhN6=? z5;K3tg}!4tXM4@n_Cefv&hkG~2fndi2X35n(}IZnMgU{HbY&o!-X0$Kl|7Ui?K_b# zSafz;!l|mH?*jN=jkoEm+xh>?h<6smso$I_?lul^JH@SWrnoMYOcIBDF<(jb@|Gvr zZ3tjuKeus5EMpPVFrbbgLOQf<16ht#1$TMoDm}&B^4QZfkG*{MUSM~5$I}I2=o+}% uV?)*%jb>_dO0gQ##lq?1W*&~E>8@kDuE`?5i!965r_%dq;^4faaP|*(AZ^zG literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_AppendEvent_when_session_not_found_should_fail.replay b/session/vertexai/testdata/Test_vertexaiService_AppendEvent_when_session_not_found_should_fail.replay new file mode 100644 index 0000000000000000000000000000000000000000..da1ac3c0eebd0872423b9a0301a068f5f36f22ba GIT binary patch literal 1330 zcmds$&u-H|5XNJtNRhc9AI)Vil_EseNt^$0X=x}y6)6rU5JH>v#M#!~HM{Gyxgqfa z2qeS-iPzwW#GO~*1$YL+nmAI0L~{Tx_+;;TelzpUH^K3p0MeK&O6Iv{l}Z)Mz2!$j zL@{6*i!8*1nR`7IGQso41T<;jF(}0ZAA_7T3jSW20uKjs;M)6QId}CU1-xRAlqpO_ zvJrWK5y0~dly+ZQPMAt@0#qxXgr~Jg)U>(qV?l{F`IVok8Z}uZi5tEIq6B9=8t{m5 zP=2e`X}8+Fdb8PVHG19M-FDqqtICzn1s|L9aVTF8uM&O!*$`p4k` zqNYv3rM sao(cs-FNQ&d5tPHn*-JI|0Y|vUEk?-JH1}hY<#cPZg+P6foxZP0Nz8@^#A|> literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_AppendEvent_with_all_fields.replay b/session/vertexai/testdata/Test_vertexaiService_AppendEvent_with_all_fields.replay new file mode 100644 index 0000000000000000000000000000000000000000..42dab8697bc7827013ad3c7cf22db5043a91615e GIT binary patch literal 7263 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 E2Bq}9iU0rr literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_Create_generated_session_id.replay b/session/vertexai/testdata/Test_vertexaiService_Create_generated_session_id.replay new file mode 100644 index 0000000000000000000000000000000000000000..707357cc391bc1efecdaa76cdccd052929634a78 GIT binary patch literal 6668 zcmeI1&u`mg7{_C;E!oR}&fW8<6f1DCV59D?K{HWIcr}8Ua92Sq2+dOG2F3{{a- zjsSB+5j*xb(~7jb3$*ubi&)$Yc$Pa>ynXY%AEu`7|NPZYY$>xUGY{ygQX$oM?fEt# zEkKf}=o3_dKTd%1F8R*Gwa8iYJBa&og1a$ssbwxMEH0fn&o@o8WnShT=)=&p z>vbA-wI8<5)4?D3fM5jy7CNETg>Rv7T?pd|ADlw-nhk^AK-c+Ga1$y#aft^TCze`o zuD;s3xHNx4d}V8c^l4^;M9xC$S9JF-@&y=%D8#}UcX?oBL%Z3i4I62^lYEyBo8`d9 zL+-~(-nJqP8-C<@{H1{(_e9gLWwlL5(e>~uyhbkbZ_`t5!#b}e$=CVWhn)T_{|?WT zkK{g3%6)*^OJ+=SAE0j`Bfmn1eEE^^fyvwlav$iuX5>Dw{Wfx_eBiO%2XY_Cec<1y z#G&$mqleiCW@5hdDGOd6ao zQKB|QU+nAaGB;6TH{^6(>8~rJh}#uPk3Ra~x2fqbzP|oFTYmiam>!+lD?K{z;shVz z(&r3V80UP4gCqp$jeUoZLM(!t?0Bd&n2Zh{S2K@g-k-@B~D0UFZFoxYGiYNYx zMX_f#Jr6y&#innK$)+<=5?LHDSbRR#%#Crt^q&kkw->h+xWIhnTs-185X5cyEB=enTd_CbmO|UZq`|QDkaQLz8R)FnK#aR zvvv{DOFf8q^rQ%a2>J&IMZuFG_@fuWgS8hA-n@zj^(B*;O)6$-T((dHIlLG6d_Uj! z{d~XQPu$IOE+mn+cOU!w#=aF!Xet55K?JfN;jAlrA^V;7aN3u5)oq}jDsKTHA&$2o z-3@(E&ZXc{7+s2=a_Bj_mw=q3cu7cPANLOgoj`Hn9-;#X%SYQB1RLnU+&C48v3%Ti30caAF$`ATAO} z*H>3KmNGA1=TU76=Lp9^n#BWTxt%~d3@u#a(c4_fuCA(@qZ^v4+RlHVr;z+@8F#g& zIhIy;4AasrT~|#_!9{SQ)Qx)0ux(4L*@o_Drdcxtirgt#{A)QUUcSul1y<^=M?K+D zbV@i;x%u(6?-owpxbf{Tyf|^Ha=+)gX&hMM+jO%Av=Gf_9BR!%eIKGwOpKzkCWoV# zsU@i#8w%*JdGz=c{%PXNThyZ&5q_Aj%nPrN@|KR0suW`?^>7wO9-NxUqU#d1gOAc{>rHkh5HlClZ+sFEZeCY zwxK$vp)+&1gPxzImGW2orz<>#L}9UVYd9@_{N(e8#PP$!+>=kCC#HmBc~QKb_2gDR mp?lT)@3m=RTg<%Mb;mK-e-FpBth%;2Xa-(9e|W>9y7)IP$*Tka literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_Delete.replay b/session/vertexai/testdata/Test_vertexaiService_Delete.replay new file mode 100644 index 0000000000000000000000000000000000000000..81f37ac61cd592afe0d5e6811a96ebc48a6d992c GIT binary patch literal 3649 zcmds4+l$;(95(6B?r^*};H}~t<5ti<_Zmm0OR$nY-$!5-kbTc_I=Va|7 z;sq)qB8aa-!3PEZ0kQvpKB*w$ljw7w`dHAaXC{}~5jtyQ?SKRGkdyHJZr}ONciinW zE=(ft@E-cRM*mHQ>qr4YK?FrVLRnw*Li(NzP&yP3ls!m1McjcHhbZ2G=|Sj2=}Zbe z0*gidGX^|I4ihNmC|(j`(MQ8$L8nk$_-Y0*;a&l~Bt;jYPq5_m_g4e7O6a;2A>Si3 z1uSKF)rT=jy-1O09vH=89CYI#j3Jf`!!ixik#$`+6vx&yQ|6ZT8X1PEvc}MAS^g>u zK0FbpJ+~4Md4c>%#%-nCuRhsl@YX; z4aDv+A&0F-T@^VOV$waJ>W}%lDl&nX6HDsWK9RrLwND`CRD1Kp_z3>90Un#dmNfCj z9paG;b3Znk4erfCIfZJ2+y${0Vnl=i%HqDrf42Zy$K<;X_p)F!9w6@11@6ki`JTJE zvAMN&PUyOB&%Gc7FotR9x7!qUcMRJWs7We3lSl;^i-Xh~!q-r`FQ&O0D!S0Uw0w=k3v3gyv~Zr$AE8l_Ea<8vZ)gP{Te zVkip(5)wl}NDQ!0bpv*UK(HZ@03nr$0n`B+oH)*-qN(Cmsnkdre6jxfAK&-=Ut;6K zF=D2*xfbWM;(UXEIl(0jBbg@Qc-qJ&!dlAt-JUkAOgP6+5nJQK6NJ)~p-&Jimx>c^ z*dm%uN{8$sfM@JElZ4$Unwg^ExRKd3XibzDxtt}`W+vg7X&EylZc|Q6%=RaZe%m?c z(negfofIgSrTuZD+m@E*IVTTl=qWuJ)srcmP)?GtEXgVoMN#5aB@&Sl(=pYSrNrWg zfKzRNT@FB0C*8E6C#|er2pS$UiB-InnGe8Qpqv=yc|nbc0?#Yz7Bb_g+g8fSlB)0$ zRxnZ|BuB6!h@9bFPB{@_j08D?cv(P#Bx6}&Y#6Ik#fFotX?nT73(k}inyoQM;9+KO z@XN#b*X>=aKVSR>Y|T>$th$gH*mSzLFjQUBv62b$E4%6kCY!6uKj)Zjq zbSD4@{p4S&pLqvhrkr*&pj11<9&e(b|GZob#CW(Lwri$2Fe@;vY#=AV1F)mOm&$?n z0$+TAFVShniF5nL$M&vA->?1#p*`J&?f_=6QDh96CeahooWp7M1AwRfw6-dShmD({ zx!_<9v8{!+^|hw-9DlMicx7Sf)!nsxJ`W~a#tN(nuxH`OmaC(tBZ4H<=9h@)Q`9at zo3eh1SP9`ZyU9fKTYE)8eAiwHu|u`zT_~;kWJ>kyTqrS50DKJK^(L=3QXo9#1QBCi zz_KVIgjI|pNOXDB)8aBoXj#XlP{%=6hZnY}!*6$3hdYB`i+gtI*|m#RGF(#GO=bE2 zG93KmF6iTp%5VV~r42>dd%&MkRR{6-acSEfU9UU;2LXS+vo^^R3El&^EAh~|=Hj7Y zkAI4!NyXzY`}FuVQ9ML6d0QNH;d0-P?_U9Mv=OgXW7?4_Vj+UCfK*vkBZ9(Y;5k1A z!0qBjZv9(PMczF9^`qd{tDd?+-!a%*=R&74+nCBuhoc$Oo(t{UZQ7y8NKh55s-p9U TP?cmE3*+wk9Nj+Y2p#wbQEV;4 literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_Get_ok.replay b/session/vertexai/testdata/Test_vertexaiService_Get_ok.replay new file mode 100644 index 0000000000000000000000000000000000000000..386b3a6fabb1e37bd02313f0b7d7639710bf7346 GIT binary patch literal 3390 zcmds3O>5LZ7&cLtI;~Q2Q9|+HLD!EanQW3ts^Xztx7Lr=6n{Xvoi1s!nV6X^^eiHX zs3-BD=s^(t16IMmAO*dOdiCVdi{fmv$?huM(zvwJUG^|pc=EpUJkR?+?##H0qrh9) z!#_XyZ$OaFt2IJu0amI3Y0N90&;Mr&B(5vVa1JvMDzlhUpMq}dRaY}+&pm837Hs!=i={?(A9AqygikR`>y?a{@C>vMKkqS58UeC2 zIPm4=?e~S@?`toHpL6Y*V`b&`<@lR6wrY@mf<@0Rkz6spOe{pZmYAmdCjfiL**v|sO04;1xdwrz3dntPiw&2^!qdf4WDd^{1$vpjL` zn*gS^vV=Rb7=pINLzrzCCFq#8p_>-Zv6u6hD!IkKL)cL;A`K0EO+wha2Tw)0>vTjo ybsC)P@j~7iBXf-frCg8LN^x+vO%B`AbjL;xPsE1dn3jd~nPd}9^>1(#5B>(0AaFMT literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_Get_with_config_filters.replay b/session/vertexai/testdata/Test_vertexaiService_Get_with_config_filters.replay new file mode 100644 index 0000000000000000000000000000000000000000..29fd3132a94fa8b754b309f3b5a60cfce54e9e68 GIT binary patch literal 11049 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 a6-^V-L{>wyOog8)bpuWw?w+8=?)?Xv;hu~D literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_StateManagement_session_state_is_not_shared.replay b/session/vertexai/testdata/Test_vertexaiService_StateManagement_session_state_is_not_shared.replay new file mode 100644 index 0000000000000000000000000000000000000000..e0f8c54993b93fe1699b427ff69192af1f9b6d9a GIT binary patch literal 4868 zcmd^@O=uHA6vsQ;SnJeUT`v;Eg9nKp&CY)BR6Y1nzoj)EMDenw(-2K|*UYBSTQ7o$ zD5wVyg0x2sM!_0;^Z|42q@4ZQ4 za94sj8FRFaeZH`#6Y$%Glx?RnB%aLJ`JuR(X7Al$JC}`*h^L5ait(d_(zIJ8z)FIg%@gYK!*IaK*{4a;rGhzhrZ;8x zx@=y^*h$l6E>Iy)dy~X+b7n>qm><-((pIY9N~JAA1y$8GRl`V86jj8AENh5go#=*L zu$`U}`AC#wTjKKK4#0NKMW;B3=5Re@Td7>$DlQNoa7eD?0e_Bz7eU2IT@)oOE0QQ0 zcphE~)H`A-XfncB(iN;|*uav83?$h8;#80gBucu`fc;@yubS|M zW5G-%Ce0DfoFP#?0o~!?)-e8;_s@R@Fk5kZA*2+H;d(!!g=_cEbBTdKp1>ZsG*3yc z&rh7{gjC8T39f)0rsGt9;E&7ii>!u_ZH2Vg9XVGXGffnIHMrAul0A#hQv_WUwy-egN{iuBxnv zNs2B>hAJb?Fj@-vi4x@B+@85xgnZ2?wsi48zCI)|5Ax&lg#7!~LcZSbU= zm1uk?60YA3I_dG4f8P!lfjr>1)}}yx%luQH(^XD^cVm~1Cr)~8jxY9kw>g2X=@&gu z*Y}V$cQ?t9>OE^JWX%m&@w20)?Q%VlHh3hx8RowA5x@hD^vE@rCB;A*QZxmNGGYZ+ zHITt);lVJ?ly{21yCvN!70}7Cd#{QGv}UvhY}yPr&aoidnQNcQ56AnnjyoFbUTj*a zPu;(y6%k{0lh<@f5wRlSR%@m3yzqdlXr(l2R4YCITg^2zBw<6xSYdzQU{zzm81!yH L2bx!MW2=4uZvauj literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_StateManagement_temp_state_is_not_persisted.replay b/session/vertexai/testdata/Test_vertexaiService_StateManagement_temp_state_is_not_persisted.replay new file mode 100644 index 0000000000000000000000000000000000000000..9046e20a8c1b17a8311ef79405608c5c56173f75 GIT binary patch literal 5084 zcmd^DU5Fc16yBN5ZsXl*>pV%&IxOhM+MWMQGU-OxcGGohyIHe}U@6P&3#{;sc@9$+9Xi ziB;McXDJdYom!`XFu+&Kv1^`hw=8lc7kce2nM*Wyo9cL#6-7yt6-g9x+AiGUQIJ!_ z%W_5(R4F5C8Brx4B_YQ<(dLL(WknM-Nfu>Q%BfjN5CjH;@_;C|JjT9^cj7xsj<}*@ zojC#?Wez4k|Kie*k9e)BIn)wfDhcLTiSw>{cTc$tSd0?XGFD8_NUYa&954Xnnf2O-Z=V{*t5uCY> zERo$Cj?F>Ob3DY&noFTx_3UOi6YN?DKgXV-4L$uweU^#hwdgw*{JZ zEGrd4Mbknn@FG6XzDBZKfq6C)IiF`I_u2hP_H~v@kKWHZFclP%PiSy_0Iwpnq#jL}c2krdXC4Cp(5N0}m}C&cMmNPLCX#QycjMbD_ck^*sFz1d zty)W`7QpYM}oY4K*y7y-g6Hq$oxQo?P&}StA%aCRG$pA@L4Kb-6h+I4vY;sn(*V!K=_-OOZZ$Z#-fk#w|GxhIC}}o(R*z6e zp9W72*pNhqomIce6&o&IOCS2b$x2QYBrTWKG=&VNw2VroI7>l$cY5zsCH?R}y4aYO literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_StateManagement_user_state_is_user_specific.replay b/session/vertexai/testdata/Test_vertexaiService_StateManagement_user_state_is_user_specific.replay new file mode 100644 index 0000000000000000000000000000000000000000..c37fdba642f005a3b7d758fd755ee2efa029b98f GIT binary patch literal 9811 zcmeHNU5Fc16z*h`ZM@ssc8XME)nTvDYH<=k{ zCTkZF3#CxBqKKeqf0h=DAo$oWMMV%qK`jW157r+LEc#OaKz-hr5@dfQqiLBOqEk7okfxO^es7IvMxGvr z`;etTdN(r7tfuZp#&|Y`B3lfkSg7fG=@0qvvU~+NK;YH;Lg##dso{Q3Q!_?EwI`ty zIx>o0kcWKW2vK%43Lz_VJPV;DFTm4?>1?wZ5m}L!WXQ`B&k8KI%Q6woo!pE-4hm60 zj7kh6Nl_N^j6_->Sz{GfjBHg9%IkX=M?I}r3VA13O)d$(_V%GKx|hHF$C4EI75`=kX zOKEA;U$(cN%HwKNGN}I=$74N}QZP34tA$*S+EYSeJ86n*8BXI;%;wBtwBOoGJ%e{S zjbfDGIv=BYud(~R)Uy;3?&{z`sDlF-UaHEpg9Dg<)I35wxO_w4K%j#I9UQP}Mh6FG z>X5d=fyM2N18W^&Iq3r%YVd5A!$CsYJ(7pWPd+&Zqgm3Dzw~8P))GHqcbRDVb z_&9C^gnlK!)<*W(W5X4Ylf>0kkR%iG+kJ_3{;WC<*_EWHQ&HN+%{{>buS|XP`sL~A zY2wL630rUbLPKENf|qRSxSfK+JwG?43v{ZPU7*LC-v#p9EuROR%A9JX<#VvbEgxRw z7~JrYSca8x&qwBYZ_9_}WuAeM;aEY&ucJK6lSWHgKC3~9>)2=!*AE~1k_sT^++-H=>7M{OojBauZluxpDCY7gdHVX%FY&j`*x*F;#RR<@ zX%={HlSL7Vyo~7q6c{dw#RcX8dGJsp`qJ6OD_tX>aFXbQQ=i)ez%#m)Sa(0Tuf~LU zWtO(DFha-kx-}7A)n>UO&ZhJg)=>Y)3l|&D6`6_3vd9WtRK(m;5CzDRW6jMKM}JNh zbLGIt-`<7AOJz`Rrrg$UnKJvsk!yr1JO>$3U<3}gG8o7}mX}(CDo%1-I&${3{c(zC mRBx`_exor=NyM!r2^TDnzlV|qQH-*?oc7`N<`K;B9sdEo$ja0J literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/app_state_is_shared.replay b/session/vertexai/testdata/app_state_is_shared.replay index 742c396e4f93fa90307ccbb5ee162b94acf07f0c..28d5801413dc25f44d7b9907b35f352137ced8ac 100644 GIT binary patch literal 4812 zcmds*U5MON6vsE&al78N?RpWBSo+Y`GHu;S?oD!Y)7sjvwc0A%P9H43rL(s~c4v~9 zWES>K`~cAwt51b$7xAHwQq;QZgA{yO5ClQ=q4eXSpa_B>`0-%Zo5>_Ivx~cKCS9Qe zc{s^%bM8I=^E>CHeEd+Ec{OXgkN^JS*PsCR%2n5`)>x@ha~tC&%jVC+Gp=7RO`+2) zv{30J3j*79PBMSWt}ywK&#aKOZc3jC;Ms86W2JZ%%d>-0#jUTnXxA!b_Hu&-A+*7m z=erZE5(ct0J~>i#M?!vIuDKN})1|p)T!DI3uIk4h)zsJ z(=;8KR8@%rA1x1q4Pe0Xyxo%s79^4HN^GE~1-^MvniYYvjQ2Iysrn5kdPix@V}8>K z_?igL3aOt4LfBL_j1V<@a8wTBQx0UL8Ave^G8BanMWqJSWH)(vAX6PtzERbr(VvKE zm>Bw?yv#2CHLzVVNcZ;w5Bj(jT5uDP;LzYZ7vK11VEET_Gh3xjqbU3t>u2|hpkB^SJ;dfc_Nn2B@yh&;(u%Z@TLIjsoQHIq^=NdN#Z&G z$@izq2f;?Lt`RUl&6D;G+)Nz2IFVAj^*lMh7LpJ*yST z0ncO3_~;bRb?IjTJd@>=JJv{KG9#?5PM|CmqBGrDu$?J{A#B5_b!G>L248t^_Nxnb z7Zw(Tm-@z{q!Oj)z>%JJ9C#dxp4lYJURbj{+usx@!|b(TFzym`s*Ti`yNuA z@?yjR-887Ks+3X~-V>;A2AkkREvV1m`Ta`2)MXXd{n0zt zujYpJI+#rPm^ap!Mr2DYpNl3Lq;|se z^=;~lgMA=1G2~{HR1;hvO`@Ow?bFNs#RqT20(ok+NPpfAlENQI?+e)o*SSan^%J?% zK`SQVgR!O?{1X7im`~3NoXl4%@nHOJv&z1C=gKKQRke-PDrMvS gR!=EWut^Qm)cA)OQzyj0$D?ErrK9XHXYDuRFV1@D3n6ezVxL?d@q;iGwrF4jcW1fx zoO{0KbIzIEo~=3JmaOqE`uCcC(j43<796KgBH4V&sg7i=B7N>3bSmZS7&<_F3uX5a z&nr6iK2jMg=83qqLM)%md@20Qfp7hBmt@&4mRt0)d8horO(VPH$l)sSd{_j-ZpAr7 z^1dfpBcuHVr{AaFizO#-`P2kYta|-6 zALQD2@!r5jVo>+*dm8i+%eUZa&zY_+YSSDQE}r zL;3u|fuT62H?|11V}nvXCdp{0ZAexy0iEK(U>y5r{D;ptP_CQ(F{BiY;hHd_5APnE zNaZ$wrC?FjBb9+s4Ec1|?92g6W-^IAt^XJWKMv9=llmsO7Y+{2RTGG#i zzd5iy&ZRc(5vt+}_i<$bMWH)D*1Fn%3}J&lxJDnmr6+x4;@+e4Z>FcGxgWcRgB;?8 z@4$|Ed+fQCkR-F<&qTm}ElIHdkUZGCsowDa7~`^`E1H2-#nenyK?;syoIX$$TCFJI zB}375Q|&Cq@6<4UHc#X`BWoxR3{m~4eg2c)!HWC2&tK>j_tCJvaalvpf0`k?cZ&vXOOJVJXI}Y`p zmkjC~=2};G7rn6A@`Q_AyfV|gNmyC;CXV!fST0Vc;!PA$BUv$o7gFHKf1H~=>7Q!s zJ^uL7;oLwY7h9%gL{(A61!)PMcGQ^`qsKe_A_L9T^UL?SW^Wi-+L%Af@mjaI^i9lG zWg97x#tix(W~&m}CDe5%=ZF@Z`HG4DN}NHI_Q^ZvGCVX*kytkjO_L-#E*qLc8xUE8 zWw0ww56W)wed8n3{_Uf8PN%4pnpUg0b?d>`Z4RWK`Obmr!R%1k^~W<`er8%}k&A1Y zRxZWS%4B@4AlcL<+M?>1zUh!8r05uqCaaY`Ai$pVUG}Qs+#mae=EYorx_ diff --git a/session/vertexai/testdata/append_event_to_the_session_and_overwrite_in_storage.replay b/session/vertexai/testdata/append_event_to_the_session_and_overwrite_in_storage.replay deleted file mode 100644 index 478a1ecae06c3b3ebf89de40dcddb27babdafedb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9692 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 R{`1psoH?6sRVxuJ`4?#V6!icA diff --git a/session/vertexai/testdata/append_event_when_session_not_found_should_fail.replay b/session/vertexai/testdata/append_event_when_session_not_found_should_fail.replay deleted file mode 100644 index abe44864cfd758247194c648c303ca5c1810da43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8299 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 J{}_iHGXQ-C6{P?G literal 8298 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 RR9al|+UJ?wV~)h5{{ZVU;p_ka literal 10362 zcmd^FO>7%Q6wW$H8zw=(0-`JhL@Qt^iaj&?H`9VpDWNTY0rvzKPDI#O=s zPogRZgeufS)FLEM4;-R`L{zF?kU-+pQ{dFnLR*BY0vA*X)G)j2{R`VfUdJ)DBgN5p zquKZNeeaw1zPI@ocjR@mQkdyuKUdlB5XV2BD;Y+qqGyX0qdt)>l-YHApHZ92PNTiL zT|n7Ax@DD(>K?r|T`uam9W}jR>y0mkFFCNwo-y^Tw@bk+TiK#9wc@6+UGn-Hb=|W0 z*TA@0GhWw=wv{VPOpca}QJa0As~E+C%}lUzb!)V!SM6G%f^y6b%cz#CrLk(MT-B|d zqG+n3QYg!^f+&$B74moHM*sksNyHBb8RpSo-O_9536A-m5#}=hE@OPfsFrH=s&k9% zxT)7%8~C>~;C(JIQ$q+-NyZ2f+Jc=q%X`RH4nZVoP?l7MDl)+;mSN8DZ*Ju@2vtp? zSRqIvkokxSUjzANytr;$V<8Cqd!EI7qF@*JjX>ashtB=^&iCtXojZ5tRUx_%xdwc; zt{CYh@Y$)`K6bw1z*ri?!!3N^R6!+($m#%R*??p``25X*ZE5Up_|JdMfvLdkwE$8q zgqubh!TtE7g@asP1S4Qw!8Er{Vm>p}7fqVr3UaPAQb_FYMFPQ|_jtR9fD>s5jZV~e9p)22V41bZ8cvH`CNJ2<%2o6cGJ03&EAzno1 z7T&rNtwY>uBjAmtRY~I{M&fYyaR^Z%rbMGuX1xqmR8_-4BZJ*W#;-#<+sGu?8P?4N zA%ze~H6kJQhsY9+atP88RapDN+=B|j2x7Udjm*z3haCIv)OuD0hYRuMZeoyT19HL9 z05Zav7ORjWtyUq;+8qnwrXZeYPh}hJemqOWTQ)f57RPUEwp-n9ntF9&Y?^5f!U6}L zPSfs1_VD}FgIsG3YqQUbzegBwl$0&*=aa)j`#+rj=J2(}#YOIwzHtu(yTQ(uy|YXv z(&*xCX>;nivSqsu+Y-`>1K}3_!(L-?Ag%AAs*&3FaElK4-krioL_ZA5k%Z#J7e?-Q z!Aq!J>F6Q~;6^^(-evTaAk>C+LxKS6(3hjzt`U9Rl{SUL(4|h{aErA_;e-Z1t?wcC zr1m}BqC>u~dg9)FL$qiKC|E(}2^n#TuoLj3+paalQc*bT&6*x-w)}Fkp`n&r!Yr4r zLWyqT1M8(#pEP}taZBj~gX!jBaU(t6=T8oRj?V~war>tIKYTv#^yXpUQ4s3uABCX3 z@?^WEg*KbteA@XFnuI4FiHxrei1%Le(yr+d?^Ak34@bNwQjU0IyZDK>#>MzUp3HtU z&79^p4m{J$crG%fRhT@AG>J%*LW(F-3FN21vuXM(?m)@8++^fvSU-GJtN-VV^~Sxjt*gkDd;Mt5@O>x_Ms9=aXYbc`q<^m zAy(W~vsihc$FPz>)SEqCnN7nhmpHIHC9gPwFq_7x5VH3`u)+qo{N(D=iucyXMUPfK zJMuD9+QLP%K-t`Fbzg$+_LtY}|DN)YOsxAp;lR<(N~$BiM1JU}PczAy>Vw_K0tw*_ tb%}&|Mu$!e_I8^jD-uPr%#JbV1&=mW7hdN7ar%olof9LnYL*Bd{ug!cjdcJ3 diff --git a/session/vertexai/testdata/list_all_users_for_app.replay b/session/vertexai/testdata/list_all_users_for_app.replay deleted file mode 100644 index a3d71eedb4c8691960fc953e7deca44051474642..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9194 zcmeHNPiP!f9L`LV8ebynNU?6MLJzW5vishf_h%;6L#?s?CAz(Nn{Hm$wYxjx&TJqz z54KvWP^yOF7oQV_=KrI1_T?s3QZ-2>Vfe6Qu|HkwS1dd%TV*gmPjaF8PBn15pAQ zG@=w@P;M{?b<9mnnEXtc)=LTrA+~Vl>gTG2UnEKo_YH+4lw>fZkn3Gi$YM_^Bq8s3 z4Rbod?Pakv0dX^zlUFbU0Avb(&qf@D+q8#FD z4`Nad4FnAXG2I|N<&Y!6F?IUl+%ZMsFA}Bi?HdkBD9K&HAqjcMYbZG+tmw557r#+r zI^+h{XmUtQhm3AK4v_~mHVt`(6WPs}a$w4>hh$2Us){%yYEQncOeHmrLbM^vUKXQc6vx}<>TJL&i$V>DXU zV@h{DYr6}kZBGsPcFL~c@3W&L@0?ipc5Z2Td0Ba5U@Anxi(p^ZvvVEk)QI$hS3-m7 z>2{a$lxrI<5wL=jd=}CRjYlaNV@UA<$b*-|O?Z$LY7< zlC=E4TaXhN+})>uQX2$byv=Y&A53|{U;#)PYQv#XHz z1ATe+y9ktF) zXi`XeiIpF}zxbE$d52gDqIR+JNS|RP;rerpu!4z>4NjTC2$miwl`PXstjx{*`K#|s zgjflpcCoU3qe;Jn4)>q1PrcURIhiQ^&M4sY8oL)`b@$@(g(DxS$@ayLjmHED^=%2x r1ov=_n1C`U|KkFhoM3rrYCr?_NMRfe6vmfJUwrDH8j+}7CV2dRPy5~6 diff --git a/session/vertexai/testdata/list_for_user1.replay b/session/vertexai/testdata/list_for_user1.replay deleted file mode 100644 index cbe8b6ee7810495f6417d0a33351be50414dfe5f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9014 zcmd^ETWB0r7|u+R8c*VFBv`jqA&88X?992$<+K*9ZLGCQbo=JpbTeJoY<9+-*+AYZ zwxC22`y|0)L7_!_Q0OIADS{wWU&M#LS`ah{+Je-^S`yFB>}+PtCShiGjLk0W!aoUT z{&T+XeCPkalY3!z&a_L$R2Tcqvu__K?34%RE|sjj;j$DQsp<^p&9YlDN>E~XI99n>E{v25#j@#0ilV8C zN@YTb0;!I%Dhq?T0RR9$4uzf!&t}wHb<7If#j&~bne#l5S4qBPl?#<>x%P_esBKof z9E77hc#CVKsX>S+CI~{Ewveaf_>1gFP=#366a}m7had&%vSbA>XLH0ld#pq1y^*Sv zBEbc@Rc5gwt`P_t^Lw6QK4!Rv@F3VK4D?+)e(d6=yDz_c>02%niA1hWpzUj7{VxLT zHQj(c=QuEuB=w~s0_v1Osews*sIw|0-9CYCa$rZ2{Oj`L1l0z?mg zbM_{e6T>?N41i6BZEt5ghwtlZM5q?kcahlLi5i0Z*`p_X3d|PeZQGy*8K$2V!r>C`c1PIG;W@ zT2zqpsVYd5ydw|}1$fS$`Djl>KTtz4R@4t9-qykT0iW~<@OcvbIKqSJG;0tUYXlNa zXN44!WvEdU)(@g9DrOHg1yh~r2qCGKAijKjfARMVJUhNZk$8K@2nbw`$gNR=EYCZN zAuB1Qy4=KemI&@3)c2FSeb? z@9^F!@J4SNiTg!Q+Q`QyZh(A~hBU;3dTj;4t=w6sz4ozpUC4r8+Zcf2;DW#FVqZ?| zF-zvs%FA2jj;~J6{Jg{!FN({REgP-!Ytk}a_Zz>f3D3!A?8G1t$w+4gA@lx-Fy7t@!@qXC;hh?@XMU2{ Moz+qhR|g*Y2fPowO8@`> diff --git a/session/vertexai/testdata/list_for_user2.replay b/session/vertexai/testdata/list_for_user2.replay deleted file mode 100644 index 3342eae373798ea50a86154b476b796fa311980a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8817 zcmd^_Uu@e%9LH<79pPI38BL6IfK(wAhMM@!clLQ>f-%Y%e^kicc=J*(geGx_ol1S{ z=nxN`rl|-CX_QSoAwXyX1Y1F95-;VU(w-3FWz$|Z@v;etF%4Kb=iSF2qJO94-x$p1ireE8gHcNS9qKo}pW4|#$+L_8)RyJ=YGkL2rnly6kx^uuP7n0*} zzv&n-xzDuioK@UsmdA4$Gqt;H8je}}GX9MKUUVi(W|GfhlyY`5V-;>WX<(ML`F6#$ z9qCOlQYu?-nHk4U8KYxES!>8)->33c#&B2&cB*0zWz3>eHu5mV;;^k^u9zJzW^+Z; zP7$K1MAc=CF@ZW&6jhcsr~3f_;uw^A;v!p7Z^bstaF@W=F2?6XAg>U7-YRCxm7;r# z`q6l0pQyqnu8tjiRt zs>-fGO+y$%RdPUjg;m@%uYnZQ*Y_&R`KaL-(xYIj)E`@%o%wmw{YMx6I48yfql8cs z(DqyAdWnGcn2x8O?*uR$CiKP>o^*;JU(ZQ2(pfPj-CRKb2w+E;_-pHzeh@&R9`>D( zQZR>uUPez&UZ3esCqO^gWRyzV$B-n(y8Qd5eg(Q$YP%@Wy%8CL`0CNKTmq-VFyxR3 zraQ9Ir zb`bJyWC$sMvHD(&8X?OwB69is-6lc47kuE0oFL1?4rY)6gy(bq;^E%}A3&;@s6%5Z zPP{FYxN8Fg1U|zRa4HNyraEgvFvS{HWd;W%Ys_O{IbcE*S=Lmn5lX45DH?$?mdYJ5 zAzMLQ>Wdw$66DggKYQZ-LE`QWV?mlEMBwtd-X=iiI%+|h#2rZCpqJ0HM-L7&el)B) zs?(zhOFRS8xBmD+RT=42({;@17@er9hU!HOsunRGJy*AgX%c6{F+-@RSXXpSMFc~F zw4eiF1q_rm$S{JaLUfh6l#UiLoFBhl`RsGIi1CdQ_pB#^w3?EOuKN<>=i4O6iFOIn zYTUsbG7rKtdg7}O&Wp8jKe$s8_ib@uDM&k7tyOoGN@j6%c%1bd;=c&sg)qgQZw{|o zo%Xx75MHy@CngQVd)!XSw(93|eX+@zQ$KxpV{vg&c%y5C%V00q)As7@5^K_!i3cuo zU;lKgkNcIJ?ezxi_!mOBi2Rdy58Q~nvov_G+ez9xx^vL|iv}fr98Zhk`eDJK`E!(h z6u=tu_br{zDDmLAD1`7)ghF^dav>ZMs}y~&H^Riv^?fTH?}bzU=?4TCmo- za*a&~Z^smGzQH$L%boq_&Ai#@>@xv|>g)$*@%Y=x_H2_H<8cn-*)XN`B>}ut?*rXN zN5$8ihm>FqsWQdvgCGhKmBzr!VUAV)6qk49c|kl?Er>t8aCLxHQ;oqEVcS#S$rWzL z*5z3HD+iLpg_1Loc1Q diff --git a/session/vertexai/testdata/missing_author.replay b/session/vertexai/testdata/missing_author.replay index 71f8ef74f9b7580109e263810b19ee3a498652c0..eed191ab6d74b4dfe30f1f689494b8ea3125061b 100644 GIT binary patch literal 1731 zcmds1O>fgc5Ka1})Nnw);nOMtQL4tyhwU^jJ)|@(LH%?A3EC>!+GDfec#U@*wL+W_ zoH--za7W^2@E5oM;%BgKoD!k7ktkg7UaTE^=FOWo&)&LiLloenoOo{JbGbrZUeJ6> z{Qy)apoyzuBI4YmaaZkC4uIo|x&w?68ty>cBMxZID1y*!^&sTRx4g8%k0Ma}IdDXn z>d@}UltVdec$NU;^4q+GqnJJchcgYkPfI>6aS_l0>R>MFGc92yM^ue*P|;$*jD{ri zTcJ-vVA{M{t~TcDjfP&<^@d@Z^{U4D$l2hfDjT!c%z9a`I=WH9xK=Ic)tXnr74S-B z!!-=ivI+J2(xt-t53fE?UHSUy2Y-~iIxeUhQ{GHT*U#xTjvRG|W1cYi`($BU-d-b& zBkCcKCZUTUj%m!S>595SAaE@NDTOLd_+uI`pml*2`(UAz5E6rny!10f7z!yz47fV2 zJd|#Z0I-DJ0i$!$taN#}h^(BQTlqL4&5X#s3~7la)%Hen4Yh20 z)80ougb)+QvPjs&0dYGx_LHt?(n3NMnMO`@rk)5*1iLg;`vj>?vXv>Yb?88pjz0b0 zg~j&9gXUU$xzn^)@2|Hu?#-cYpR~ol&h#8|aKd0QOo9OIW*d?Y_!wlz6^lUv)`3I5 zkDec+vrugS$3n$Z0qyZWpZy*$qO7VYxI1D}zf_RII~G!>N2uU6U8KR4z&R|Bv;cEr zl2KkfTP#KNB0Kq+{1@RR!bfh6T>9U9B&8_3V#>R`w0cfYXL{3cl7a5GrOk^NiDA@D q!)yo_(Tz%DzE(5KTWO19%3bNs#WAu-OaNxa|2NuB@!x+y+vHDKrCP!O delta 35 pcmX@i`wyYTsA`&WxEEN39TI z#ezRT01W>IQpbjqQ8RJ@?$>?%s7F3du=EU$favW;$yw z+X3f62-pjG>|;Xpd)4Klhx^4NP^5_WKuXHl9*92mz-~qnFu&CYrmXK-V_ls@fCn{5 zL?!lkZ*0kPHC%WZgH+}lgG7Qq2Tw_x_%8|pFDQL(hukAd!%I7s1y6$t5*BR%L~=%1 z&|(2)Anm2X%G*D2%nB^`;#!+VOTSeoyqrgP?#O)3@QUDV|<^AQNag>+_$c%4G%I|ve) zFlGI;iw^ad(U#^#0&q~$I~CxgZt^vv5;3lj1chg;XU3f|JXVQ66t83yjawtIOzUC@ z>)WI;HwI!2j^d!ry4?IQX}+J_-*MX;?d|3!YPs%?dx!#HAgJd!xnEar23Z``{& VM;Ylsz+CQslWjx}UxREn{s2boV*>yH delta 36 qcmX@b`ur8IBl7B`JSF|+_8Q02uU|=14ItP2FATJ zz*mmacSQ5IqP$6m2uo T3)Oc@wb`5!Qg(|!(029#g1+8X diff --git a/session/vertexai/testdata/nil_event.replay b/session/vertexai/testdata/nil_event.replay deleted file mode 100644 index 71f8ef74f9b7580109e263810b19ee3a498652c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 635 zcmdszu};G<5QYgXC}YPy0ZZ(pjh$FxLkNT_QPL+c?qL~RpUF9u`Yya1uYwT_Y#k5_ zC*AU&^y$BESYHhgy*`}edCz-Z%r3b_v>ur8IBl7B`JSF|+_8Q02uU|=14ItP2FATJ zz*mmacSQ5IqP$6m2uo T3)Oc@wb`5!Qg(|!(029#g1+8X diff --git a/session/vertexai/testdata/ok.replay b/session/vertexai/testdata/ok.replay index ed990b4f103a0c4924c0fee95d036b758c2fbe99..d008edb56ddb251beb7580a21b5f91cae3411bc8 100644 GIT binary patch literal 7020 zcmeHMPi)&%7_YOm4R2!%Bcx#vvMRx{vHD+ZC(ah6vSy`YG$n=0rm1qBm&mJQ8^7nU z3L)5G5`vw^Wdfw39XNm(yD{yA#2qBiIDz1R5ZVce%Rs2%InIBvRCTLV>S8GezewNz z@Atmn=i=gtB6KZ%ZHzp3$uB|CIlkgJ6$^5P<@hB|H_89F?08kKCayrNi`)`K$aL%_ z=+#UE@+Ul~W7s@oKBd5mc+G`eC`ETo#2HR?L!muVitu$GB21qF1=n-VLjxmTFRe~j zoM}wXdCM_$OiVz$kERW1V^6n4p4fpL+q5fryJFf9@rsgG6;%^tSyn_XlS-)qJ-IUO zBj|}U6!A93+>QZZfVq}qS3KVigmMKJdJV1gr5LzOwGB&)qNJr{Nfa~M7A)gY7*@oK znj&dwHI))XO;$5vMp1dEr5y2+Dk~W^C1+%TJaS49GBgIo0bblUlRZIi`}Z=*a!JQJ z{WN%r-k13M!%N>z9Ju+(Z~w%Yp0^}*FD#kthS}{anK_7C_3{-3@=;9hh-ejy4Xk!f zwx?JIrX;baQ9$)^@XP@FJ=frtu#SC1-x*Jg({DHZW`}7iYde@NJHB1wnD62sxq-d2 zaK*38+hvEo9;dIx&lZaFbMp%`r`de5SSX%jE6|3XX=JlR>~ceFmL)CT?08OhUBs0= zy$auOyj9K%d$}2f`~?HLLF;DO<8Te8SZrbo<_;|sUR^#}cx_?!5dVBfLHo2vL93j( zHZl?IRmbL_=Q$qYX3WJVWOZpXoD$5nB8k*fdk_s92nt1FlLN!pr3aXIDi!&cte+cd79%fk(h(z^k*X&4VV*K2I3q z#rJ-mIPmM|fBqFqK6EgM4n-f^EMuH;U1*o`HNy3oA1QD$N@%-ev^eAY)Nn~7{Uj3) z!gM5V+BNZoy@_|O-1_F~dc9t!&WsfTHXUQ;!Q7U;LoQj4z~tVjjd!c76DBa+53?KL^8dr| z{$Iky-SN%Ba8MY{l>=fvrlKKLw~$tUyt3_^-os)3ug9Y8qkD~PHo+YV?8mmmIJkR*y};1IELFp zL>G^~{RdwZqdNsmf-Qz^?_@iN&yBQ4*c-KWkvKYx8iIrD@dnlq4XQ|!Db+C5bXCDb zgHa738qy$S0;yvrkr-o&`>G*71{(6pN3U{>f02AqjEEF=QGNAo}fz z<VC zb}s80WsLyqW^_eW*%2MKGT3irHbxne5ND@u_T)pxL?J4nDw7FWi%STkgdznZmZpjb zRQfC-0mT;HJ9Wt`Ln0&bhRq})X(74j`2c)=u}29x(W``{jXRDZD?xxYbijPS};UW8H1rW_@;gjyVtca~yayOUoCTBkWn<;CgFX)2^7XEkEY@DLefC z4^QM?dgrsRj$K|^S>c`=nF(m{7}(o$c8<-g8dJPE>#lmT;<)}|JNc{zL1&6^vDeuk z$eMe{*~pxGXNm#k{&Ya4ec3ij6uK6ZZn-8}r3B}WX;j2O`=8y?#bSwCF`?aZ>o0+2 z`(RrA@|Lt^IXU@vxolfJI!Q>ZXRo?p`xft?Mz4=&vz6mP2J{imj)4tU;D*5aZdwHD zD>~*D@SwjoVfq$73_qc*k0#SH%K99B`fV4v4_MowK;rF}gKd}krsNT`YIbf)&SbnP ziOmvjyndc$3GLc_L7RCz%YDi39C$p9#~r!spcFzGF^>{bg}v((VW9?|$WovLtGL=N z$4`FudF&Gdb_|FsNM{#jP=%CI zb`|G!n|F-be*5QgjO3RgPL&lW^p&l4l6q#bkTnUVsE1_yg?We9RpZxq|&$SG6 z#UG{R%3YhS^h@Yn{|efMrHtofV(Iq*2aXTen3%|22=2fB{#8o$CGOdLLXZG&S6^^I mLO?J<3elma>M~ZC;i{4T2*J-j1r~nzddWL9BBQiIaPNP~MfxEC diff --git a/session/vertexai/testdata/partial_events_are_not_persisted.replay b/session/vertexai/testdata/partial_events_are_not_persisted.replay index 30e31418339a67eda065b20986a258fa91a6d35e..0e695695b4f8da854109f07ed32fd6e6c479c99b 100644 GIT binary patch literal 3382 zcmeHJJ#W)M80K7>mgB4Pge=6sfI?AipX0A9FrX4zm5-KmV{_AMxe zAu%D47?2PWOe~=O2o@x^iiN2YLkA|{636+d+C()~ix8GP+}V2WeeZqV=Y0yZ69p1_ z&hjDtZ18s&h?kX85R^P37rmgmAUiJq&K85HBG=G7VGfe#2&Hb|&ylF+7Kt(u5r>iH zrt})XDYhIEIYx0pm&(PU(igM~r9kdh31#9PI1@&}5-BpOI1A;mQZUB&zTyQ%hw%`o zQl(=>;i~6Bc{xF-|B9m%p_q6Up@esPZN3@A|Z3hAcfxBEfGB92bBA*x*`bD6ug$E zDLP9>L$2>6JNUoP8hyBZpJl$r3Vdm|3S7VGrUX&x1%Q)j%awp6e7n8pBYQL!+E+q4 zW6_yufyAs5zX15R32&maiSG6j;_V4>kcm{R+T4>NQ?qTc0g|W^pKQ>I$(q`j%G`)A)kv;w!{$6dw literal 9147 zcmd^_U1%It6vsQ8q{f?wIufkYs#vOwmF&#DGj~2(MQV+0{fchie4B1=*EO4+ac4G= zw_+(o6oeq+gSMh50kyVLaINOa7mL0q=!?+yL8SWRo5hdDvorfKlZ+`dn{DH6Ac4CJ zch0@%oZp=DKgm6_H^-ckHMa?VF2nCI$3G+$Y`aim*?h^aPGzkkeD58$D>KKfHd zSyfevO+!&MnIFrIB7`#2m>(1}aH65A%PRO`4$httY8fQ2VSLFh7b?|q{SnzohgEzV z_@f#0D%Ubo$C#LkN-#Fe4(ycN;F4Vln;OMbQ)C@Zq7cQ@49RXj+?7;WmsL~8vTPE~ zzy!k?D=4?dDgM*15f`-1_Z0Yi%JM9J8xr`D;SY~5oZEcMkz@bpLUKlT@xMUy*puamL z$d_FuNQ~W4A8v1Y^y0af7Y0HMSxF7j;)zd}Miz-T^f5#524)DRDmJigszk>+R2zz_ zh8dy|0`&(4&;V*=3M}a^Lq76rkgvc0^aBp!FSHVG>>Gx}xa5DqkQlq8J_LpY9=&|} zuOGOi48h_^?=i&CWMUe+X{yl5m{ikr(&}S~-^Xl>IwZ!{*#E_&e`K2xbWP}SH)*&Z4twWO9 z9rYo!G9L%;i1SN38V!FmQN)|K)GIEI-<`By-Qzf{JT);3s)O(&haOAQ@P+nhcC4>( zowck`JS^Rvwr(&|I)Et=VOjrF0YgM8Ybjuv6_ z8|gH5ArNaHOiHi%UJbd)72e{~C3tv~=8ZnP?o#b*8UIx@Ka@^Tjv5)<8~?>1>TZji zyu5Aa(Tb)xHjlb&e)0}Kk@*B|EQzyiJZ{}~jNI8|*zH$>tV=v1d6bn{dqe_vkZME{ z*`=9xT_xG-w*S6Br#g{F==_aCk0+70LsJeIdk{rch+*nFD3;JZ82k)+B29G?+~Vr- zMj%g~^vlNirNyK5wji|H!tHzj-M_{I;bfkDsCqa%G2?i1;vIb!D>3A_PO)MTjmnBn zWW|7qnXDVoEq5_iF23>kPwiOoS4m@KYoEnROo@DNz?I`^xbiE94y5IZX<(R(z;u<$ znhfenm$34QkCoc|!nbwBGPFt>DtGo;;TO}l{t;Bmzo&dn#uk3>ap;}vtVxVEG>OHZ z-+w(5FG}3qdmxDMZ@X8H(FAt{Ab2|s2ri{BFkqqq#!wJisX?f&V;EsfuWx}7{Qtx$ Tp9H7Qd^ZNe_x2(w2k!Y7KI7uc diff --git a/session/vertexai/testdata/session_state_is_not_shared.replay b/session/vertexai/testdata/session_state_is_not_shared.replay index f450cd979988421bc3b816767dbff70dd998cb11..d949a013a7b26363f68a2ac564f2b541b6ce7bdd 100644 GIT binary patch literal 4865 zcmd^@ONi4z7{^Uo*E&^K<3)ma5Rv-WCNoJUi4X9#zGbx@1o0BPv!%Oj5OIS7vu&w|N=gwm{IA0&mr zY??@03&iqBWm0^=ft}uvOOpOBmYb!?w3DB7(}rDAGIgTlylOS$4sD7k#?nt?G>yXl$agVv9orj+sBaCHiV5mV&HQ1`)fz$+t7&oX5{;wWy z9}9S|*pR1)`zjba(>|(2l7?3|AmqoW4JsX8}{RS{n?UK59kDQij)-A z1({NtK;_gyU;*f`T(|Z>Fb>wo=2<_}%!xB^I~IO^edk#u5m{C88z-!vs%AF3F0p&N z2U+5apE$5RjMMA(2vl>G`?s%^LWN8f_ic&Cj$e5Ee0XGZbd)zgs5TKmJb-%eKrpVXmy(NdeYni^$A2FF`&`*D zRj%*N(x4ciBBOCA($#Mgko`c*se<`3jqgXpRlAKw`aBjsM!*b^2l&(06lhW~k12D4 z6gd6n*9|^(#P0?IwYR8LteTKy9DXc&T_d4l2-aCy&mQ+{>p*f zQ>l^bdL`Y^*gdLZcEPGL>jf1<74l$Dm}<)R;&@%srE(Sh{rr#1r7o#%w1iu|2CSOk zh3sUWbF|o(?9RL1P-6ZJ(@K-vUEyoxS{SW-4zCr6WX(`?O;(_4CQ-}jqWJ#whZ{HC)qBtCzNZwa7-O5OA7O;)Kjz4k)IX^8vbq8GF(%i3`kI$Gr@ zm%|xlPH`|PdXc5l)fp>z{ zLasOqOVf34IuzHHrdM-9kpfrRe7eTmFmRfhBJ$v#+i>f%ZoT0$uIRdH=!UIQN_EY) z2r*Q&zd8;_;V@s)(1-P9^n1Yx9W()DG+Mg&@#>{L5B+}jI+1%9W$;%5otbUB{6FZ-u`tEW8i2ET z67PmcLDPauoBb#TG+VV}BEilX2_DFkey4o&1AxuVtOtOiOamv=Zl3((pIUV;!w)zP z_qI6;W&~lR(olDaWB}H#Roq(S~|IU{<{y?H#RoF8$8}7>kC+-}_FwN*liWntT}uwhv!+!gQl7@R<3rEg;I_vzXP>p;$|%AB7N zKB{Q=Q84u+d}50SipoOoczdxj+w#Me^27fYf|UzJ zVr8&y`z}F+VBHjUiE0)mMAZy}cNHpEHZK3xg-X2IDOC2~Yg5!RRBT(OZBua~YwLz# Q;v*3oB>8Z0L$v(Je;ZtXy8r+H diff --git a/session/vertexai/testdata/temp_state_is_not_persisted.replay b/session/vertexai/testdata/temp_state_is_not_persisted.replay index ab7c93b3ada38d05c3048b12c7a984564e5ff1fe..52eaae9597342db46dcc37a870b674884615f2a1 100644 GIT binary patch literal 5083 zcmd^DO=uit7|u*L>H1Y|os$e&hk$mi&HV1{%xn`OO|~($&Bl!)SW1}8e!EOFJ2TGr z&DtPh6bf2As22;Z6n_pLdRK~vLQjI|L4;nUcoIbL=c1>^Z+3R}r`QddgbkR5Im|c9 z`}@4l`+jd_ai#)&$6OmBzhB6crI;z9?s;_w@|NR;Ro=A8cU<#=2H%ucAT}j_2_j^B z?h*`|wgrWm0Gb%K582Ns@El(AAs6htPo zLWj~cMO>xX8#E9H*y?z0JqX=MB474l&{D}d-js!zAR8i6;RaA_;YACWUG8j|_cyZq}_C>to-x-qPs))8q>QX5~R4m>EvW* zkTNhO*?lblZH$5^2iPC@7Qci|93tk&(d;PmX4`LWn5J^Bhq;;;x>cV2J_B-_*gFeX z!uq^h^O#RF%(cwfa%FyQeqs7FSE^LXm2+Gjx-hVv6$_H^Fx1&(HU_n2s+afm!*Py~-8#{37$U^zW<&))?7G{qK&vq4bPWu!jaOOL- zM6@?Nmxn>%1&E)v7u$T*qs{WFaH=Ek7}p@xX4c`bUcl$L*GZJCu*j*2@kMTYm(3sN-r%U*zP-!?xww&hLWAQ2>MFr(2eS8c zQs!q0G&-CxjF!^R<>Lvn-+uqj=XB)+I0*I=Tn5EeiOH~ZQ|cbbtpgc648};QcmI&Q zx4%bC_B@gL_NAZ34z0iY!zC&=YDBT3nB!aR7t_8E-D;^x+C2L=1x}_3bdQXrV_c_( z+cKC*HWLN)XvTJ%lKceocobC(8$D43fJUovgijpEu7CK|t;=^eHa4hNM#@pMrP+Bf zw{7>4PX;HL+rO9VHm&ccZG>A$L~PpGr7I#q;*e(-CFZQ_+vKd@cA2vdWUsbn)7!T$ zPgWMX43(C$Ku_`g2Yo$oI1bxKbUFs=O?zB{%(oQyh63*l`7!|`B1M9vYPu}v$@N9c z%Z93nY9Z#Q@sV^4l>>L;3&G@82|fxxCV^KF-7-_?k|4$?Z3? z4TIk)u*YiYp}Q$p4YSaJ-BrVll&gk5DG64FOu|paK)RM@1Mw>bPHkmc>{%a3ilV6n zO*2U8>xQHlvdA>R^J!*^IEwe%8+_d2<}cR&x*6>adfpzTjy?mP9-xqThP{=r#+MpC kUdtW)zp;v55M@Kx3?onWQifV6X!2q_+@0DvQpr8?FKEh?!~g&Q literal 3986 zcmd^CJ#W)M7|yw+4cAsho{&Y0)B%N}+Btv5X&FjM3kB4Y4oGZna*aY0+vJ=`-B=I< zNJt%kfscU!1_l@q&;gK;5C|dI5G;rRf%pS3wVdsIR%)uaRX|`Vk}>BW(ZHR90c zLe5GV4t0SDMKY4Yrc*F-NT7ZY%gmaYgqg{jmbE~z3Nx|53HY%972igpZF|N~HIyB?biQ$gIsV#?3Y$}a7vg3bd z=%CNgt6OB~hd<8HXPyGz{doB+NKV!m>T|Nn!p#X=hdht1-9gcDJWCvptAq=x-!&5z zuC`gf2Yhr~VSjDk-9{W%8c#8L zRs&85hj9+qCWMuM6GDTP3M|^J8~c3B+zK){`~Yyem1eKL>zAUct|^+PD>0<#h)xJ> z9v%zQDEL|2sNH|=9X9tLetE~xRL!J Zn5OG89Vc}~RW)(e@7SidPd_64zX4IHAS3_) diff --git a/session/vertexai/testdata/user_state_is_user_specific.replay b/session/vertexai/testdata/user_state_is_user_specific.replay index 62fc28511dd97263914fcc9f9d3c2443e06cbb73..29a2afc7312f9b6490332e6f3a9a7898e8018a3b 100644 GIT binary patch literal 8774 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 J>&2wO2>`!ttE&J2 literal 8302 zcmd^EO^n+_6kaF0CCn}Yj4Dc6IP`!_(Z-(fcsy?TQ7KFLs}|B5H#a*Cp-F6FXQjO@ z6r_rRsCq(Zt0GlMz4cPjsPtA;35k{;3DgrUCssXiq6kozu^ngaCQXS`vvjjkR(gpv zesA74-+b?L@wHt=W>?LHG5+@l|KtSmX}N4!n&Dt9od{aFkQB$UH-jXwMwSTQ*h*_GhJeJw_#SH%=2)pdZk{Tsh2Bt=E$0+6O9-O z##n;}RaK&h8;er_fXo3Xj^{JHqKT%%8t_?x*Ph9@GC=8*eATL#8_l}+h{CMR8h#Gq z!3_97=%lGbhzu1Y2&pkdp0X2cvLjQ939mv^sR|LskZMG>!iPJuS01G*QWOdi!32vg zDE6u0mR*fCpi|u*-tjrpHO0rk7I7+f@z~L?H#~6Yqw}YPd@PcLHhi|Iajq(Q~T$*SI zLjxmZ7#OQ4RSgpGN9(-{7c%_*ibT@ABf}s)T5^|YkS-|2d&o6N(9z}J&%B$BaLCQ7 z19kL>uTI)JUYcBg972hL465^!#RnNfBSc4?DF*pdOvs>X%OO4TTouhoH&hBKQ4McO zF*Ky2D2G6VprI>#LL(3xx{9%qDu;aQYmm!de09W|V`5;_fb@m7ZHcfOEWJbdc9 zCuAbgCSlu?;E6sDUqnl^$Nk8exU#5JSlQdoF=h{_cR={Apt4 zv#M#!~HM{Gyxgqfa z2qeS-iPzwW#GO~*1$YL+nmAI0L~{Tx_+;;TelzpUH^K3p0MeK&O6Iv{l}Z)Mz2!$j zL@{6*i!8*1nR`7IGQso41T<;jF(}0ZAA_7T3jSW20uKjs;M)6QId}CU1-xRAlqpO_ zvJrWK5y0~dly+ZQPMAt@0#qxXgr~Jg)U>(qV?l{F`IVok8Z}uZi5tEIq6B9=8t{m5 zP=2e`X}8+Fdb8PVHG19M-FDqqtICzn1s|L9aVTF8uM&O!*$`p4k` zqNYv3rM sao(cs-FNQ&d5tPHn*-JI|0Y|vUEk?-JH1}hY<#cPZg+P6foxZP0Nz8@^#A|> literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/with_all_fields.replay b/session/vertexai/testdata/with_all_fields.replay new file mode 100644 index 0000000000000000000000000000000000000000..0fa9394cafe8c02b19c3e0c7067d09e77e1a2b04 GIT binary patch literal 6237 zcmeHLPi)&%7@ys=&0fp?xR=SAkX3;e%Id_9?bz80>ej5ZjHaY;*)&D2^BQ?^{EYov z79qrhGzrSa-@`zFkTwM3zaK#J~|9&e!Prb4@<6yOU!^e}S{Kn!QbfR1bA6WS3pvt_$V!4s;v0GFNDDm1-%L5d=|7OQImC+5>2o^a8hf z$&9QClA2B_X+;rbNmL~@iJF%at3*}Ih(bz{Rq|F*B|%{@DD<%6UnAM&^p<_kkSLdQ ztTPXTqs(Z0?ZYeI4ITRR*6Y`4w(FFj{t8kiyJ>XqOPN`SoB48!0=X!vw?#CQ#RgU@ zCfk!NJwp;7Xe7}3Ab7Ng{;p&2OIXLg$J`l=4>GTWc60qym9r7%R*-L(c=r1kNNl3- z3|#ihbM^{iZp4^RV(0UPx!Jje>9bs}P{po#4@w?OPUiK1AMn z(ll%-mPjd@mRE$A@CEKA;^h*|a&lAoEH}Qx>W_0Tb5vqr4+q#i93b%0Ri=A5K=?+_ z7Se&sy8;IW|IawU288lG8l33SUz@$F)w=E@)y$6+sJ6I4Kd5ClryC!QztP}(fBg2& z4^%+_2f=WiFJ~(v6JzOB5gY(Rq_VRWp>Y;k=kR?V6y_1C3v11njSmGDS>U z`$WvNR|Rp5IlRH$r$*wJ-~H;F_wTK*uT%3NU7o-yNISoFg!E1e|GU zV0+zpJL0;tOG+~(`rN4=3G(IRC@avr6qw%%P3_oUW+XvVGFnR2Qi7OKRPx7Ez|&F4 zN8rVci_4>cWk%v}HgM$fwYQ#RI}b;wiO0cXJ+32mVYKY8@VTmkYl*|V4LVfP&@?rp XX%g9tYqFwb#KnLE&Tk)bB*y**S@$F$ literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/with_bytes_content.replay b/session/vertexai/testdata/with_bytes_content.replay new file mode 100644 index 0000000000000000000000000000000000000000..4b13009854c68e938e42445b620ea9a0a2f8a403 GIT binary patch literal 4003 zcmds4O-K}B81@@~`>d2X#ULIeWHa2EZ+3pQATzV9u-2Ow8Q0Hcba!UjZ+5|3L>7dH zs6#=mO@KYkCv7{K!qG&^&Q3%PlYVz6?MynFL*v*rP;>+nV%pXS&g9Gl=KC|) zaB<-!_Z+|r?u>(@z7@kUNi<=nw>4T}C62FWF>%>ZIP7HXDV%T#Z%j;gC+%*RzUNbR z!f>ezgwK-h1h(9akwQH6gV>g7B?qjeX<@?4vZ~06E=ZCjBVCKd6oG9Vug?;kL4ANm z%Ww+}6t-|TWn0Ni*7A%-haH^B-^$K1a1In5QxOvNm?R>k>AUcZCw^K9uS%j2lN3=^ zHBpolNe~3y4lXCWrpQP_Fwk8I0l+)ZQfak-cw#-I=jhCRh z;9&c?IxmeKoiP8;wzF;UkeN1;n0Krs+v3UCSpGcbQLU@av~uWdbJLCcuirgdU0YiN zqcy`GyBO{w9Ncvlh(pB^N9w~8_OeObd{GD&marugK~btq*s!selqPiSB@_D;`*B~a zo-pAURbD$EO6(T^KLL2B%54yP!a#UQS7|R71re!|5Yq&tgrS>QvyO0)#seejv-zg| z;oI*wfQtGy8vdW?Z!GFfKmB%pYNw*^!Kcvg2saPH=~=mvfJN8#d_L^*iSmpIH^o*u zS)j^S0QbMkZw!c_$#2*7HwHxWYRHx098TfV%CHb}WvH+cK%&Y`rPIgD(=cnqGJr!n z8U4z408J7#q{u1~6{JbBEJ$n`UJ5fO_*vX+_`jXkk$dkK-+IG;+36VQItfo~aUqS& rwa2rQ(Sfw%&P0wJH1W`A1JX5>?q77j)MZ6c#jF0nHnexz5o!4cp|C#D literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/with_config_after_timestamp.replay b/session/vertexai/testdata/with_config_after_timestamp.replay deleted file mode 100644 index 3c35d4b8e4d55ba706817c874d3e5412fd27fb8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5409 zcmeHLO=uHA6z-(8)~RAy5la-o97=3ycJ?>BtrcpG)vC0nUi7w2ry-i`u9;1tH-B0v z=s`urgQAxn{JASV33?JQA`}rsy{RD9gW#!kHk+hbZBw$fDb$1wWGC?Ez3=^f??nfC zqS#DoV>R^ig?<|V)5XROBc8;eSklN0hqMI!-x@K}sn94lge{E=4PrtPhCYbXqlp-1 zd(v35aPH7Q1>j+8%)}u_ie@HAC}yOV73z`_#pg1ZSj;KdZ>EhiIA#%68$R0}H`*FOgn<4EntXpX<$+1)qV(1Ay9?|0o9TQfO5w9v?RTYsas*)^+d8U4-E<Kexux4q@Zn%rt z-0Gvwas>N0H(8Czjm7@)`ugb zG$H*n03PxJOp%PdXD@@wx|Z4L&(?_6I=k2$Xl%G}{ps`DbMy1_;B-yDkYO=)*_!B*gkn& z-YkY2a|6J&pKkxog6(ro7L>i`I*5~aVb3+~5h|CId>&MIchTbXqqT$dbQ+?`?wZ*YdQ@8w z^x_ZVL9iDu;#Cw+f;Vr1h9W$p=v@F0xnmAX_*P6O!xJex_gA9%f0Q1@Sjti29P1lsR!)?;~ zrmtVepEb5zntbsj5)ItN!FNAnI{!-UFBx-?x#e_u2w-m**XK->bH#$$Sx){A1}T{{G$=CZ}kobkh-enwcBx+HQYNY z4rKHtfCs_=Q(>db>}60}YNf5wVvT5P@QTg8=BD%49zDG^Ju@={PS^GMydQ=K7abjU zLaIam3I|%p1C53z4D%?T1$CniD=2f_Za8E2L+#7cIt+HwfbS%T& zVx#2Iw;?6aoQZOyyjkN$8QbY*a-v0=IcM*l7(xhl6t9yAb$Sf+t{7cERh-)RYk&eR==+dc9b8RNS?KwsyE{fftI%TzjZ6 MlIYDj?pS=wPrYk66aWAK diff --git a/session/vertexai/testdata/with_config_filters.replay b/session/vertexai/testdata/with_config_filters.replay new file mode 100644 index 0000000000000000000000000000000000000000..f0ad951e05b674cf0d55b5fedb6e60911f4f841f GIT binary patch literal 11083 zcmeHN--{bn6yC}1X5($O%T#n73(_c(wru9!J3sH%7PPL_s%%XKp>MO<+mLQDGiD}% zeQdQV7D4+USQJE51pk17zWE})2v!PJX!S)XZ6Cy^KB+gEnM|VDtht*_WIIb1G6|fV z^WAgL`R=)2;rNjPbt~4|1pB+m{xgDjNG>@}sY3I`ic??7TV?jWf6A#<^9{62eGBDJ zP|qtn_6b^Rl#5h8Qlpkn!w;p;1@NrD=F)s%#d6DDzUWjpG}>jQKwqs>&lgXECAa3B zrbXYAt(DdJk~8nK&*h3!w0!1*C)d6CBDMXRRY5ZI!*lGiU0SqDWt)1krWv}X6R4`H zhKQ*sIuvJ?r|TZAp~C`;HYHt30ocdgier~*b-QUazvR+d>sQ6IDR5qBJ7yq+iK1eJ zOtJ~jWG{f#ld%qwN>rUW&pcKXY(m+Ie%zCZWl{ zaY{-xFt`t-E!SOGMXhJleh$A6chc#dnVV6}5;GlW&r0Y_9 zWwF7wv&4XTh6mUV8&PJwA&fRP#0R9QCXD;1%60=imCl?wfBEb5$%&=rc1cOkfuoxa z%yU`kqq*q>>29RYZWYoq3DVt2zqVCK-;*HSjr8kVh4hXD>29RYZ57h@@-n3t%AViq zhn~dClwuQ6KO8Yr;*C9~c-*naCdL%|Ljg;?ut}D9dAuxfXXbY62ypeC-@cnZ)!Mq??**m})=+ zvl9Uk#1kwL+{~dNTkN-C(;dtU`>ariB-*nko591 zq#^x2sWFUwU^R@?`VG|#tpNI_$h@lZebja4+fGX_aXV?OByLhX1yeqk@ zc5GryRhu{VxZQBa9-A0b>`76M#*L$x#>ipsNQ&}*p5OMJ%0+l?&!W6Lp_9$2WN$io z|J_`*_XZQ!fhX~CtCfD`oW#c_qK@_ci07@@$Nh4ZT?$!M_wb=CBevQ+MC|o4gbU3V zKG1rTO?J=vyXV0_Qmw<-t?8j1#y*}NBil_HYSK_QKIP0Mb+b?1L}A^$n;UC#97>Ku zJ;WH{u`QWV4Y$h-HluoYdlI_gh%om25{Pr27*vGO=g{qOHiEO{!t2%B?|tvR z+3$UmCl61`uCc@07JzZUss=%|E=%Ql(5#fan*KjN6GV;Dv@<1R&ncajO4S1Y zw2Y=}WoaFbq!-K1Me|1hkHs^gEG1FAu%=4opz+$EnJAO;d{e5}_!>@xQE*n4V`X`j zb7R$DEY{bpdQkRa4MAB=HCC2>9C>xe(l}J$*Zk_ZU#hO+ z>zhhO2Z2s@*qj}P_7Z68fnSZ9ek*2aB9u}4p~j_QcpWU@Vvd6ZCdhHPSOG9gC9EpT zMHst`A%}9?7TgvBSwZ*V%0dKT#xdng-{x?AGc_VOxkMKKTgVLO3;CVUMXq?UXY7Qg zF^>pfcx^`@8oZM3t(dbc0U>^Q^H-FI>mkQZ0-g~=AyW6duuX)v2VF$Jrsu0 zuZ&OYX*BNvcq9)msTkeDUIYEDtg*vv9}s)iw@#YRMhh2i&ECEJa(;dud^0qWl>G~M zbj8`JP#<5i_`w?8S-LN-D%~5_=+4r8X;taoxJGxD?#ru6_vjkkS-P*RD&3pkX9biu z_pfRyZi@tE=2d@-A-W==&+6}9A_M+fq58WySoJqrm}^V>+t24Wcf{X^zpQKP+MhS% z({Jg@Bm&2HFaqxYc(u>_l5ELPWif`CgQ##Q5gZH0CU&Ckb`2%^4h_Ac@CFH%a3YY4 zF~iiQ1S3j%fKBV80fOaJFQJ5T;s}O_uu)HD)9PY?V42>cG16*1UFhQDuG)@~t)LAl(D`)XIz-*JXMH zgm!I+>*&eF)IOPy;c;88Jo)aYSzu;Qb} z@B7|+zwb9WF)}HFk~`Og6|hfB9|}umFb*YRR4$P{PIk0)|?WdYv#@h zDT}^$R+MLpd0~u{g&T_cOZ^&vr^2~F`v#5&W&b;o z52fKwpC2mtL!o+al>EFKDhSf3$f3OO!m?X34TVGcUePOzdWE7Vq`?TXIHR0ngfVvr zp_bM+)mxFGd<>{$Gy0tjL~Ece`Cg%1@v1R%C)Y^gf9sf_yqVsYs>ai9 z=xPVxer;#}vzH4G`u5zvKEIsN6Hi(2Z`3d!zqPij8s?-38{Kjnz|l0&TOk^);=xk0 zB=4qG)~v{%)!Z}sLjZ@<;IHSe{RH4l6Lu#$DOtmVjfyT_e)TvLt&Hrp!Rp!o+VFbo z`dJVNZ+diAtw;S208gYLCKjVn*-N0arPV&tYg@#T-s)a+e4u~+=jD4#uUA%9z_&f) zQQc3&lN-)X25JKn+3j0&C+YrvQ|aEZMR$_!A2yZl54Pw|(!H>$bPsINouvE6O{IHR zS_$+;QHHgHQtD|XP%A{^pzN>&N}GF2yQR*(6{1VIzc=#lc}?Cg{&V#&P?vvuZf&bc z+nYPmDY$rFMiNJR)CX?>c(KDXlc)+&X<&vpWz=*SMbsgTU=-=QgFTVLvwBu(ye@*Z zEJi6cZ9;8^5J!x)0~=RJT?C6AuEtMnQ=)Dt!_2nYFdNqwT?A`0HJ@|G!br6Ma@1Wr zX5*)G7r|m`Q)(&40u#bj!%~FXFdIMHvb+C|rtweKJGE8UYkeG*`mZ3}U=t70*pk26 zn0ezUf5RbMIXBobIkRnoCZgeMESH{kGY^8}L`fq~j#hBoa?MxkSWDv3qaY$z~#XG(|iMMJ5QPDaW25*?cNa#NjNl zJW_fnyaw>5H|LUwZ^d#`G!l0*yBe*t5+nC>gnIlW9CNeI42gSGv=X!ZNvGdq&&9M8 zw>$=ciaFXJC$^Wh(nw@DsAH$>WYkWkY(hm<)iqT!B}GwGWE!%pNqpB-XO5CA8UoB) zPFUxl)BtYUv6I=HT`(FMb4j-NR(^qlOP~TwM+lp;f)O&zeIOI5A66Ue98>$n%#dWGeW#FU)}&14FbJtqD&SWrYkvV4p|zeMDPi;%)!AR z_)Grt9|6o%V7G!w^*QV2(?;3?QusE>hJ%y$S}9>bF`WV!CZEGnK+^YIi3^ zy&|Kj5LE4|Cd!O%vDyXoy(-gzeXp80B;SvW(k~V}`NoU2btZSL= zOB%n)!B+q-ws^uKg`Pu2T~aVc8XFZgRbn%pj16DPd)s_5$8A+PKSZ3eB59hTsv1&M zRn`qf#&tMx5obvZ!^gh(liHQN_Txf&N}~!DRza}7RcoYN!}pp#+V}ZHT;qZ*AoUA8UG(|#Jje#P4dCcrruLfk4P#Aa zJDrMDO_x&=Bv9X0V_TQxQf-82>UX~0x9O~>Q@U_RPB?FgU#3+H3TaR2}S literal 0 HcmV?d00001 From a386bed8aa3c4f1a8b29a7dafcc7480ea75060a4 Mon Sep 17 00:00:00 2001 From: Serob Nahapetyan Date: Tue, 31 Mar 2026 12:12:07 +0200 Subject: [PATCH 07/86] fix: fix data part conversions (#689) * fix data part conversions --- server/adka2a/parts.go | 44 +++++++++++++++++++++++-------------- server/adka2a/parts_test.go | 17 +++++++------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/server/adka2a/parts.go b/server/adka2a/parts.go index 639c64400..312b93e9a 100644 --- a/server/adka2a/parts.go +++ b/server/adka2a/parts.go @@ -15,6 +15,7 @@ package adka2a import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -60,6 +61,18 @@ func IsPartialFlagSet(meta map[string]any) bool { return isSet } +func validateDataPartJSON(d *genai.Part) ([]byte, bool) { + if d.InlineData == nil || d.InlineData.MIMEType != "text/plain" { + return nil, false + } + if noPrefix, ok := bytes.CutPrefix(d.InlineData.Data, []byte("")); ok { + if result, ok := bytes.CutSuffix(noPrefix, []byte("")); ok { + return result, true + } + } + return nil, false +} + // ToA2APart converts the provided genai part to A2A equivalent. Long running tool IDs are used for attaching metadata to // the relevant data parts. func ToA2APart(part *genai.Part, longRunningToolIDs []string) (a2a.Part, error) { @@ -81,6 +94,12 @@ func ToA2AParts(parts []*genai.Part, longRunningToolIDs []string) ([]a2a.Part, e r.Metadata = map[string]any{ToA2AMetaKey("thought"): true} } result[i] = r + } else if jsonBytes, ok := validateDataPartJSON(part); ok { + var data map[string]any + if err := json.Unmarshal(jsonBytes, &data); err != nil { + return nil, err + } + result[i] = a2a.DataPart{Data: data} } else if part.InlineData != nil || part.FileData != nil { r, err := toA2AFilePart(part) if err != nil { @@ -308,13 +327,7 @@ func toGenAIFilePart(part a2a.FilePart) (*genai.Part, error) { } func toGenAIDataPart(part a2a.DataPart) (*genai.Part, error) { - if part.Metadata == nil { - return toGenAITextPart(part) - } - adkMetaType, ok := part.Metadata[a2aDataPartMetaTypeKey] - if !ok { - return toGenAITextPart(part) - } + adkMetaType := part.Metadata[a2aDataPartMetaTypeKey] bytes, err := json.Marshal(part.Data) if err != nil { @@ -351,14 +364,13 @@ func toGenAIDataPart(part a2a.DataPart) (*genai.Part, error) { return &genai.Part{FunctionResponse: &val}, nil default: - return &genai.Part{Text: string(bytes)}, nil - } -} - -func toGenAITextPart(part a2a.DataPart) (*genai.Part, error) { - bytes, err := json.Marshal(part.Data) - if err != nil { - return nil, err + var jsonData []byte + prefix, suffix := []byte(""), []byte("") + jsonData = make([]byte, 0, len(prefix)+len(bytes)+len(suffix)) + jsonData = append(jsonData, prefix...) + jsonData = append(jsonData, bytes...) + jsonData = append(jsonData, suffix...) + + return &genai.Part{InlineData: &genai.Blob{Data: jsonData, MIMEType: "text/plain"}}, nil } - return &genai.Part{Text: string(bytes)}, nil } diff --git a/server/adka2a/parts_test.go b/server/adka2a/parts_test.go index 91a0d4fb0..c1f705443 100644 --- a/server/adka2a/parts_test.go +++ b/server/adka2a/parts_test.go @@ -169,24 +169,23 @@ func TestPartsTwoWayConversion(t *testing.T) { } } -func TestPartsOneWayConversion(t *testing.T) { - part := a2a.DataPart{Data: map[string]any{"arbitrary": "data"}} - wantGenAI := &genai.Part{Text: `{"arbitrary":"data"}`} +func TestPartsDataPartConversionRoundTrip(t *testing.T) { + a2aPart := a2a.DataPart{Data: map[string]any{"arbitrary": "data"}} + wantGenAI := &genai.Part{InlineData: &genai.Blob{Data: []byte("{\"arbitrary\":\"data\"}"), MIMEType: "text/plain"}} - gotGenAI, err := ToGenAIParts([]a2a.Part{part}) + gotGenAI, err := ToGenAIParts([]a2a.Part{a2aPart}) if err != nil { t.Fatalf("toGenAI() error = %v, want nil", err) } if diff := cmp.Diff([]*genai.Part{wantGenAI}, gotGenAI); diff != "" { - t.Fatalf("toGenAI() wrong result (+got,-want)\ngot = %v\nwant = %v\ndiff = %s", gotGenAI, part, diff) + t.Fatalf("toGenAI() wrong result (+got,-want)\ngot = %v\nwant = %v\ndiff = %s", gotGenAI, a2aPart, diff) } - wantA2A := a2a.TextPart{Text: `{"arbitrary":"data"}`} - gotA2A, err := ToA2AParts(gotGenAI, nil) + gotbackA2A, err := ToA2AParts(gotGenAI, nil) if err != nil { t.Fatalf("toA2AParts() error = %v, want nil", err) } - if diff := cmp.Diff([]a2a.Part{wantA2A}, gotA2A); diff != "" { - t.Fatalf("toA2AParts() wrong result (+got,-want)\ngot = %v\nwant = %v\ndiff = %s", gotA2A, wantA2A, diff) + if diff := cmp.Diff([]a2a.Part{a2aPart}, gotbackA2A); diff != "" { + t.Fatalf("toA2AParts() wrong result (+got,-want)\ngot = %v\nwant = %v\ndiff = %s", gotbackA2A, a2aPart, diff) } } From 6eb2fa989828f9e88dacbe65bfb3eb08bfa4193c Mon Sep 17 00:00:00 2001 From: Serob Nahapetyan Date: Tue, 31 Mar 2026 18:53:32 +0200 Subject: [PATCH 08/86] fix: correct json envelope for datapart (#695) --- server/adka2a/parts.go | 6 +++--- server/adka2a/parts_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/adka2a/parts.go b/server/adka2a/parts.go index 312b93e9a..c693bceb3 100644 --- a/server/adka2a/parts.go +++ b/server/adka2a/parts.go @@ -65,8 +65,8 @@ func validateDataPartJSON(d *genai.Part) ([]byte, bool) { if d.InlineData == nil || d.InlineData.MIMEType != "text/plain" { return nil, false } - if noPrefix, ok := bytes.CutPrefix(d.InlineData.Data, []byte("")); ok { - if result, ok := bytes.CutSuffix(noPrefix, []byte("")); ok { + if noPrefix, ok := bytes.CutPrefix(d.InlineData.Data, []byte("")); ok { + if result, ok := bytes.CutSuffix(noPrefix, []byte("")); ok { return result, true } } @@ -365,7 +365,7 @@ func toGenAIDataPart(part a2a.DataPart) (*genai.Part, error) { default: var jsonData []byte - prefix, suffix := []byte(""), []byte("") + prefix, suffix := []byte(""), []byte("") jsonData = make([]byte, 0, len(prefix)+len(bytes)+len(suffix)) jsonData = append(jsonData, prefix...) jsonData = append(jsonData, bytes...) diff --git a/server/adka2a/parts_test.go b/server/adka2a/parts_test.go index c1f705443..6479f0296 100644 --- a/server/adka2a/parts_test.go +++ b/server/adka2a/parts_test.go @@ -171,7 +171,7 @@ func TestPartsTwoWayConversion(t *testing.T) { func TestPartsDataPartConversionRoundTrip(t *testing.T) { a2aPart := a2a.DataPart{Data: map[string]any{"arbitrary": "data"}} - wantGenAI := &genai.Part{InlineData: &genai.Blob{Data: []byte("{\"arbitrary\":\"data\"}"), MIMEType: "text/plain"}} + wantGenAI := &genai.Part{InlineData: &genai.Blob{Data: []byte("{\"arbitrary\":\"data\"}"), MIMEType: "text/plain"}} gotGenAI, err := ToGenAIParts([]a2a.Part{a2aPart}) if err != nil { From 5e5e139a2c3bd9d15e0693d61add6c98bce2269a Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Wed, 1 Apr 2026 17:18:58 +0200 Subject: [PATCH 09/86] chore: update a2a-go and fix double cleanup (#698) * update a2a-go and fix double cleanup * tidy --- agent/remoteagent/a2a_e2e_test.go | 8 +++++--- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/agent/remoteagent/a2a_e2e_test.go b/agent/remoteagent/a2a_e2e_test.go index 5ed105f12..4799bd60d 100644 --- a/agent/remoteagent/a2a_e2e_test.go +++ b/agent/remoteagent/a2a_e2e_test.go @@ -375,7 +375,7 @@ func TestA2AMultiHopInputRequired(t *testing.T) { func TestA2ACleanupPropagation(t *testing.T) { // Remote A2A server publishes a submitted task and start generating artifact updates // until it detects a context cancelation - remoteTaskIDChan, remoteCleanupCalledChan := make(chan a2a.TaskID, 1), make(chan struct{}) + remoteTaskIDChan, remoteCleanupCalledChan := make(chan a2a.TaskID, 1), make(chan struct{}, 2) serverB := startA2AServer(&mockA2AExecutor{ executeFn: func(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { remoteTaskIDChan <- reqCtx.TaskID @@ -393,7 +393,7 @@ func TestA2ACleanupPropagation(t *testing.T) { return queue.Write(ctx, finalUpdate) }, cleanupFn: func(ctx context.Context, reqCtx *a2asrv.RequestContext, result a2a.SendMessageResult, cause error) { - close(remoteCleanupCalledChan) + remoteCleanupCalledChan <- struct{}{} }, }) defer serverB.Close() @@ -450,7 +450,9 @@ func TestA2ACleanupPropagation(t *testing.T) { t.Fatalf("type(lastStreamingUpdate) = %T, want *a2a.TaskStatusUpdateEvent", lastStreamingUpdate) } - // Check subagent task got cancelled when the parent task was cancelled + // Check subagent task got cancelled when the parent task was cancelled. + // Reads from channel twice because cleanup gets called both for cancelation and execution. + <-remoteCleanupCalledChan <-remoteCleanupCalledChan remoteTaskID := <-remoteTaskIDChan remoteClient := newA2AClient(t, serverB) diff --git a/go.mod b/go.mod index c9f7737af..ae0075e18 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( cloud.google.com/go v0.123.0 cloud.google.com/go/aiplatform v1.105.0 cloud.google.com/go/storage v1.56.1 - github.com/a2aproject/a2a-go v0.3.10 + github.com/a2aproject/a2a-go v0.3.13 github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/glebarez/sqlite v1.8.0 github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index cf4a5bb74..155b5abda 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= -github.com/a2aproject/a2a-go v0.3.10 h1:oiwxhxe6HlJiYupASW04aHixZeiZq1Y/fha2N1EWJyI= -github.com/a2aproject/a2a-go v0.3.10/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= +github.com/a2aproject/a2a-go v0.3.13 h1:WpIcSHgCySIxD7OQEdV7U7WJc/HL/G2QQj0RJ0YhPi0= +github.com/a2aproject/a2a-go v0.3.13/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= From 090cbe582b2cf3de0dc0993511f81ecd111f57f8 Mon Sep 17 00:00:00 2001 From: Artsiom Shut <63152812+foxfrikses@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:26:18 +0200 Subject: [PATCH 10/86] feat: Introduce skill package, define frontmatter and helper functions. (#693) Define frontmatter per https://agentskills.io/specification#frontmatter. Implement helper functions for: - parsing frontmatter and instructions out from SKILL.md file content, - validating frontmatter struct, - building SKILL.md file content from frontmatter and instructions. --- tool/skilltoolset/skill/frontmatter.go | 151 +++++++++ tool/skilltoolset/skill/frontmatter_test.go | 358 ++++++++++++++++++++ 2 files changed, 509 insertions(+) create mode 100644 tool/skilltoolset/skill/frontmatter.go create mode 100644 tool/skilltoolset/skill/frontmatter_test.go diff --git a/tool/skilltoolset/skill/frontmatter.go b/tool/skilltoolset/skill/frontmatter.go new file mode 100644 index 000000000..2af18c4cc --- /dev/null +++ b/tool/skilltoolset/skill/frontmatter.go @@ -0,0 +1,151 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package skill provides structs and functions for working with agent skills. +package skill + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "slices" + "strings" + + "gopkg.in/yaml.v3" +) + +var ( + frontmatterSeparator = []byte("---\n") + frontmatterSeparatorWin = []byte("---\r\n") +) + +// Frontmatter represents the YAML metadata at the top of a SKILL.md file. +// For more details, see https://agentskills.io/specification#frontmatter. +type Frontmatter struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + License string `yaml:"license,omitempty"` + Compatibility string `yaml:"compatibility,omitempty"` + Metadata map[string]string `yaml:"metadata,omitempty"` + AllowedTools []string `yaml:"allowed-tools,omitempty"` +} + +// Parse reads and validates YAML frontmatter from a SKILL.md reader. +// On success, the reader will contain the remaining Markdown instruction. +// Use when reading the entire file into memory is expensive. +func Parse(r *bufio.Reader) (*Frontmatter, error) { + line, err := r.ReadBytes('\n') + if err != nil { + return nil, fmt.Errorf("reading first line: %w", err) + } + if !bytes.Equal(line, frontmatterSeparator) && !bytes.Equal(line, frontmatterSeparatorWin) { + return nil, fmt.Errorf("invalid frontmatter separator line: must be '---' followed by a new line") + } + + yamlBytes := bytes.Buffer{} + for { + line, err := r.ReadBytes('\n') + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("missing closing frontmatter separator line: must be '---' followed by a new line") + } + if err != nil { + return nil, fmt.Errorf("read a line of frontmatter: %w", err) + } + if bytes.Equal(line, frontmatterSeparator) || bytes.Equal(line, frontmatterSeparatorWin) { + break + } + yamlBytes.Write(line) + } + + fm := Frontmatter{} + decoder := yaml.NewDecoder(bytes.NewReader(yamlBytes.Bytes())) + decoder.KnownFields(true) + if err := decoder.Decode(&fm); err != nil { + return nil, fmt.Errorf("parse frontmatter: %w", err) + } + if err := Validate(&fm); err != nil { + return nil, fmt.Errorf("invalid frontmatter: %w", err) + } + return &fm, nil +} + +// ParseBytes splits and validates YAML frontmatter from SKILL.md content bytes. +// It returns the Frontmatter and the Markdown instruction string. +// Use when the entire file content is in memory. +func ParseBytes(content []byte) (*Frontmatter, string, error) { + reader := bufio.NewReader(bytes.NewReader(content)) + frontmatter, err := Parse(reader) + if err != nil { + return nil, "", fmt.Errorf("parse frontmatter: %w", err) + } + markdown, err := io.ReadAll(reader) + if err != nil { + return nil, "", fmt.Errorf("read markdown: %w", err) + } + return frontmatter, string(markdown), nil +} + +// Validate checks if the Frontmatter struct is valid according to the +// specification. It enforces field lengths, formats, required fields, etc. +// +// Note: "skill name must match the parent directory name" check is omitted from +// this function as the Frontmatter struct lacks the file path context. +func Validate(fm *Frontmatter) error { + if fm == nil { + return fmt.Errorf("frontmatter cannot be nil") + } + + if len(fm.Name) < 1 || len(fm.Name) > 64 { + return fmt.Errorf("name must be between 1 and 64 characters long") + } + if strings.HasPrefix(fm.Name, "-") || strings.HasSuffix(fm.Name, "-") { + return fmt.Errorf("name must not start or end with a hyphen") + } + if strings.Contains(fm.Name, "--") { + return fmt.Errorf("name must not contain consecutive hyphens") + } + for _, ch := range fm.Name { + isLowerAlpha := ch >= 'a' && ch <= 'z' + isNumeric := ch >= '0' && ch <= '9' + isHyphen := ch == '-' + if !isLowerAlpha && !isNumeric && !isHyphen { + return fmt.Errorf("name may only contain lowercase alphanumeric characters (a-z, 0-9) and hyphens") + } + } + + if len(fm.Description) < 1 || len(fm.Description) > 1024 { + return fmt.Errorf("description must be between 1 and 1024 characters long") + } + + if len(fm.Compatibility) > 500 { + return fmt.Errorf("compatibility must not exceed 500 characters") + } + + return nil +} + +// Build creates the full SKILL.md file content from a Frontmatter struct and a +// Markdown instruction body. It validates the Frontmatter before building. +func Build(fm *Frontmatter, markdown string) ([]byte, error) { + if err := Validate(fm); err != nil { + return nil, fmt.Errorf("invalid frontmatter: %v", err) + } + marshalled, err := yaml.Marshal(fm) + if err != nil { + return nil, fmt.Errorf("marshal frontmatter: %v", err) + } + return slices.Concat(frontmatterSeparator, marshalled, frontmatterSeparator, []byte(markdown)), nil +} diff --git a/tool/skilltoolset/skill/frontmatter_test.go b/tool/skilltoolset/skill/frontmatter_test.go new file mode 100644 index 000000000..e16f69135 --- /dev/null +++ b/tool/skilltoolset/skill/frontmatter_test.go @@ -0,0 +1,358 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skill + +import ( + "bufio" + "errors" + "fmt" + "io" + "reflect" + "strings" + "testing" + "testing/iotest" +) + +func TestValidate(t *testing.T) { + tests := []struct { + name string + frontmatter *Frontmatter + wantErr bool + }{ + { + name: "valid frontmatter", + frontmatter: &Frontmatter{Name: "valid-name", Description: "A valid description."}, + }, + { + name: "nil frontmatter", + frontmatter: nil, + wantErr: true, + }, + { + name: "name too short", + frontmatter: &Frontmatter{Name: "", Description: "Valid description."}, + wantErr: true, + }, + { + name: "name too long", + frontmatter: &Frontmatter{Name: strings.Repeat("a", 65), Description: "Valid description."}, + wantErr: true, + }, + { + name: "name starts with hyphen", + frontmatter: &Frontmatter{Name: "-name", Description: "Valid description."}, + wantErr: true, + }, + { + name: "name ends with hyphen", + frontmatter: &Frontmatter{Name: "name-", Description: "Valid description."}, + wantErr: true, + }, + { + name: "name has consecutive hyphens", + frontmatter: &Frontmatter{Name: "na--me", Description: "Valid description."}, + wantErr: true, + }, + { + name: "name contains uppercase letters", + frontmatter: &Frontmatter{Name: "Invalid-Name", Description: "Valid description."}, + wantErr: true, + }, + { + name: "description too short", + frontmatter: &Frontmatter{Name: "valid-name", Description: ""}, + wantErr: true, + }, + { + name: "description too long", + frontmatter: &Frontmatter{Name: "valid-name", Description: strings.Repeat("a", 1025)}, + wantErr: true, + }, + { + name: "compatibility too long", + frontmatter: &Frontmatter{Name: "valid-name", Description: "Valid.", Compatibility: strings.Repeat("a", 501)}, + wantErr: true, + }, + } + + for _, testcase := range tests { + t.Run(testcase.name, func(t *testing.T) { + err := Validate(testcase.frontmatter) + if got, want := (err != nil), testcase.wantErr; got != want { + t.Fatalf("expected error %v, got %v", want, got) + } + }) + } +} + +func TestParse_Valid(t *testing.T) { + tests := []struct { + name string + input string + want *Frontmatter + wantInstruction string + }{ + { + name: "valid minimal frontmatter", + input: "---\nname: skill\ndescription: skill\n---\nMarkdown Body", + want: &Frontmatter{ + Name: "skill", + Description: "skill", + }, + wantInstruction: "Markdown Body", + }, + { + name: "valid minimal frontmatter with windows line endings", + input: "---\r\nname: skill\r\ndescription: skill\r\n---\r\nMarkdown Body", + want: &Frontmatter{ + Name: "skill", + Description: "skill", + }, + wantInstruction: "Markdown Body", + }, + { + name: "valid full frontmatter", + input: `--- +name: my-cool-skill +description: A cool skill. +metadata: + author: "Cool Author" + version: "1.0.0" +allowed-tools: + - tool1 + - tool2 +compatibility: "compatible indeed" +license: "yes" +--- + +# Long + +Multi-line +Markdown +Body + +`, + want: &Frontmatter{ + Name: "my-cool-skill", + Description: "A cool skill.", + Metadata: map[string]string{ + "author": "Cool Author", + "version": "1.0.0", + }, + AllowedTools: []string{"tool1", "tool2"}, + Compatibility: "compatible indeed", + License: "yes", + }, + wantInstruction: "\n# Long\n\nMulti-line\nMarkdown\nBody\n\n", + }, + } + + for _, testcase := range tests { + t.Run(testcase.name, func(t *testing.T) { + t.Parallel() + reader := bufio.NewReader(strings.NewReader(testcase.input)) + got, err := Parse(reader) + if err != nil { + t.Fatalf("Parse: expected no error, got: %v", err) + } + + if !reflect.DeepEqual(got, testcase.want) { + t.Errorf("Parse: got %v, want %v", got, testcase.want) + } + bytes, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("io.ReadAll: expected no error, got: %v", err) + } + if string(bytes) != testcase.wantInstruction { + t.Errorf("expected unread instruction %q, got %q", testcase.wantInstruction, string(bytes)) + } + }) + } +} + +func TestParse_InvalidFormat(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "missing opening separator", + input: "name: skill\ndescription: skill\n---\n# Markdown\nBody", + }, + { + name: "missing newline after opening separator", + input: "---name: skill\ndescription: skill\n---\n# Markdown\nBody", + }, + { + name: "whitespace after opening separator", + input: "--- \nname: skill\ndescription: skill\n---\n# Markdown\nBody", + }, + { + name: "missing closing separator", + input: "---\nname: skill\ndescription: skill\n", + }, + { + name: "missing newline after closing separator", + input: "---\nname: skill\ndescription: skill\n---# Markdown\nBody", + }, + { + name: "whitespace after closing separator", + input: "---\nname: skill\ndescription: skill\n--- \n# Markdown\nBody", + }, + { + name: "invalid yaml", + input: "---\n_ : invalid : _\n---\n# Markdown\nBody", + }, + { + name: "fails frontmatter validation", + input: "---\nname: INVALID_NAME\ndescription: test\n---\n# Markdown\nBody", + }, + { + name: "unknown fields", + input: "---\nname: skill\ndescription: skill\nunknown-field: field\n---\n# Markdown\nBody", + }, + } + for _, testcase := range tests { + t.Run(testcase.name, func(t *testing.T) { + t.Parallel() + reader := bufio.NewReader(strings.NewReader(testcase.input)) + + _, err := Parse(reader) + + if err == nil { + t.Errorf("expected error, got nil") + } + }) + } +} + +func TestParse_FailingReader(t *testing.T) { + errImmediate := fmt.Errorf("immediate error") + tests := []struct { + name string + reader io.Reader + wantErr error + }{ + { + name: "immediate error", + reader: iotest.ErrReader(errImmediate), + wantErr: errImmediate, + }, + { + name: "timeout error", + reader: iotest.TimeoutReader(strings.NewReader("---")), + wantErr: iotest.ErrTimeout, + }, + } + for _, testcase := range tests { + t.Run(testcase.name, func(t *testing.T) { + t.Parallel() + _, err := Parse(bufio.NewReader(testcase.reader)) + if !errors.Is(err, testcase.wantErr) { + t.Errorf("Parse: expected error %v, got %v", testcase.wantErr, err) + } + }) + } +} + +func TestParseBytes(t *testing.T) { + input := []byte("---\nname: my-skill\ndescription: A cool skill\n---\n# Markdown Content\nHere.") + wantFrontmatter := &Frontmatter{ + Name: "my-skill", + Description: "A cool skill", + } + wantInstruction := "# Markdown Content\nHere." + + gotFrontmatter, gotInstruction, err := ParseBytes(input) + if err != nil { + t.Fatalf("ParseBytes failed: %v", err) + } + + if !reflect.DeepEqual(gotFrontmatter, wantFrontmatter) { + t.Errorf("ParseBytes: got frontmatter %v, want %v", gotFrontmatter, wantFrontmatter) + } + if gotInstruction != wantInstruction { + t.Errorf("ParseBytes: got instruction %q, want %q", gotInstruction, wantInstruction) + } +} + +func TestParseBytes_Error(t *testing.T) { + input := []byte("---\ninvalid-frontmatter without closing") + _, _, err := ParseBytes(input) + + if err == nil { + t.Fatalf("ParseBytes: expected error for invalid frontmatter bytes, got nil") + } +} + +func TestBuild(t *testing.T) { + frontmatter := &Frontmatter{ + Name: "my-skill", + Description: "A test skill.", + Metadata: map[string]string{ + "author": "Cool Author", + }, + AllowedTools: []string{"tool1", "tool2"}, + } + instruction := "# Instruction\nDo something cool." + + outBytes, err := Build(frontmatter, instruction) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + outStr := string(outBytes) + // We don't rely on strict exact string matching for the entire block. + if !strings.HasPrefix(outStr, "---\n") { + t.Errorf("Build: output must start with frontmatter separator") + } + if !strings.Contains(outStr, "\n---\n") { + t.Errorf("Build: output missing proper closing separator") + } + for _, field := range []string{"name", "description", "metadata", "allowed-tools"} { + if !strings.Contains(outStr, field) { + t.Errorf("Build: output missing %s field", field) + } + } + for _, field := range []string{"compatibility", "license"} { + if strings.Contains(outStr, field) { + t.Errorf("Build: output should not contain %s field", field) + } + } + // Parse the output to verify it's correct. + parsedFrontmatter, parsedInstruction, err := ParseBytes([]byte(outStr)) + if err != nil { + t.Fatalf("ParseBytes: failed to parse frontmatter after Build: %v", err) + } + if !reflect.DeepEqual(parsedFrontmatter, frontmatter) { + t.Errorf("ParseBytes: parsed frontmatter after Build: %v, want %v", parsedFrontmatter, frontmatter) + } + if parsedInstruction != instruction { + t.Errorf("ParseBytes: parsed instruction after Build: %q, want %q", parsedInstruction, instruction) + } +} + +func TestBuild_ValidationError(t *testing.T) { + fm := &Frontmatter{ + Name: "INVALID_NAME!!!", + Description: "A test skill.", + } + + _, err := Build(fm, "Some markdown") + + if err == nil { + t.Fatalf("Build: expected validation error, got nil") + } +} From 0ad77f755e0ab6c769e293b0b34955686c8cf3a3 Mon Sep 17 00:00:00 2001 From: Anastasia Date: Wed, 8 Apr 2026 11:01:01 +0200 Subject: [PATCH 11/86] feat: setup subrouter for pub/sub trigger (#704) Create a new subrouter that will handle the pubsub trigger endpoint and add the implementation for this trigger. --- cmd/launcher/full/full.go | 3 +- cmd/launcher/web/triggers/pubsub/pubsub.go | 136 +++++++++++++ .../web/triggers/pubsub/pubsub_test.go | 97 ++++++++++ server/adkrest/controllers/triggers/config.go | 29 +++ server/adkrest/controllers/triggers/pubsub.go | 101 ++++++++++ .../controllers/triggers/pubsub_test.go | 181 ++++++++++++++++++ .../adkrest/controllers/triggers/triggers.go | 154 +++++++++++++++ server/adkrest/internal/models/triggers.go | 44 +++++ 8 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 cmd/launcher/web/triggers/pubsub/pubsub.go create mode 100644 cmd/launcher/web/triggers/pubsub/pubsub_test.go create mode 100644 server/adkrest/controllers/triggers/config.go create mode 100644 server/adkrest/controllers/triggers/pubsub.go create mode 100644 server/adkrest/controllers/triggers/pubsub_test.go create mode 100644 server/adkrest/controllers/triggers/triggers.go create mode 100644 server/adkrest/internal/models/triggers.go diff --git a/cmd/launcher/full/full.go b/cmd/launcher/full/full.go index 9cf2c92a0..a6f46d3b2 100644 --- a/cmd/launcher/full/full.go +++ b/cmd/launcher/full/full.go @@ -22,10 +22,11 @@ import ( "google.golang.org/adk/cmd/launcher/web" "google.golang.org/adk/cmd/launcher/web/a2a" "google.golang.org/adk/cmd/launcher/web/api" + "google.golang.org/adk/cmd/launcher/web/triggers/pubsub" "google.golang.org/adk/cmd/launcher/web/webui" ) // NewLauncher returnes the most versatile universal launcher with all options built-in. func NewLauncher() launcher.Launcher { - return universal.NewLauncher(console.NewLauncher(), web.NewLauncher(webui.NewLauncher(), a2a.NewLauncher(), api.NewLauncher())) + return universal.NewLauncher(console.NewLauncher(), web.NewLauncher(webui.NewLauncher(), a2a.NewLauncher(), pubsub.NewLauncher(), api.NewLauncher())) } diff --git a/cmd/launcher/web/triggers/pubsub/pubsub.go b/cmd/launcher/web/triggers/pubsub/pubsub.go new file mode 100644 index 000000000..2e77c05b1 --- /dev/null +++ b/cmd/launcher/web/triggers/pubsub/pubsub.go @@ -0,0 +1,136 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package pubsub provides a sublauncher that adds PubSub trigger capabilities to ADK web server. +package pubsub + +import ( + "flag" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gorilla/mux" + + "google.golang.org/adk/cmd/launcher" + "google.golang.org/adk/cmd/launcher/web" + "google.golang.org/adk/internal/cli/util" + "google.golang.org/adk/server/adkrest/controllers/triggers" +) + +type pubsubConfig struct { + pathPrefix string + triggerMaxRetries int + triggerBaseDelay time.Duration + triggerMaxDelay time.Duration + triggerMaxRuns int +} + +type pubsubLauncher struct { + flags *flag.FlagSet + config *pubsubConfig +} + +// NewLauncher creates a new pubsub launcher. It extends Web launcher. +func NewLauncher() web.Sublauncher { + config := &pubsubConfig{} + + fs := flag.NewFlagSet("pubsub", flag.ContinueOnError) + fs.StringVar(&config.pathPrefix, "path_prefix", "/api", "Path prefix for the PubSub trigger endpoint. Default is '/api'.") + fs.IntVar(&config.triggerMaxRetries, "trigger_max_retries", 3, "Maximum retries for HTTP 429 errors from triggers") + fs.DurationVar(&config.triggerBaseDelay, "trigger_base_delay", 1*time.Second, "Base delay for trigger retry exponential backoff") + fs.DurationVar(&config.triggerMaxDelay, "trigger_max_delay", 10*time.Second, "Maximum delay for trigger retry exponential backoff") + fs.IntVar(&config.triggerMaxRuns, "trigger_max_concurrent_runs", 100, "Maximum concurrent trigger runs") + + return &pubsubLauncher{ + config: config, + flags: fs, + } +} + +// Keyword implements web.Sublauncher. Returns the command-line keyword for pubsub launcher. +func (p *pubsubLauncher) Keyword() string { + return "pubsub" +} + +// Parse parses the command-line arguments for the pubsub launcher. +func (p *pubsubLauncher) Parse(args []string) ([]string, error) { + err := p.flags.Parse(args) + if err != nil || !p.flags.Parsed() { + return nil, fmt.Errorf("failed to parse pubsub flags: %v", err) + } + if p.config.triggerMaxRetries <= 0 { + return nil, fmt.Errorf("trigger_max_retries must be > 0") + } + if p.config.triggerBaseDelay < 0 { + return nil, fmt.Errorf("trigger_base_delay must be >= 0") + } + if p.config.triggerMaxDelay <= 0 { + return nil, fmt.Errorf("trigger_max_delay must be > 0") + } + if p.config.triggerMaxRuns <= 0 { + return nil, fmt.Errorf("trigger_max_concurrent_runs must be > 0") + } + + prefix := p.config.pathPrefix + if !strings.HasPrefix(prefix, "/") { + prefix = "/" + prefix + } + p.config.pathPrefix = strings.TrimSuffix(prefix, "/") + + return p.flags.Args(), nil +} + +// CommandLineSyntax returns the command-line syntax for the pubsub launcher. +func (p *pubsubLauncher) CommandLineSyntax() string { + return util.FormatFlagUsage(p.flags) +} + +// SimpleDescription implements web.Sublauncher. +func (p *pubsubLauncher) SimpleDescription() string { + return "starts ADK PubSub trigger endpoint server" +} + +// SetupSubrouters adds the PubSub trigger endpoint to the parent router. +func (p *pubsubLauncher) SetupSubrouters(router *mux.Router, config *launcher.Config) error { + triggerConfig := triggers.TriggerConfig{ + MaxRetries: p.config.triggerMaxRetries, + BaseDelay: p.config.triggerBaseDelay, + MaxDelay: p.config.triggerMaxDelay, + MaxConcurrentRuns: p.config.triggerMaxRuns, + } + + controller := triggers.NewPubSubController( + config.SessionService, + config.AgentLoader, + config.MemoryService, + config.ArtifactService, + config.PluginConfig, + triggerConfig, + ) + + subrouter := router + if p.config.pathPrefix != "" && p.config.pathPrefix != "/" { + subrouter = router.PathPrefix(p.config.pathPrefix).Subrouter() + } + + subrouter.HandleFunc("/apps/{app_name}/trigger/pubsub", controller.PubSubTriggerHandler).Methods(http.MethodPost) + return nil +} + +// UserMessage implements web.Sublauncher. +func (p *pubsubLauncher) UserMessage(webURL string, printer func(v ...any)) { + printer(fmt.Sprintf(" pubsub: PubSub trigger endpoint is available at %s%s/apps/{app_name}/trigger/pubsub", webURL, p.config.pathPrefix)) +} diff --git a/cmd/launcher/web/triggers/pubsub/pubsub_test.go b/cmd/launcher/web/triggers/pubsub/pubsub_test.go new file mode 100644 index 000000000..79f20098d --- /dev/null +++ b/cmd/launcher/web/triggers/pubsub/pubsub_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pubsub + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + + "google.golang.org/adk/cmd/launcher" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + args []string + wantPrefix string + wantRetry int + wantErr bool + }{ + { + name: "default values", + args: []string{}, + wantPrefix: "/api", + wantRetry: 3, + wantErr: false, + }, + { + name: "custom prefix and retries", + args: []string{"-path_prefix=/custom", "-trigger_max_retries=5"}, + wantPrefix: "/custom", + wantRetry: 5, + wantErr: false, + }, + { + name: "invalid retry count", + args: []string{"-trigger_max_retries=-1"}, + wantPrefix: "/api", + wantRetry: 3, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := NewLauncher().(*pubsubLauncher) + _, err := l.Parse(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if l.config.pathPrefix != tt.wantPrefix { + t.Errorf("Parse() pathPrefix = %v, want %v", l.config.pathPrefix, tt.wantPrefix) + } + if l.config.triggerMaxRetries != tt.wantRetry { + t.Errorf("Parse() triggerMaxRetries = %v, want %v", l.config.triggerMaxRetries, tt.wantRetry) + } + }) + } +} + +func TestSetupSubrouters(t *testing.T) { + l := NewLauncher().(*pubsubLauncher) + _, _ = l.Parse([]string{"-path_prefix=/api"}) + + router := mux.NewRouter() + config := &launcher.Config{} + + err := l.SetupSubrouters(router, config) + if err != nil { + t.Fatalf("SetupSubrouters() failed: %v", err) + } + + // Verify route is registered + req := httptest.NewRequest(http.MethodPost, "/api/apps/my-app/trigger/pubsub", nil) + var match mux.RouteMatch + if !router.Match(req, &match) { + t.Errorf("SetupSubrouters() did not register expected route") + } +} diff --git a/server/adkrest/controllers/triggers/config.go b/server/adkrest/controllers/triggers/config.go new file mode 100644 index 000000000..3fae42b78 --- /dev/null +++ b/server/adkrest/controllers/triggers/config.go @@ -0,0 +1,29 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package triggers + +import "time" + +// TriggerConfig contains configuration options for triggers. +type TriggerConfig struct { + // MaxRetries is the maximum number of times to retry a failed agent execution. + MaxRetries int + // BaseDelay is the base delay between retries. + BaseDelay time.Duration + // MaxDelay is the maximum delay between retries. + MaxDelay time.Duration + // MaxConcurrentRuns is the maximum number of concurrent runs. + MaxConcurrentRuns int +} diff --git a/server/adkrest/controllers/triggers/pubsub.go b/server/adkrest/controllers/triggers/pubsub.go new file mode 100644 index 000000000..fea0912ca --- /dev/null +++ b/server/adkrest/controllers/triggers/pubsub.go @@ -0,0 +1,101 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package triggers + +import ( + "encoding/json" + "fmt" + "net/http" + + "google.golang.org/adk/agent" + "google.golang.org/adk/artifact" + "google.golang.org/adk/memory" + "google.golang.org/adk/runner" + "google.golang.org/adk/server/adkrest/internal/models" + "google.golang.org/adk/session" +) + +const defaultUserID = "pubsub-caller" + +// PubSubController handles the PubSub trigger endpoints. +type PubSubController struct { + runner *RetriableRunner + semaphore chan struct{} +} + +// NewPubSubController creates a new PubSubController. +func NewPubSubController(sessionService session.Service, agentLoader agent.Loader, memoryService memory.Service, artifactService artifact.Service, pluginConfig runner.PluginConfig, triggerConfig TriggerConfig) *PubSubController { + return &PubSubController{ + runner: &RetriableRunner{ + sessionService: sessionService, + agentLoader: agentLoader, + memoryService: memoryService, + artifactService: artifactService, + pluginConfig: pluginConfig, + triggerConfig: triggerConfig, + }, + semaphore: make(chan struct{}, triggerConfig.MaxConcurrentRuns), + } +} + +// PubSubTriggerHandler handles the PubSub trigger endpoint. +func (c *PubSubController) PubSubTriggerHandler(w http.ResponseWriter, r *http.Request) { + if c.semaphore != nil { + c.semaphore <- struct{}{} + defer func() { <-c.semaphore }() + } + + // Parse the request to the request model. + var req models.PubSubTriggerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("failed to decode request: %v", err)) + return + } + + // Decode base64 message data. + messageContent := make(map[string]any) + if len(req.Message.Data) > 0 { + // Avoids encoding the data twice later with json.Marshal. + messageContent["data"] = string(req.Message.Data) + } + // Add attributes to the messageContent if present + if len(req.Message.Attributes) > 0 { + messageContent["attributes"] = req.Message.Attributes + } + + if len(messageContent) == 0 { + respondError(w, http.StatusBadRequest, "empty message data and attributes") + return + } + + agentMessage, err := json.Marshal(messageContent) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to marshal agent message: %v", err)) + return + } + + appName, err := appName(r) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + + if _, err := c.runner.RunAgent(r.Context(), appName, req.Subscription, string(agentMessage)); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to run agent: %v", err)) + return + } + + respondSuccess(w) +} diff --git a/server/adkrest/controllers/triggers/pubsub_test.go b/server/adkrest/controllers/triggers/pubsub_test.go new file mode 100644 index 000000000..a7539389e --- /dev/null +++ b/server/adkrest/controllers/triggers/pubsub_test.go @@ -0,0 +1,181 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package triggers_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "iter" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" + + "google.golang.org/adk/agent" + + "google.golang.org/adk/runner" + "google.golang.org/adk/server/adkrest/controllers/triggers" + "google.golang.org/adk/server/adkrest/internal/fakes" + "google.golang.org/adk/server/adkrest/internal/models" + "google.golang.org/adk/session" +) + +var defaultTriggerConfig = triggers.TriggerConfig{ + MaxConcurrentRuns: 10, + MaxRetries: 3, + BaseDelay: 1 * time.Millisecond, + MaxDelay: 5 * time.Millisecond, +} + +func TestPubSubTriggerHandler(t *testing.T) { + tests := []struct { + name string + mockAgentResults []error + expectedCode int + expectedRunCount int + requestAttributes map[string]string + expectedAttributes map[string]string + requestData string + }{ + { + name: "Success_Immediate", + mockAgentResults: nil, + expectedCode: http.StatusOK, + expectedRunCount: 1, + requestData: "Hello agent", + }, + { + name: "ResourceExhaustedRetry", + mockAgentResults: []error{fmt.Errorf("429 ResourceExhausted"), fmt.Errorf("429 ResourceExhausted")}, + expectedCode: http.StatusOK, + expectedRunCount: 3, + requestData: "Hello agent", + }, + { + name: "With_Attributes", + mockAgentResults: nil, + expectedCode: http.StatusOK, + expectedRunCount: 1, + requestAttributes: map[string]string{"key1": "val1", "key2": "val2"}, + expectedAttributes: map[string]string{"key1": "val1", "key2": "val2"}, + requestData: "Hello agent", + }, + { + name: "Empty Data", + mockAgentResults: nil, + expectedCode: http.StatusBadRequest, + expectedRunCount: 0, + requestData: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mockAgentRunCount := 0 + testAgent := createMockAgent(t, tc.mockAgentResults, &mockAgentRunCount, tc.expectedAttributes) + + apiController := setupTest(t, testAgent) + + reqObj := models.PubSubTriggerRequest{ + Message: models.PubSubMessage{ + Data: []byte(base64.StdEncoding.EncodeToString([]byte(tc.requestData))), + Attributes: tc.requestAttributes, + }, + Subscription: "test-sub", + } + reqBytes, err := json.Marshal(reqObj) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, "/apps/test-agent/triggers/pubsub", bytes.NewBuffer(reqBytes)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req = mux.SetURLVars(req, map[string]string{"app_name": "test-agent"}) + rr := httptest.NewRecorder() + + apiController.PubSubTriggerHandler(rr, req) + + if rr.Code != tc.expectedCode { + t.Errorf("expected status %d, got %d. Body: %s", tc.expectedCode, rr.Code, rr.Body.String()) + } + + if mockAgentRunCount != tc.expectedRunCount { + t.Errorf("expected %d run attempts, got %d", tc.expectedRunCount, mockAgentRunCount) + } + }) + } +} + +func setupTest(t *testing.T, a agent.Agent) *triggers.PubSubController { + t.Helper() + sessionService := &fakes.FakeSessionService{Sessions: make(map[fakes.SessionKey]fakes.TestSession)} + agentLoader := agent.NewSingleLoader(a) + return triggers.NewPubSubController(sessionService, agentLoader, nil, nil, runner.PluginConfig{}, defaultTriggerConfig) +} + +func createMockAgent(t *testing.T, results []error, runCount *int, expectedAttributes map[string]string) agent.Agent { + t.Helper() + testAgent, err := agent.New(agent.Config{ + Name: "test-agent", + Run: func(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + *runCount++ + + userContent := ctx.UserContent() + if len(expectedAttributes) > 0 { + if userContent == nil || len(userContent.Parts) == 0 { + t.Errorf("expected user content but got none") + } else { + var msgMap map[string]any + err := json.Unmarshal([]byte(userContent.Parts[0].Text), &msgMap) + if err != nil { + t.Errorf("failed to unmarshal message content: %v", err) + } else { + gotAttrs, ok := msgMap["attributes"].(map[string]any) + if !ok { + t.Errorf("expected attributes map, got %T", msgMap["attributes"]) + } else { + for k, v := range expectedAttributes { + if gotAttrs[k] != v { + t.Errorf("expected attribute %s=%s, got %s", k, v, gotAttrs[k]) + } + } + } + } + } + } + + if *runCount <= len(results) { + err := results[*runCount-1] + if err != nil { + yield(nil, err) + return + } + } + yield(&session.Event{ID: "success-event"}, nil) + } + }, + }) + if err != nil { + t.Fatalf("agent.New failed: %v", err) + } + return testAgent +} diff --git a/server/adkrest/controllers/triggers/triggers.go b/server/adkrest/controllers/triggers/triggers.go new file mode 100644 index 000000000..6bf6e9aca --- /dev/null +++ b/server/adkrest/controllers/triggers/triggers.go @@ -0,0 +1,154 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package triggers + +import ( + "context" + "fmt" + "math" + "math/rand" + "net/http" + "strings" + "time" + + "github.com/gorilla/mux" + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/artifact" + "google.golang.org/adk/memory" + "google.golang.org/adk/runner" + "google.golang.org/adk/server/adkrest/controllers" + "google.golang.org/adk/server/adkrest/internal/models" + "google.golang.org/adk/session" +) + +type RetriableRunner struct { + sessionService session.Service + agentLoader agent.Loader + memoryService memory.Service + artifactService artifact.Service + pluginConfig runner.PluginConfig + triggerConfig TriggerConfig +} + +func (r *RetriableRunner) RunAgent(ctx context.Context, appName, userID, messageContent string) ([]*session.Event, error) { + if userID == "" { + userID = defaultUserID + } + + // Each retry = new session + sessReq := &session.CreateRequest{ + AppName: appName, + UserID: userID, + } + sessResp, err := r.sessionService.Create(ctx, sessReq) + if err != nil { + return nil, fmt.Errorf("failed to create session: %v", err) + } + + userMessage := genai.Content{ + Role: "user", + Parts: []*genai.Part{ + {Text: messageContent}, + }, + } + + curAgent, err := r.agentLoader.LoadAgent(appName) + if err != nil { + return nil, fmt.Errorf("failed to load agent: %v", err) + } + + runR, err := runner.New(runner.Config{ + AppName: appName, + Agent: curAgent, + SessionService: r.sessionService, + MemoryService: r.memoryService, + ArtifactService: r.artifactService, + PluginConfig: r.pluginConfig, + }) + if err != nil { + return nil, fmt.Errorf("failed to create runner: %v", err) + } + + return r.runAgentWithRetry(ctx, runR, sessResp.Session.UserID(), sessResp.Session.ID(), &userMessage) +} + +// runAgentWithRetry uses exponential backoff with jitter to handle 429 rate-limit errors. +// After MaxRetries is exhausted, raises an error to signal the upstream service (Pub/Sub, Eventarc) to retry at a higher level. +func (r *RetriableRunner) runAgentWithRetry(ctx context.Context, runR *runner.Runner, userID, sessionID string, userMessage *genai.Content) ([]*session.Event, error) { + var runErr error + events := []*session.Event{} + for i := 0; i <= r.triggerConfig.MaxRetries; i++ { + resp := runR.Run(ctx, userID, sessionID, userMessage, agent.RunConfig{StreamingMode: agent.StreamingModeNone}) + + isThrottled := false + for event, err := range resp { + if err != nil { + runErr = err + if isResourceExhausted(err) { + isThrottled = true + } + break + } + events = append(events, event) + } + + if !isThrottled && runErr == nil { + return events, nil // Success + } + + if i < r.triggerConfig.MaxRetries && isThrottled { + delay := calculateBackoff(i, r.triggerConfig.BaseDelay, r.triggerConfig.MaxDelay) + time.Sleep(delay) + runErr = nil // Clear error for next attempt + continue + } + break // Not throttled (but error raised) or max retries reached + } + return nil, runErr +} + +func respondError(w http.ResponseWriter, code int, msg string) { + resp := models.TriggerResponse{Status: msg} + controllers.EncodeJSONResponse(resp, code, w) +} + +func respondSuccess(w http.ResponseWriter) { + resp := models.TriggerResponse{Status: "success"} + controllers.EncodeJSONResponse(resp, http.StatusOK, w) +} + +// Check if an exception represents a transient rate-limit error. +func isResourceExhausted(err error) bool { + return err != nil && (strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "ResourceExhausted")) +} + +func calculateBackoff(attempt int, base, maxDelay time.Duration) time.Duration { + backoff := float64(base) * math.Pow(2, float64(attempt)) + delay := min(time.Duration(backoff), maxDelay) + jitter := time.Duration(rand.Float64() * float64(delay) * 0.5) + return delay + jitter +} + +// Resolve the target app name from the request. +func appName(r *http.Request) (string, error) { + vars := mux.Vars(r) + appName := vars["app_name"] + if appName == "" { + return "", fmt.Errorf("no application name provided") + } + return appName, nil +} diff --git a/server/adkrest/internal/models/triggers.go b/server/adkrest/internal/models/triggers.go new file mode 100644 index 000000000..08e6e5a15 --- /dev/null +++ b/server/adkrest/internal/models/triggers.go @@ -0,0 +1,44 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +// PubSubTriggerRequest represents the request for the PubSub trigger. +// See: https://cloud.google.com/pubsub/docs/push#receive_push +type PubSubTriggerRequest struct { + // The Pub/Sub message. + Message PubSubMessage `json:"message"` + // The subscription this message was published to. + Subscription string `json:"subscription"` +} + +// PubSubMessage represents the message for the PubSub trigger. +type PubSubMessage struct { + // The message payload. This will always be a base64-encoded string. + Data []byte `json:"data"` + // ID of this message, assigned by the Pub/Sub server. + MessageID string `json:"messageId"` + // The time at which the message was published, populated by the server. + PublishTime string `json:"publishTime"` + // Optional attributes for this message. An object containing a list of 'key': 'value' string pairs. + Attributes map[string]string `json:"attributes,omitempty"` + // If message ordering is enabled, this identifies related messages for which publish order should be respected. + OrderingKey string `json:"orderingKey,omitempty"` +} + +// TriggerResponse represents the standard response for Pub/Sub and Eventarc triggers. +type TriggerResponse struct { + // Processing status: 'success' or error message. + Status string `json:"status"` +} From 4c91d1253e19a42c8173cba345b8ef3b4390bfdb Mon Sep 17 00:00:00 2001 From: Anastasia Date: Wed, 8 Apr 2026 13:50:17 +0200 Subject: [PATCH 12/86] feat: update cloudrun deployment script to include pubsub (#712) update cloudrun deployment script to include pubsub trigger --- .../internal/deploy/cloudrun/cloudrun.go | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go b/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go index 1e7c2de47..108812fd3 100644 --- a/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go +++ b/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go @@ -36,6 +36,13 @@ type gCloudFlags struct { projectName string } +type triggerConfigFlags struct { + maxRetries int + baseDelay time.Duration + maxDelay time.Duration + maxRuns int +} + type cloudRunServiceFlags struct { serviceName string serverPort int @@ -43,6 +50,8 @@ type cloudRunServiceFlags struct { a2a bool // enable a2a or not api bool // enable api or not webui bool // enable webui or not + pubsub bool // enable pubsub trigger or not + pubsubTrigger triggerConfigFlags } type localProxyFlags struct { @@ -99,6 +108,11 @@ func init() { cloudrunCmd.PersistentFlags().StringVarP(&flags.cloudRun.a2aAgentCardURL, "a2a_agent_url", "a", "http://127.0.0.1:8081", "A2A agent card URL as advertised in the public agent card") cloudrunCmd.PersistentFlags().BoolVar(&flags.cloudRun.api, "api", true, "Enable API") cloudrunCmd.PersistentFlags().BoolVar(&flags.cloudRun.webui, "webui", true, "Enable Web UI") + cloudrunCmd.PersistentFlags().BoolVar(&flags.cloudRun.pubsub, "pubsub", false, "Enable PubSub subrouter") + cloudrunCmd.PersistentFlags().IntVar(&flags.cloudRun.pubsubTrigger.maxRetries, "pubsub_max_retries", 3, "Maximum retries for HTTP 429 errors from PubSub triggers") + cloudrunCmd.PersistentFlags().DurationVar(&flags.cloudRun.pubsubTrigger.baseDelay, "pubsub_base_delay", 1*time.Second, "Base delay for PubSub trigger retry exponential backoff") + cloudrunCmd.PersistentFlags().DurationVar(&flags.cloudRun.pubsubTrigger.maxDelay, "pubsub_max_delay", 10*time.Second, "Maximum delay for PubSub trigger retry exponential backoff") + cloudrunCmd.PersistentFlags().IntVar(&flags.cloudRun.pubsubTrigger.maxRuns, "pubsub_max_concurrent_runs", 100, "Maximum concurrent PubSub trigger runs") } // computeFlags uses command line arguments to create a full config @@ -194,9 +208,16 @@ CMD ["/app/` + f.build.execFile + `", "web", "-port", "` + strconv.Itoa(flags.cl b.WriteString(`, "a2a", "--a2a_agent_url", "` + flags.cloudRun.a2aAgentCardURL + `"`) } if flags.cloudRun.webui { - b.WriteString(`, "webui", "--api_server_address", "http://127.0.0.1:` + strconv.Itoa(f.proxy.port) + `/api"] - `) + b.WriteString(`, "webui", "--api_server_address", "http://127.0.0.1:` + strconv.Itoa(f.proxy.port) + `/api"`) + } + if flags.cloudRun.pubsub { + b.WriteString(`, "pubsub"`) + b.WriteString(fmt.Sprintf(`, "--trigger_max_retries", "%d"`, flags.cloudRun.pubsubTrigger.maxRetries)) + b.WriteString(fmt.Sprintf(`, "--trigger_base_delay", "%s"`, flags.cloudRun.pubsubTrigger.baseDelay.String())) + b.WriteString(fmt.Sprintf(`, "--trigger_max_delay", "%s"`, flags.cloudRun.pubsubTrigger.maxDelay.String())) + b.WriteString(fmt.Sprintf(`, "--trigger_max_concurrent_runs", "%d"`, flags.cloudRun.pubsubTrigger.maxRuns)) } + b.WriteString(`]`) return os.WriteFile(f.build.dockerfileBuildPath, []byte(b.String()), 0o600) }) } From 1890cee3c8c15ac7866971da30574968ccd08968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20G=C3=B3rski?= Date: Wed, 8 Apr 2026 17:22:02 +0200 Subject: [PATCH 13/86] =?UTF-8?q?feat:=20add=20telemetry=20attributes=20fo?= =?UTF-8?q?r=20cache=20read=20input=20tokens=20and=20reason=E2=80=A6=20(#7?= =?UTF-8?q?14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add telemetry attributes for cache read input tokens and reasoning tokens * fix: align indentation in telemetry test usage metadata struct fields * refactor: align attribute key variable definitions for better readability --------- Co-authored-by: Dmitry Pasiukevich <20398573+dpasiukevich@users.noreply.github.com> --- internal/telemetry/telemetry.go | 12 ++++++++---- internal/telemetry/telemetry_test.go | 8 ++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index aab356d89..68ccca916 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -42,10 +42,12 @@ const ( ) var ( - gcpVertexAgentToolCallArgsName = attribute.Key("gcp.vertex.agent.tool_call_args") - gcpVertexAgentEventID = attribute.Key("gcp.vertex.agent.event_id") - gcpVertexAgentToolResponseName = attribute.Key("gcp.vertex.agent.tool_response") - gcpVertexAgentInvocationID = attribute.Key("gcp.vertex.agent.invocation_id") + gcpVertexAgentToolCallArgsName = attribute.Key("gcp.vertex.agent.tool_call_args") + gcpVertexAgentEventID = attribute.Key("gcp.vertex.agent.event_id") + gcpVertexAgentToolResponseName = attribute.Key("gcp.vertex.agent.tool_response") + gcpVertexAgentInvocationID = attribute.Key("gcp.vertex.agent.invocation_id") + genAIUsageCacheReadInputTokens = attribute.Key("gen_ai.usage.cache_read.input_tokens") + genAIUsageExperimentalReasoningTokens = attribute.Key("gen_ai.usage.experimental.reasoning_tokens") ) // tracer is the tracer instance for ADK go. @@ -125,6 +127,8 @@ func TraceGenerateContentResult(span trace.Span, params TraceGenerateContentResu span.SetAttributes( semconv.GenAIUsageInputTokens(int(params.Response.UsageMetadata.PromptTokenCount)), semconv.GenAIUsageOutputTokens(int(params.Response.UsageMetadata.CandidatesTokenCount)), + genAIUsageCacheReadInputTokens.Int(int(params.Response.UsageMetadata.CachedContentTokenCount)), + genAIUsageExperimentalReasoningTokens.Int(int(params.Response.UsageMetadata.ThoughtsTokenCount)), ) } } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index d65f77c63..d775a4f3c 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -216,8 +216,10 @@ func TestGenerateContent(t *testing.T) { resultParams: TraceGenerateContentResultParams{ Response: &model.LLMResponse{ UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: 10, - CandidatesTokenCount: 20, + PromptTokenCount: 10, + CandidatesTokenCount: 20, + CachedContentTokenCount: 5, + ThoughtsTokenCount: 15, }, FinishReason: genai.FinishReasonStop, }, @@ -229,6 +231,8 @@ func TestGenerateContent(t *testing.T) { semconv.GenAIRequestModelKey: "test-model", semconv.GenAIUsageInputTokensKey: "10", semconv.GenAIUsageOutputTokensKey: "20", + genAIUsageCacheReadInputTokens: "5", + genAIUsageExperimentalReasoningTokens: "15", semconv.GenAIResponseFinishReasonsKey: "[\"STOP\"]", gcpVertexAgentInvocationID: invocationID, }, From c6136108c9a0e39ec41c35c67ddabdfbabec70ef Mon Sep 17 00:00:00 2001 From: Artsiom Shut <63152812+foxfrikses@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:16:04 +0200 Subject: [PATCH 14/86] feat: Define skill.Source interface and implement file system skill source. (#711) Source interface defines a contract via which skills framework is going to access skills. --- tool/skilltoolset/skill/filesystem_source.go | 228 ++++++++++ .../skill/filesystem_source_test.go | 424 ++++++++++++++++++ tool/skilltoolset/skill/source.go | 61 +++ 3 files changed, 713 insertions(+) create mode 100644 tool/skilltoolset/skill/filesystem_source.go create mode 100644 tool/skilltoolset/skill/filesystem_source_test.go create mode 100644 tool/skilltoolset/skill/source.go diff --git a/tool/skilltoolset/skill/filesystem_source.go b/tool/skilltoolset/skill/filesystem_source.go new file mode 100644 index 000000000..77341a0bb --- /dev/null +++ b/tool/skilltoolset/skill/filesystem_source.go @@ -0,0 +1,228 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skill + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "io/fs" + "path" + "strings" +) + +// NewFileSystemSource creates a Source implementation backed by an fs.FS. +// +// The provided filesystem is expected to have skills organized as immediate +// subdirectories. A valid skill directory MUST contain a "SKILL.md" file +// with valid YAML frontmatter, and the directory name must exactly match +// the name defined in that frontmatter. +// +// Expected layout example: +// +// skill-1/ +// SKILL.md +// assets/ +// skill-2/ +// SKILL.md +// references/ +// scripts/ +func NewFileSystemSource(filesystem fs.FS) Source { + return &fileSystemSource{filesystem: filesystem} +} + +type fileSystemSource struct { + filesystem fs.FS +} + +// ListFrontmatters scans the immediate subdirectories of the root filesystem. +// It does not traverse recursively. Directories without a valid SKILL.md +// are silently ignored. +func (f *fileSystemSource) ListFrontmatters(ctx context.Context) ([]*Frontmatter, error) { + var frontmatters []*Frontmatter + + entries, err := fs.ReadDir(f.filesystem, ".") + if err != nil { + return nil, fmt.Errorf("read root directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue // Skills must be directories + } + frontmatter, _, closer, err := f.readSkill(entry.Name()) + if err != nil { + if errors.Is(err, ErrSkillNotFound) { + continue // Directory doesn't contain SKILL.md - not a skill. + } + return nil, err + } + // Avoid holding files open in the loop by closing without defer. + _ = closer.Close() // Ignore error as read success is what matters. + frontmatters = append(frontmatters, frontmatter) + } + return frontmatters, nil +} + +// LoadFrontmatter opens and parses the SKILL.md file located at the root +// of the specified skill directory. +func (f *fileSystemSource) LoadFrontmatter(ctx context.Context, name string) (*Frontmatter, error) { + frontmatter, _, closer, err := f.readSkill(name) + if err != nil { + return nil, err + } + _ = closer.Close() // Ignore error as read success is what matters. + return frontmatter, nil +} + +// LoadInstructions parses the SKILL.md file and returns the markdown content +// immediately following the frontmatter delimiter. +func (f *fileSystemSource) LoadInstructions(ctx context.Context, name string) (string, error) { + _, reader, closer, err := f.readSkill(name) + if err != nil { + return "", err + } + defer func() { + _ = closer.Close() // Ignore error as read success is what matters. + }() + + instructions, err := io.ReadAll(reader) + if err != nil { + return "", fmt.Errorf("read instructions: %w", err) + } + return string(instructions), nil +} + +// LoadResource reads a specific file from the skill's directory. +// +// For security, the resourcePath is sanitized using path.Clean. Access is +// strictly limited to files within the 'references/', 'assets/', or +// 'scripts/' subdirectories to prevent path traversal attacks. +func (f *fileSystemSource) LoadResource(ctx context.Context, name, resourcePath string) (io.ReadCloser, error) { + if err := f.validateSkill(name); err != nil { + return nil, err + } + + cleanPath := path.Clean(resourcePath) + if !strings.HasPrefix(cleanPath, "references/") && !strings.HasPrefix(cleanPath, "assets/") && !strings.HasPrefix(cleanPath, "scripts/") { + return nil, fmt.Errorf("%w: %q must be within 'references/', 'assets/', or 'scripts/' (relative to skill directory)", ErrInvalidResourcePath, resourcePath) + } + + fullPath := path.Join(name, cleanPath) + file, err := f.filesystem.Open(fullPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%w: %q", ErrResourceNotFound, cleanPath) + } + return nil, fmt.Errorf("open resource file %q: %w", fullPath, err) + } + return file, nil +} + +// ListResources walks the specified resource directory within the skill. +// +// If resourceDirectoryPath is empty or ".", it walks the 'references/', +// 'assets/', and 'scripts/' directories. It restricts traversal to these +// approved directories and returns sanitized paths relative to the skill root. +func (f *fileSystemSource) ListResources(ctx context.Context, name, resourceDirectoryPath string) ([]string, error) { + if err := f.validateSkill(name); err != nil { + return nil, err + } + + cleanPath := path.Clean(resourceDirectoryPath) + isRoot := cleanPath == "." || cleanPath == "" + + if !isRoot { + switch strings.SplitN(cleanPath, "/", 2)[0] { + case "references", "assets", "scripts": // Valid top level directories. + default: + return nil, fmt.Errorf("%w: %q must be empty, root (.), or within 'references/', 'assets/', or 'scripts/'", ErrInvalidResourcePath, resourceDirectoryPath) + } + } + + skillFS, err := fs.Sub(f.filesystem, name) + if err != nil { + return nil, fmt.Errorf("create sub-filesystem for %q: %w", name, err) + } + + if _, err := fs.Stat(skillFS, cleanPath); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%w: %q", ErrResourceNotFound, cleanPath) + } + return nil, fmt.Errorf("stat %q: %w", cleanPath, err) + } + + targets := []string{cleanPath} + if isRoot { // Limit the walk to these top-level directories. + targets = []string{"references", "assets", "scripts"} + } + + var resources []string + for _, target := range targets { + err := fs.WalkDir(skillFS, target, func(p string, d fs.DirEntry, err error) error { + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if !d.IsDir() { + resources = append(resources, p) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("walk target %q: %w", target, err) + } + } + + return resources, nil +} + +func (f *fileSystemSource) validateSkill(name string) error { + _, _, closer, err := f.readSkill(name) + if err != nil { + return err + } + _ = closer.Close() // Ignore error as read success is what matters. + return nil +} + +// readSkill reads and validates the frontmatter from the SKILL.md file and +// returns the frontmatter, a buffered reader for the rest of the file, and a +// closer for the file. +func (f *fileSystemSource) readSkill(name string) (*Frontmatter, *bufio.Reader, io.Closer, error) { + skillFilePath := path.Join(name, "SKILL.md") + file, err := f.filesystem.Open(skillFilePath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil, nil, fmt.Errorf("%w: %q", ErrSkillNotFound, name) + } + return nil, nil, nil, fmt.Errorf("open %q: %w", skillFilePath, err) + } + reader := bufio.NewReader(file) + frontmatter, err := Parse(reader) + if err != nil { + _ = file.Close() + return nil, nil, nil, fmt.Errorf("%w: parse frontmatter: %w", ErrInvalidFrontmatter, err) + } + if frontmatter.Name != name { + _ = file.Close() + return nil, nil, nil, fmt.Errorf("%w: name in SKILL.md (%q) does not match directory name (%q)", ErrInvalidSkillName, frontmatter.Name, name) + } + return frontmatter, reader, file, nil +} diff --git a/tool/skilltoolset/skill/filesystem_source_test.go b/tool/skilltoolset/skill/filesystem_source_test.go new file mode 100644 index 000000000..3ef2b4269 --- /dev/null +++ b/tool/skilltoolset/skill/filesystem_source_test.go @@ -0,0 +1,424 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skill + +import ( + "bytes" + "errors" + "io" + "io/fs" + "testing" + "testing/fstest" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestFileSystemSource_ListFrontmatters(t *testing.T) { + tests := []struct { + name string + source Source + want []*Frontmatter + wantErr error + }{ + { + name: "Success", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "math-skill/SKILL.md": &fstest.MapFile{ + Data: []byte("---\nname: math-skill\ndescription: test\n---\n"), + }, + "weather-skill/SKILL.md": &fstest.MapFile{ + Data: []byte("---\nname: weather-skill\ndescription: test\n---\n"), + }, + "random-file.txt": &fstest.MapFile{Data: []byte("should be ignored")}, + "SKILL.md": &fstest.MapFile{Data: []byte("should be ignored")}, + "dir/not-skill.txt": &fstest.MapFile{Data: []byte("should be ignored")}, + "sub/dir/SKILL.md": &fstest.MapFile{Data: []byte("should be ignored")}, + }}), + want: []*Frontmatter{ + {Name: "math-skill", Description: "test"}, + {Name: "weather-skill", Description: "test"}, + }, + }, + { + name: "Name mismatch", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: wrong-skill\ndescription: test\n---\n")}, + }}), + wantErr: ErrInvalidSkillName, + }, + { + name: "Invalid frontmatter", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---[INVALID_YAML")}, + }}), + wantErr: ErrInvalidFrontmatter, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.source.ListFrontmatters(t.Context()) + + if !errors.Is(err, tt.wantErr) { + t.Errorf("ListFrontmatters() expected error %v, got %v", tt.wantErr, err) + } + if diff := cmp.Diff(tt.want, got, cmpopts.SortSlices(func(a, b *Frontmatter) bool { return a.Name < b.Name })); diff != "" { + t.Errorf("ListFrontmatters() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestFileSystemSource_LoadFrontmatter(t *testing.T) { + tests := []struct { + name string + source Source + want *Frontmatter + wantErr error + }{ + { + name: "Success", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + }}), + want: &Frontmatter{Name: "test-skill", Description: "test"}, + }, + { + name: "Name mismatch", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: wrong-skill\ndescription: test\n---\n")}, + }}), + wantErr: ErrInvalidSkillName, + }, + { + name: "Invalid frontmatter", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---[INVALID_YAML")}, + }}), + wantErr: ErrInvalidFrontmatter, + }, + { + name: "Skill not found", + source: NewFileSystemSource(plainFS{fstest.MapFS{}}), + wantErr: ErrSkillNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.source.LoadFrontmatter(t.Context(), "test-skill") + + if !errors.Is(err, tt.wantErr) { + t.Errorf("LoadFrontmatter(%q) expected error %v, got %v", "test-skill", tt.wantErr, err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("LoadFrontmatter(%q) mismatch (-want +got):\n%s", "test-skill", diff) + } + }) + } +} + +func TestFileSystemSource_LoadInstructions(t *testing.T) { + tests := []struct { + name string + source Source + want string + wantErr error + }{ + { + name: "Success", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\nMath instructions.")}, + }}), + want: "Math instructions.", + }, + { + name: "Name mismatch", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: wrong-name\ndescription: test\n---\n")}, + }}), + wantErr: ErrInvalidSkillName, + }, + { + name: "Invalid YAML", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---[INVALID_YAML")}, + }}), + wantErr: ErrInvalidFrontmatter, + }, + { + name: "Skill not found", + source: NewFileSystemSource(plainFS{fstest.MapFS{}}), + wantErr: ErrSkillNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.source.LoadInstructions(t.Context(), "test-skill") + + if !errors.Is(err, tt.wantErr) { + t.Errorf("LoadInstructions(%q) expected error %v, got %v", "test-skill", tt.wantErr, err) + } + if got != tt.want { + t.Errorf("LoadInstructions(%q) = %q, want %q", "skill-name", got, tt.want) + } + }) + } +} + +func TestFileSystemSource_LoadResource(t *testing.T) { + tests := []struct { + name string + resourcePath string + source Source + wantErr error + want []byte + }{ + { + name: "Success Asset", + resourcePath: "assets/image.png", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + "test-skill/assets/image.png": &fstest.MapFile{Data: []byte("image-data")}, + }}), + want: []byte("image-data"), + }, + { + name: "Success Reference", + resourcePath: "references/doc.txt", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + "test-skill/references/doc.txt": &fstest.MapFile{Data: []byte("doc-data")}, + }}), + want: []byte("doc-data"), + }, + { + name: "Success Script", + resourcePath: "scripts/script.py", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + "test-skill/scripts/script.py": &fstest.MapFile{Data: []byte("python-code")}, + }}), + want: []byte("python-code"), + }, + { + name: "Success Clean Path resolves traversal safely", + resourcePath: "assets/../assets/images/../images/./image.png", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + "test-skill/assets/images/image.png": &fstest.MapFile{Data: []byte("image-data")}, + }}), + want: []byte("image-data"), + }, + { + name: "Error Skill Not Found", + resourcePath: "assets/image.png", + source: NewFileSystemSource(plainFS{fstest.MapFS{}}), + wantErr: ErrSkillNotFound, + }, + { + name: "Error Not a Skill", + resourcePath: "assets/image.png", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + // No SKILL.md file - not a skill. + "test-skill/assets/image.png": &fstest.MapFile{Data: []byte("image-data")}, + }}), + wantErr: ErrSkillNotFound, + }, + { + name: "Error Invalid Skill Name", + resourcePath: "assets/image.png", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: wrong-name\ndescription: test\n---\n")}, + "test-skill/assets/image.png": &fstest.MapFile{Data: []byte("image-data")}, + }}), + wantErr: ErrInvalidSkillName, + }, + { + name: "Error Invalid Frontmatter", + resourcePath: "assets/image.png", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("Invalid YAML")}, + "test-skill/assets/image.png": &fstest.MapFile{Data: []byte("image-data")}, + }}), + wantErr: ErrInvalidFrontmatter, + }, + { + name: "Error Traversal Attempt", + resourcePath: "../../etc/passwd", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + }}), + wantErr: ErrInvalidResourcePath, + }, + { + name: "Error Unauthorized Directory", + resourcePath: "unauthorized/file.txt", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + "test-skill/unauthorized/file.txt": &fstest.MapFile{Data: []byte("secret")}, + }}), + wantErr: ErrInvalidResourcePath, + }, + { + name: "Error File Not Found", + resourcePath: "assets/missing.png", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + }}), + wantErr: ErrResourceNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resource, err := tc.source.LoadResource(t.Context(), "test-skill", tc.resourcePath) + + if !errors.Is(err, tc.wantErr) { + t.Errorf("LoadResource(%q, %q) error = %v, want %v", "test-skill", tc.resourcePath, err, tc.wantErr) + } + if err != nil { + return + } + defer func() { + _ = resource.Close() + }() + got, err := io.ReadAll(resource) + if err != nil { + t.Fatalf("LoadResource(%q, %q) failed to read resource: %v", "test-skill", tc.resourcePath, err) + } + if !bytes.Equal(got, tc.want) { + t.Errorf("LoadResource(%q, %q) = %q, want %q", "test-skill", tc.resourcePath, got, tc.want) + } + }) + } +} + +func TestFileSystemSource_ListResources(t *testing.T) { + tests := []struct { + name string + searchPath string + source Source + wantErr error + want []string + }{ + { + name: "Success Root", + searchPath: ".", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + "test-skill/unauthorized/file.txt": &fstest.MapFile{Data: []byte("")}, + "test-skill/references/doc.txt": &fstest.MapFile{Data: []byte("")}, + "test-skill/references/sub/doc.txt": &fstest.MapFile{Data: []byte("")}, + "test-skill/assets/image.png": &fstest.MapFile{Data: []byte("")}, + "test-skill/scripts/script.py": &fstest.MapFile{Data: []byte("")}, + }}), + want: []string{ + "references/doc.txt", + "references/sub/doc.txt", + "assets/image.png", + "scripts/script.py", + }, + }, + { + name: "Success Root Empty search path", + searchPath: ".", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + "test-skill/references/doc.txt": &fstest.MapFile{Data: []byte("")}, + }}), + want: []string{"references/doc.txt"}, + }, + { + name: "Success Specific Dir", + searchPath: "assets", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + "test-skill/references/doc.txt": &fstest.MapFile{Data: []byte("")}, + "test-skill/assets/image.png": &fstest.MapFile{Data: []byte("")}, + "test-skill/assets/images/image.png": &fstest.MapFile{Data: []byte("")}, + }}), + want: []string{"assets/image.png", "assets/images/image.png"}, + }, + { + name: "Error Skill Not Found", + searchPath: ".", + source: NewFileSystemSource(plainFS{fstest.MapFS{}}), + wantErr: ErrSkillNotFound, + }, + { + name: "Error Skill name mismatch", + searchPath: ".", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: wrong-skill\ndescription: test\n---\n")}, + }}), + wantErr: ErrInvalidSkillName, + }, + { + name: "Error Invalid frontmatter", + searchPath: ".", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("Invalid YAML")}, + }}), + wantErr: ErrInvalidFrontmatter, + }, + { + name: "Error Unauthorized Directory", + searchPath: "unauthorized", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + "test-skill/unauthorized/file.txt": &fstest.MapFile{Data: []byte("")}, + }}), + wantErr: ErrInvalidResourcePath, + }, + { + name: "Error Directory Not Found", + searchPath: "references/missing_dir", + source: NewFileSystemSource(plainFS{fstest.MapFS{ + "test-skill/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: test-skill\ndescription: test\n---\n")}, + }}), + wantErr: ErrResourceNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.source.ListResources(t.Context(), "test-skill", tt.searchPath) + + if !errors.Is(err, tt.wantErr) { + t.Errorf("ListResources(%q, %q) error = %v, want %v", "test-skill", tt.searchPath, err, tt.wantErr) + } + if diff := cmp.Diff(tt.want, got, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("ListResources(%q, %q) diff (-want +got):\n%s", "test-skill", tt.searchPath, diff) + } + }) + } +} + +// plainFS is a minimal implementation of fs.FS that deliberately hides optional +// interface extensions like fs.ReadDirFS or fs.StatFS. +// +// We test exclusively against this minimal wrapper to ensure our Source +// implementation strictly relies only on the baseline fs.FS contract (Open) +// and doesn't accidentally depend on optional filesystem features. +type plainFS struct { + fs fs.FS +} + +func (p plainFS) Open(name string) (fs.File, error) { + return p.fs.Open(name) +} diff --git a/tool/skilltoolset/skill/source.go b/tool/skilltoolset/skill/source.go new file mode 100644 index 000000000..154c59ed6 --- /dev/null +++ b/tool/skilltoolset/skill/source.go @@ -0,0 +1,61 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skill + +import ( + "context" + "errors" + "io" +) + +// Common errors returned by Source implementations. +var ( + ErrInvalidSkillName = errors.New("invalid skill name") + ErrInvalidFrontmatter = errors.New("invalid frontmatter") + ErrSkillNotFound = errors.New("skill not found") + ErrDuplicateSkill = errors.New("duplicate skill") + ErrInvalidResourcePath = errors.New("invalid resource path") + ErrResourceNotFound = errors.New("resource not found") +) + +// Source is the interface for accessing skill components. +// +// Implementations must: +// - be safe for concurrent use. +// - return the sentinel error values defined in this package +// (ErrInvalidSkillName, ErrInvalidFrontmatter, ErrSkillNotFound, +// ErrDuplicateSkill, ErrInvalidResourcePath, ErrResourceNotFound, etc.) +// when appropriate. +type Source interface { + // ListFrontmatters returns frontmatters for all available skills. + ListFrontmatters(ctx context.Context) ([]*Frontmatter, error) + + // ListResources returns resource paths for a given skill and subpath. + // subpath is a relative path to the skill, as are the returned resource + // paths. + ListResources(ctx context.Context, name, subpath string) ([]string, error) + + // LoadFrontmatter returns the frontmatter for a single skill by name. + LoadFrontmatter(ctx context.Context, name string) (*Frontmatter, error) + + // LoadInstructions returns the instruction body for a single skill by name. + LoadInstructions(ctx context.Context, name string) (string, error) + + // LoadResource returns a stream for a specific resource within a given skill. + // resourcePath is a relative path to the skill. + // + // NOTE: The caller is responsible for closing the returned io.ReadCloser. + LoadResource(ctx context.Context, name, resourcePath string) (io.ReadCloser, error) +} From ea1865bdaa66024c2f96cbc1653ad24f17227457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Maciejczek?= Date: Wed, 8 Apr 2026 22:30:52 +0200 Subject: [PATCH 15/86] feat(telemetry): add configurable LRU cache in debug telemetry to avoid memory leaks (#687) * feat(telemetry): add configurable LRU cache in debug telemetry to avoid memory leaks * improve error handling and mention cache size default in the docs * refactor the logic touching the trace ids for event spans --------- Co-authored-by: Dmitry Pasiukevich <20398573+dpasiukevich@users.noreply.github.com> --- go.mod | 2 + go.sum | 2 + server/adkrest/controllers/debug_test.go | 11 +- server/adkrest/handler.go | 16 +- .../internal/services/debugtelemetry.go | 167 +++++++++++++----- .../internal/services/debugtelemetry_test.go | 94 +++++++++- 6 files changed, 236 insertions(+), 56 deletions(-) diff --git a/go.mod b/go.mod index ae0075e18..37438ba76 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,8 @@ require ( rsc.io/ordered v1.1.1 ) +require github.com/hashicorp/golang-lru/v2 v2.0.7 + require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go/auth v0.17.0 // indirect diff --git a/go.sum b/go.sum index 155b5abda..51a556ea9 100644 --- a/go.sum +++ b/go.sum @@ -94,6 +94,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= diff --git a/server/adkrest/controllers/debug_test.go b/server/adkrest/controllers/debug_test.go index 25efcffa0..8d6c5c0b1 100644 --- a/server/adkrest/controllers/debug_test.go +++ b/server/adkrest/controllers/debug_test.go @@ -92,7 +92,7 @@ func TestSessionSpansHandler(t *testing.T) { t.Run(tt.name, func(t *testing.T) { eventID := "test-event" opName := semconv.GenAIOperationNameExecuteTool.Value.AsString() - testTelemetry := setupTestTelemetry() + testTelemetry := setupTestTelemetry(t) apiController := controllers.NewDebugAPIController(nil, nil, testTelemetry.dt) req, err := http.NewRequest(http.MethodGet, "/debug/sessions/"+tt.reqSessionID+"/spans", nil) @@ -204,7 +204,7 @@ func TestEventSpanHandler(t *testing.T) { for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { sessionID := "test-session" - testTelemetry := setupTestTelemetry() + testTelemetry := setupTestTelemetry(t) apiController := controllers.NewDebugAPIController(nil, nil, testTelemetry.dt) req, err := http.NewRequest(http.MethodGet, "/debug/events/"+tt.reqEventID+"/span", nil) @@ -258,8 +258,11 @@ type testTelemetry struct { lp *sdklog.LoggerProvider } -func setupTestTelemetry() *testTelemetry { - dt := services.NewDebugTelemetry() +func setupTestTelemetry(t *testing.T) *testTelemetry { + dt, err := services.NewDebugTelemetryWithConfig(nil) + if err != nil { + t.Fatalf("failed to create debug telemetry: %v", err) + } tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(dt.SpanProcessor())) lp := sdklog.NewLoggerProvider(sdklog.WithProcessor(dt.LogProcessor())) diff --git a/server/adkrest/handler.go b/server/adkrest/handler.go index 01ece4770..b0dc54a6c 100644 --- a/server/adkrest/handler.go +++ b/server/adkrest/handler.go @@ -15,6 +15,7 @@ package adkrest import ( + "fmt" "net/http" "time" @@ -34,7 +35,12 @@ import ( // NewServer creates a new ADK REST API server which implements [http.Handler] interface. func NewServer(cfg ServerConfig) (*Server, error) { - debugTelemetry := services.NewDebugTelemetry() + debugTelemetry, err := services.NewDebugTelemetryWithConfig(&services.DebugTelemetryConfig{ + TraceCapacity: cfg.DebugConfig.TraceCapacity, + }) + if err != nil { + return nil, fmt.Errorf("failed to create debug telemetry service: %w", err) + } router := mux.NewRouter().StrictSlash(true) // TODO: Allow taking a prefix to allow customizing the path @@ -61,6 +67,14 @@ type ServerConfig struct { ArtifactService artifact.Service SSEWriteTimeout time.Duration PluginConfig runner.PluginConfig + DebugConfig *DebugTelemetryConfig +} + +// DebugTelemetryConfig contains parameters for the debug telemetry. +type DebugTelemetryConfig struct { + // Maximum number of traces to keep in memory. + // If <= 0, the default capacity 10_000 is used. + TraceCapacity int } // Server is an HTTP server that serves the ADK REST API. diff --git a/server/adkrest/internal/services/debugtelemetry.go b/server/adkrest/internal/services/debugtelemetry.go index affafc5d8..b2823a72c 100644 --- a/server/adkrest/internal/services/debugtelemetry.go +++ b/server/adkrest/internal/services/debugtelemetry.go @@ -15,11 +15,15 @@ package services import ( + "cmp" "context" + "fmt" "slices" "sync" "time" + lru "github.com/hashicorp/golang-lru/v2" + "go.opentelemetry.io/otel/attribute" sdklog "go.opentelemetry.io/otel/sdk/log" sdktrace "go.opentelemetry.io/otel/sdk/trace" @@ -29,18 +33,35 @@ import ( "google.golang.org/adk/internal/telemetry" ) -const eventIDKey = "gcp.vertex.agent.event_id" +const ( + defaultTraceCapacity = 10_000 + eventIDKey = "gcp.vertex.agent.event_id" +) // DebugTelemetry stores the in memory spans and logs, grouped by session and event. type DebugTelemetry struct { store *spanStore } -// NewDebugTelemetry returns a new DebugTelemetry instance. -func NewDebugTelemetry() *DebugTelemetry { - return &DebugTelemetry{ - store: newSpanStore(), +type DebugTelemetryConfig struct { + // Maximum number of traces to keep in memory. + // If <= 0, default capacity (10_000) is used. + TraceCapacity int +} + +// NewDebugTelemetryWithConfig returns a new DebugTelemetry instance with custom capacity. +func NewDebugTelemetryWithConfig(cfg *DebugTelemetryConfig) (*DebugTelemetry, error) { + capacity := defaultTraceCapacity + if cfg != nil && cfg.TraceCapacity > 0 { + capacity = cfg.TraceCapacity + } + store, err := newSpanStore(capacity) + if err != nil { + return nil, fmt.Errorf("failed to create span store: %w", err) } + return &DebugTelemetry{ + store: store, + }, nil } func (d *DebugTelemetry) SpanProcessor() sdktrace.SpanProcessor { @@ -64,7 +85,7 @@ func (d *DebugTelemetry) GetSpansBySessionID(sessionID string) []DebugSpan { } func convertAttrs(in []attribute.KeyValue) map[string]string { - out := make(map[string]string) + out := make(map[string]string, len(in)) for _, attr := range in { out[string(attr.Key)] = attr.Value.Emit() } @@ -94,40 +115,40 @@ type DebugLog struct { // spanRecord stores a span and its associated logs. type spanRecord struct { - Span *inMemorySpan - Logs []DebugLog -} - -// inMemorySpan stores spans in memory for debug telemetry. -type inMemorySpan struct { Name string StartTime time.Time EndTime time.Time Context trace.SpanContext ParentSpanID trace.SpanID Attributes map[string]string + Logs []DebugLog } // spanStore stores spans and logs in memory for debug telemetry. type spanStore struct { mu sync.RWMutex + // recordsByTraceID is the main store for spans, indexed by trace id. + recordsByTraceID *lru.Cache[string, []*spanRecord] // recordsBySpanID stores spans indexed by span id. recordsBySpanID map[string]*spanRecord // traceIDsBySessionID stores trace ids indexed by session id for easy lookup. traceIDsBySessionID map[string]map[string]struct{} // recordsByEventID stores spans indexed by event id for easy lookup. recordsByEventID map[string][]*spanRecord - // recordsByTraceID stores spans indexed by trace id for easy lookup. - recordsByTraceID map[string][]*spanRecord } -func newSpanStore() *spanStore { - return &spanStore{ +func newSpanStore(capacity int) (*spanStore, error) { + store := &spanStore{ recordsBySpanID: make(map[string]*spanRecord), traceIDsBySessionID: make(map[string]map[string]struct{}), recordsByEventID: make(map[string][]*spanRecord), - recordsByTraceID: make(map[string][]*spanRecord), } + var err error + store.recordsByTraceID, err = lru.NewWithEvict(capacity, store.evict) + if err != nil { + return nil, fmt.Errorf("failed to create LRU cache: %w", err) + } + return store, nil } func (s *spanStore) getSpansByEventID(id string) []DebugSpan { @@ -135,50 +156,65 @@ func (s *spanStore) getSpansByEventID(id string) []DebugSpan { defer s.mu.RUnlock() // Create a copy of the slice to avoid race conditions. records := slices.Clone(s.recordsByEventID[id]) + s.touchTraces(records) return convertRecords(records) } +// touchTraces marks traces as recently used. Required because fetching by event ID bypasses the trace LRU cache. +func (s *spanStore) touchTraces(records []*spanRecord) { + // touchedTraces is used to avoid touching the same trace multiple times. + touchedTraces := make(map[string]bool) + for _, r := range records { + traceIDStr := r.Context.TraceID().String() + if traceIDStr != "" && !touchedTraces[traceIDStr] { + touchedTraces[traceIDStr] = true + // Get the trace to update its access time in the LRU cache, ignore the result. + s.recordsByTraceID.Get(traceIDStr) + } + } +} + func (s *spanStore) getSpansBySessionID(sessionID string) []DebugSpan { s.mu.RLock() defer s.mu.RUnlock() traces := s.traceIDsBySessionID[sessionID] var records []*spanRecord for traceID := range traces { - if r, ok := s.recordsByTraceID[traceID]; ok { - records = append(records, r...) + if traceRecords, ok := s.recordsByTraceID.Get(traceID); ok { + records = append(records, traceRecords...) } } return convertRecords(records) } func convertRecords(records []*spanRecord) []DebugSpan { - records = filterNilsAndSort(records) + records = filterUnclosedAndSort(records) debugSpans := make([]DebugSpan, len(records)) for i, r := range records { // Clone the logs to avoid race conditions. logs := slices.Clone(r.Logs) debugSpans[i] = DebugSpan{ - Name: r.Span.Name, - StartTime: r.Span.StartTime.UnixNano(), - EndTime: r.Span.EndTime.UnixNano(), - TraceID: r.Span.Context.TraceID().String(), - SpanID: r.Span.Context.SpanID().String(), - ParentSpanID: r.Span.ParentSpanID.String(), - Attributes: r.Span.Attributes, + Name: r.Name, + StartTime: r.StartTime.UnixNano(), + EndTime: r.EndTime.UnixNano(), + SpanID: r.Context.SpanID().String(), + TraceID: r.Context.TraceID().String(), + ParentSpanID: r.ParentSpanID.String(), + Attributes: r.Attributes, Logs: logs, } } return debugSpans } -func filterNilsAndSort(records []*spanRecord) []*spanRecord { +func filterUnclosedAndSort(records []*spanRecord) []*spanRecord { filtered := slices.DeleteFunc(records, func(s *spanRecord) bool { // Logs are emitted before the span is closed and sent to the processor. // Skip them in the response. - return s == nil || s.Span == nil + return s == nil || !s.Context.TraceID().IsValid() }) - slices.SortFunc(filtered, func(a, b *spanRecord) int { - return a.Span.StartTime.Compare(b.Span.StartTime) + slices.SortStableFunc(filtered, func(a, b *spanRecord) int { + return cmp.Compare(a.StartTime.UnixNano(), b.StartTime.UnixNano()) }) return filtered } @@ -222,21 +258,20 @@ func (s *spanStore) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySp s.recordsBySpanID[spanID] = record } - record.Span = &inMemorySpan{ - Name: span.Name(), - StartTime: span.StartTime(), - EndTime: span.EndTime(), - Context: span.SpanContext(), - ParentSpanID: span.Parent().SpanID(), - Attributes: attrs, - } + record.Name = span.Name() + record.StartTime = span.StartTime() + record.EndTime = span.EndTime() + record.Context = span.SpanContext() + record.ParentSpanID = span.Parent().SpanID() + record.Attributes = attrs - s.updateSpanIndexes(record.Span, record) + s.updateSpanIndexes(record) } return nil } -func (s *spanStore) updateSpanIndexes(span *inMemorySpan, record *spanRecord) { +func (s *spanStore) updateSpanIndexes(span *spanRecord) { + traceIDStr := span.Context.TraceID().String() // Update session id -> trace id mapping. sessionIDKey := string(semconv.GenAIConversationIDKey) if sessionID, ok := span.Attributes[sessionIDKey]; ok { @@ -245,16 +280,54 @@ func (s *spanStore) updateSpanIndexes(span *inMemorySpan, record *spanRecord) { traces = make(map[string]struct{}) s.traceIDsBySessionID[sessionID] = traces } - traceID := span.Context.TraceID().String() - traces[traceID] = struct{}{} + traces[traceIDStr] = struct{}{} } // Update event id -> span id mapping. if eventID, ok := span.Attributes[eventIDKey]; ok { - s.recordsByEventID[eventID] = append(s.recordsByEventID[eventID], record) + s.recordsByEventID[eventID] = append(s.recordsByEventID[eventID], span) + } + + // Update trace id -> span id mapping (LRU). + records, _ := s.recordsByTraceID.Get(traceIDStr) + s.recordsByTraceID.Add(traceIDStr, append(records, span)) +} + +func (s *spanStore) evict(traceID string, spans []*spanRecord) { + for _, span := range spans { + if span.Context.TraceID().IsValid() { + delete(s.recordsBySpanID, span.Context.SpanID().String()) + + if eventID, ok := span.Attributes[eventIDKey]; ok { + s.evictRecordsByEventID(eventID, span) + } + + if sessionID, ok := span.Attributes[string(semconv.GenAIConversationIDKey)]; ok { + s.evictTraceIDsBySessionID(sessionID, traceID) + } + } + } +} + +func (s *spanStore) evictRecordsByEventID(eventID string, span *spanRecord) { + records := s.recordsByEventID[eventID] + records = slices.DeleteFunc(records, func(r *spanRecord) bool { + return r.Context.SpanID() == span.Context.SpanID() + }) + if len(records) == 0 { + delete(s.recordsByEventID, eventID) + } else { + s.recordsByEventID[eventID] = records + } +} + +func (s *spanStore) evictTraceIDsBySessionID(sessionID, traceID string) { + traces := s.traceIDsBySessionID[sessionID] + if traces != nil { + delete(traces, traceID) + if len(traces) == 0 { + delete(s.traceIDsBySessionID, sessionID) + } } - // Update trace id -> span id mapping. - traceID := span.Context.TraceID().String() - s.recordsByTraceID[traceID] = append(s.recordsByTraceID[traceID], record) } // ForceFlush implements sdklog.Exporter and sdktrace.SpanProcessor. diff --git a/server/adkrest/internal/services/debugtelemetry_test.go b/server/adkrest/internal/services/debugtelemetry_test.go index 7d66d2578..3ebb6ed8a 100644 --- a/server/adkrest/internal/services/debugtelemetry_test.go +++ b/server/adkrest/internal/services/debugtelemetry_test.go @@ -212,7 +212,7 @@ func TestDebugTelemetryGetSpansBySessionID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - debugTelemetry, tp, lp := setup() + debugTelemetry, tp, lp := setup(t) if tt.testSetup != nil { tt.testSetup(ctx, tp.Tracer("test-tracer"), lp.Logger("test-logger")) @@ -349,7 +349,7 @@ func TestDebugTelemetryGetSpansByEventID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - debugTelemetry, tp, lp := setup() + debugTelemetry, tp, lp := setup(t) if tt.testSetup != nil { tt.testSetup(ctx, tp.Tracer("test-tracer"), lp.Logger("test-logger")) @@ -377,8 +377,90 @@ func TestDebugTelemetryGetSpansByEventID(t *testing.T) { } } -func setup() (*DebugTelemetry, *sdktrace.TracerProvider, *sdklog.LoggerProvider) { - debugTelemetry := NewDebugTelemetry() +func TestDebugTelemetryLRU(t *testing.T) { + ctx := t.Context() + + debugTelemetry, tp, lp := setupWithConfig(t, &DebugTelemetryConfig{TraceCapacity: 2}) + tracer := tp.Tracer("test-tracer") + + // 1. Add Trace 1. + _, span1 := tracer.Start(ctx, "root-1", trace.WithAttributes( + semconv.GenAIConversationID("session-1"), + attribute.String("gcp.vertex.agent.event_id", "event-1"), + )) + span1.End() + + // 2. Add Trace 2. + _, span2 := tracer.Start(ctx, "root-2", trace.WithAttributes( + semconv.GenAIConversationID("session-2"), + attribute.String("gcp.vertex.agent.event_id", "event-2"), + )) + span2.End() + + _ = tp.ForceFlush(ctx) + _ = lp.ForceFlush(ctx) + + // 3. Verify both traces are present. + if gotSpans := len(debugTelemetry.GetSpansBySessionID("session-1")); gotSpans != 1 { + t.Errorf("expected 1 span for session-1, got %d", gotSpans) + } + if gotSpans := len(debugTelemetry.GetSpansBySessionID("session-2")); gotSpans != 1 { + t.Errorf("expected 1 span for session-2, got %d", gotSpans) + } + + // 4. Access session-2 making it the most recently used. + _ = debugTelemetry.GetSpansBySessionID("session-2") + + // 5. Add Trace 3 - should evict Trace 1 because it's the least recently used. + _, span3 := tracer.Start(ctx, "root-3", trace.WithAttributes( + semconv.GenAIConversationID("session-3"), + attribute.String("gcp.vertex.agent.event_id", "event-3"), + )) + span3.End() + + // 6. Verify Trace 1 is evicted, Trace 2 and 3 are present. + if gotSpans := len(debugTelemetry.GetSpansBySessionID("session-1")); gotSpans != 0 { + t.Errorf("expected 0 spans for session-1, got %d", gotSpans) + } + if gotSpans := len(debugTelemetry.GetSpansBySessionID("session-2")); gotSpans != 1 { + t.Errorf("expected 1 span for session-2, got %d", gotSpans) + } + if gotSpans := len(debugTelemetry.GetSpansBySessionID("session-3")); gotSpans != 1 { + t.Errorf("expected 1 span for session-3, got %d", gotSpans) + } + + // 7. Verify Trace 1 spans are removed from event index. + if gotSpans := len(debugTelemetry.GetSpansByEventID("event-1")); gotSpans != 0 { + t.Errorf("expected 0 spans for event-1, got %d", gotSpans) + } + + // 8. Access Trace 2 via GetSpansByEventID, making it the most recently used. + _ = debugTelemetry.GetSpansByEventID("event-2") + + // 9. Add Trace 4 - should evict Trace 3 because it's the least recently used. + _, span4 := tracer.Start(ctx, "root-4", trace.WithAttributes( + semconv.GenAIConversationID("session-4"), + attribute.String("gcp.vertex.agent.event_id", "event-4"), + )) + span4.End() + + // 10. Verify Trace 3 is evicted, Trace 2 and 4 are present. + if gotSpans := len(debugTelemetry.GetSpansBySessionID("session-2")); gotSpans != 1 { + t.Errorf("expected 1 span for session-2, got %d", gotSpans) + } + if gotSpans := len(debugTelemetry.GetSpansBySessionID("session-4")); gotSpans != 1 { + t.Errorf("expected 1 span for session-4, got %d", gotSpans) + } + if gotSpans := len(debugTelemetry.GetSpansBySessionID("session-3")); gotSpans != 0 { + t.Errorf("expected 0 spans for session-3, got %d", gotSpans) + } +} + +func setupWithConfig(t *testing.T, cfg *DebugTelemetryConfig) (*DebugTelemetry, *sdktrace.TracerProvider, *sdklog.LoggerProvider) { + debugTelemetry, err := NewDebugTelemetryWithConfig(cfg) + if err != nil { + t.Fatalf("Failed to create debug telemetry: %v", err) + } tp := sdktrace.NewTracerProvider( sdktrace.WithSpanProcessor(debugTelemetry.SpanProcessor()), ) @@ -386,3 +468,7 @@ func setup() (*DebugTelemetry, *sdktrace.TracerProvider, *sdklog.LoggerProvider) return debugTelemetry, tp, lp } + +func setup(t *testing.T) (*DebugTelemetry, *sdktrace.TracerProvider, *sdklog.LoggerProvider) { + return setupWithConfig(t, nil) +} From a37ce952d29afc068332934d29bb123ad3c8e9fc Mon Sep 17 00:00:00 2001 From: Anastasia Date: Mon, 13 Apr 2026 13:48:23 +0200 Subject: [PATCH 16/86] feat: add the implementation of Eventarc subrouter (#713) Implementation of Cloud Events trigger processing --- cmd/launcher/full/full.go | 3 +- .../web/triggers/eventarc/eventarc.go | 136 ++++++++++++ .../adkrest/controllers/triggers/eventarc.go | 137 ++++++++++++ .../controllers/triggers/eventarc_test.go | 204 ++++++++++++++++++ server/adkrest/controllers/triggers/pubsub.go | 59 ++--- .../adkrest/controllers/triggers/triggers.go | 4 - server/adkrest/internal/models/triggers.go | 33 +++ 7 files changed, 547 insertions(+), 29 deletions(-) create mode 100644 cmd/launcher/web/triggers/eventarc/eventarc.go create mode 100644 server/adkrest/controllers/triggers/eventarc.go create mode 100644 server/adkrest/controllers/triggers/eventarc_test.go diff --git a/cmd/launcher/full/full.go b/cmd/launcher/full/full.go index a6f46d3b2..a49040275 100644 --- a/cmd/launcher/full/full.go +++ b/cmd/launcher/full/full.go @@ -22,11 +22,12 @@ import ( "google.golang.org/adk/cmd/launcher/web" "google.golang.org/adk/cmd/launcher/web/a2a" "google.golang.org/adk/cmd/launcher/web/api" + "google.golang.org/adk/cmd/launcher/web/triggers/eventarc" "google.golang.org/adk/cmd/launcher/web/triggers/pubsub" "google.golang.org/adk/cmd/launcher/web/webui" ) // NewLauncher returnes the most versatile universal launcher with all options built-in. func NewLauncher() launcher.Launcher { - return universal.NewLauncher(console.NewLauncher(), web.NewLauncher(webui.NewLauncher(), a2a.NewLauncher(), pubsub.NewLauncher(), api.NewLauncher())) + return universal.NewLauncher(console.NewLauncher(), web.NewLauncher(webui.NewLauncher(), a2a.NewLauncher(), pubsub.NewLauncher(), eventarc.NewLauncher(), api.NewLauncher())) } diff --git a/cmd/launcher/web/triggers/eventarc/eventarc.go b/cmd/launcher/web/triggers/eventarc/eventarc.go new file mode 100644 index 000000000..abc68d238 --- /dev/null +++ b/cmd/launcher/web/triggers/eventarc/eventarc.go @@ -0,0 +1,136 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package eventarc provides a sublauncher that adds Eventarc trigger capabilities to ADK web server. +package eventarc + +import ( + "flag" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gorilla/mux" + + "google.golang.org/adk/cmd/launcher" + "google.golang.org/adk/cmd/launcher/web" + "google.golang.org/adk/internal/cli/util" + "google.golang.org/adk/server/adkrest/controllers/triggers" +) + +type eventarcConfig struct { + pathPrefix string + triggerMaxRetries int + triggerBaseDelay time.Duration + triggerMaxDelay time.Duration + triggerMaxRuns int +} + +type eventarcLauncher struct { + flags *flag.FlagSet + config *eventarcConfig +} + +// NewLauncher creates a new eventarc launcher. It extends Web launcher. +func NewLauncher() web.Sublauncher { + config := &eventarcConfig{} + + fs := flag.NewFlagSet("eventarc", flag.ContinueOnError) + fs.StringVar(&config.pathPrefix, "path_prefix", "/api", "Path prefix for the Eventarc trigger endpoint. Default is '/api'.") + fs.IntVar(&config.triggerMaxRetries, "trigger_max_retries", 3, "Maximum retries for HTTP 429 errors from triggers") + fs.DurationVar(&config.triggerBaseDelay, "trigger_base_delay", 1*time.Second, "Base delay for trigger retry exponential backoff") + fs.DurationVar(&config.triggerMaxDelay, "trigger_max_delay", 10*time.Second, "Maximum delay for trigger retry exponential backoff") + fs.IntVar(&config.triggerMaxRuns, "trigger_max_concurrent_runs", 100, "Maximum concurrent trigger runs") + + return &eventarcLauncher{ + config: config, + flags: fs, + } +} + +// Keyword implements web.Sublauncher. Returns the command-line keyword for eventarc launcher. +func (e *eventarcLauncher) Keyword() string { + return "eventarc" +} + +// Parse parses the command-line arguments for the eventarc launcher. +func (e *eventarcLauncher) Parse(args []string) ([]string, error) { + err := e.flags.Parse(args) + if err != nil || !e.flags.Parsed() { + return nil, fmt.Errorf("failed to parse eventarc flags: %v", err) + } + if e.config.triggerMaxRetries <= 0 { + return nil, fmt.Errorf("trigger_max_retries must be > 0") + } + if e.config.triggerBaseDelay < 0 { + return nil, fmt.Errorf("trigger_base_delay must be >= 0") + } + if e.config.triggerMaxDelay <= 0 { + return nil, fmt.Errorf("trigger_max_delay must be > 0") + } + if e.config.triggerMaxRuns <= 0 { + return nil, fmt.Errorf("trigger_max_concurrent_runs must be > 0") + } + + prefix := e.config.pathPrefix + if !strings.HasPrefix(prefix, "/") { + prefix = "/" + prefix + } + e.config.pathPrefix = strings.TrimSuffix(prefix, "/") + + return e.flags.Args(), nil +} + +// CommandLineSyntax returns the command-line syntax for the eventarc launcher. +func (e *eventarcLauncher) CommandLineSyntax() string { + return util.FormatFlagUsage(e.flags) +} + +// SimpleDescription implements web.Sublauncher. +func (e *eventarcLauncher) SimpleDescription() string { + return "starts ADK Eventarc trigger endpoint server" +} + +// SetupSubrouters adds the Eventarc trigger endpoint to the parent router. +func (e *eventarcLauncher) SetupSubrouters(router *mux.Router, config *launcher.Config) error { + triggerConfig := triggers.TriggerConfig{ + MaxRetries: e.config.triggerMaxRetries, + BaseDelay: e.config.triggerBaseDelay, + MaxDelay: e.config.triggerMaxDelay, + MaxConcurrentRuns: e.config.triggerMaxRuns, + } + + controller := triggers.NewEventarcController( + config.SessionService, + config.AgentLoader, + config.MemoryService, + config.ArtifactService, + config.PluginConfig, + triggerConfig, + ) + + subrouter := router + if e.config.pathPrefix != "" && e.config.pathPrefix != "/" { + subrouter = router.PathPrefix(e.config.pathPrefix).Subrouter() + } + + subrouter.HandleFunc("/apps/{app_name}/trigger/eventarc", controller.EventarcTriggerHandler).Methods(http.MethodPost) + return nil +} + +// UserMessage implements web.Sublauncher. +func (e *eventarcLauncher) UserMessage(webURL string, printer func(v ...any)) { + printer(fmt.Sprintf(" eventarc: Eventarc trigger endpoint is available at %s%s/apps/{app_name}/trigger/eventarc", webURL, e.config.pathPrefix)) +} diff --git a/server/adkrest/controllers/triggers/eventarc.go b/server/adkrest/controllers/triggers/eventarc.go new file mode 100644 index 000000000..65f32ecc3 --- /dev/null +++ b/server/adkrest/controllers/triggers/eventarc.go @@ -0,0 +1,137 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package triggers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "google.golang.org/adk/agent" + "google.golang.org/adk/artifact" + "google.golang.org/adk/memory" + "google.golang.org/adk/runner" + "google.golang.org/adk/server/adkrest/internal/models" + "google.golang.org/adk/session" +) + +const eventArcDefaultUserID = "eventarc-caller" + +// EventarcController handles the Eventarc trigger endpoints. +type EventarcController struct { + runner *RetriableRunner + semaphore chan struct{} +} + +// NewEventarcController creates a new EventarcController. +func NewEventarcController(sessionService session.Service, agentLoader agent.Loader, memoryService memory.Service, artifactService artifact.Service, pluginConfig runner.PluginConfig, triggerConfig TriggerConfig) *EventarcController { + return &EventarcController{ + runner: &RetriableRunner{ + sessionService: sessionService, + agentLoader: agentLoader, + memoryService: memoryService, + artifactService: artifactService, + pluginConfig: pluginConfig, + triggerConfig: triggerConfig, + }, + semaphore: make(chan struct{}, triggerConfig.MaxConcurrentRuns), + } +} + +// EventarcTriggerHandler handles the Eventarc trigger endpoint. +func (c *EventarcController) EventarcTriggerHandler(w http.ResponseWriter, r *http.Request) { + var event models.EventarcTriggerRequest + contentType := r.Header.Get("Content-Type") + // The HTTP Content-Type header MUST be set to the media type of an event format for structured mode. + // https://github.com/cloudevents/spec/blob/main/cloudevents/bindings/http-protocol-binding.md#321-http-content-type + if contentType == "application/cloudevents+json" { + // --- STRUCTURED MODE --- + // The entire event is in the body. Decode it. + // The payload (Storage or Pub/Sub) gets safely trapped in event.Data as bytes. + if err := json.NewDecoder(r.Body).Decode(&event); err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("failed to unmarshal eventarc request: %v", err)) + return + } + } else { + // --- BINARY MODE --- + // Metadata is in the headers. + event.ID = r.Header.Get("ce-id") + event.Type = r.Header.Get("ce-type") + event.Source = r.Header.Get("ce-source") + event.SpecVersion = r.Header.Get("ce-specversion") + event.Time = r.Header.Get("ce-time") + + // The entire body is the payload. + // We just read it as raw bytes into event.Data. + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read body: %v", err)) + return + } + event.Data = bodyBytes + } + + var messageContent string + + // Handle Pub/Sub Specifically --- + if event.Type == "google.cloud.pubsub.topic.v1.messagePublished" { + var pubsub models.PubSubTriggerRequest + var err error + // Unmarshal the raw bytes into our specific Pub/Sub struct + if err := json.Unmarshal(event.Data, &pubsub); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal pubsub data: %v", err)) + return + } + messageContent, err = messageContentFromPubSub(pubsub) + if err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("failed to retrieve message content: %v", err)) + return + } + } else { + // Otherwise just marshal the whole event as an input data. + // E.g. as https://googleapis.github.io/google-cloudevents/examples/binary/storage/StorageObjectData-simple.json + messageBytes, err := json.Marshal(event) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to marshal agent message: %v", err)) + return + } + messageContent = string(messageBytes) + } + + appName, err := appName(r) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to retrieve app name: %v", err)) + return + } + + userID := event.Source + if userID == "" { + userID = eventArcDefaultUserID + } + + // Semaphore limits concurrent agent calls based on the TriggerConfig. + if c.semaphore != nil { + c.semaphore <- struct{}{} + defer func() { <-c.semaphore }() + } + + if _, err := c.runner.RunAgent(r.Context(), appName, userID, messageContent); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to run agent: %v", err)) + return + } + + respondSuccess(w) +} diff --git a/server/adkrest/controllers/triggers/eventarc_test.go b/server/adkrest/controllers/triggers/eventarc_test.go new file mode 100644 index 000000000..63b03e8ff --- /dev/null +++ b/server/adkrest/controllers/triggers/eventarc_test.go @@ -0,0 +1,204 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package triggers_test + +import ( + "bytes" + "encoding/json" + "iter" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/gorilla/mux" + + "google.golang.org/adk/agent" + "google.golang.org/adk/runner" + "google.golang.org/adk/server/adkrest/controllers/triggers" + "google.golang.org/adk/server/adkrest/internal/fakes" + "google.golang.org/adk/session" +) + +func TestEventarcTriggerHandler(t *testing.T) { + storagePayload := `{ + "bucket": "sample-bucket", + "contentType": "text/plain", + "generation": "1587627537231057", + "id": "sample-bucket/folder/Test.cs/1587627537231057", + "kind": "storage#object", + "size": "352", + "storageClass": "MULTI_REGIONAL", + "timeCreated": "2020-04-23T07:38:57.230Z", + "timeStorageClassUpdated": "2020-04-23T07:38:57.230Z", + "updated": "2020-04-23T07:38:57.230Z" +}` + + pubsubPayload := `{ + "subscription": "projects/test-project/subscriptions/my-subscription", + "message": { + "attributes": { + "attr1":"attr1-value" + }, + "data": "dGVzdCBtZXNzYWdlIDM=", + "messageId": "message-id", + "publishTime":"2021-02-05T04:06:14.109Z", + "orderingKey": "ordering-key" + } +}` + + jsonRawToMap := func(t *testing.T, data []byte) map[string]any { + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal JSON to map: %v", err) + } + return m + } + + tests := []struct { + name string + contentType string + headers map[string]string + body []byte + expectedPayload map[string]any + }{ + { + name: "Storage_Structured_Mode", + contentType: "application/cloudevents+json", + body: []byte(storagePayload), + expectedPayload: map[string]any{ + "id": "sample-bucket/folder/Test.cs/1587627537231057", + "source": "", + "type": "", + "specversion": "", + }, + }, + { + name: "Storage_Binary_Mode", + contentType: "application/json", + headers: map[string]string{ + "ce-id": "1234-5678", + "ce-type": "google.storage.object.v1.finalized", + "ce-source": "//storage.googleapis.com/projects/_/buckets/sample-bucket", + "ce-specversion": "1.0", + }, + body: []byte(storagePayload), + expectedPayload: map[string]any{ + "id": "1234-5678", + "type": "google.storage.object.v1.finalized", + "source": "//storage.googleapis.com/projects/_/buckets/sample-bucket", + "specversion": "1.0", + "data": jsonRawToMap(t, []byte(storagePayload)), + }, + }, + { + name: "PubSub_Structured_Mode", + contentType: "application/cloudevents+json", + body: func() []byte { + ce := map[string]any{ + "specversion": "1.0", + "type": "google.cloud.pubsub.topic.v1.messagePublished", + "source": "//pubsub.googleapis.com/projects/test-project/topics/test-topic", + "id": "1234-5678", + "data": json.RawMessage(pubsubPayload), + } + b, _ := json.Marshal(ce) + return b + }(), + expectedPayload: map[string]any{ + "data": "test message 3", + "attributes": map[string]any{ + "attr1": "attr1-value", + }, + }, + }, + { + name: "PubSub_Binary_Mode", + contentType: "application/json", + headers: map[string]string{ + "ce-id": "1234-5678", + "ce-type": "google.cloud.pubsub.topic.v1.messagePublished", + "ce-source": "//pubsub.googleapis.com/projects/test-project/topics/test-topic", + "ce-specversion": "1.0", + }, + body: []byte(pubsubPayload), + expectedPayload: map[string]any{ + "data": "test message 3", + "attributes": map[string]any{ + "attr1": "attr1-value", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mockAgentRunCount := 0 + var receivedContent string + testAgent, err := agent.New(agent.Config{ + Name: "test-agent", + Run: func(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + mockAgentRunCount++ + userContent := ctx.UserContent() + if userContent != nil && len(userContent.Parts) > 0 { + receivedContent = userContent.Parts[0].Text + } + yield(&session.Event{ID: "success-event"}, nil) + } + }, + }) + if err != nil { + t.Fatalf("agent.New failed: %v", err) + } + + sessionService := &fakes.FakeSessionService{Sessions: make(map[fakes.SessionKey]fakes.TestSession)} + agentLoader := agent.NewSingleLoader(testAgent) + controller := triggers.NewEventarcController(sessionService, agentLoader, nil, nil, runner.PluginConfig{}, defaultTriggerConfig) + + req, err := http.NewRequest(http.MethodPost, "/apps/test-agent/triggers/eventarc", bytes.NewBuffer(tc.body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", tc.contentType) + for k, v := range tc.headers { + req.Header.Set(k, v) + } + req = mux.SetURLVars(req, map[string]string{"app_name": "test-agent"}) + rr := httptest.NewRecorder() + + controller.EventarcTriggerHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. Body: %s", rr.Code, rr.Body.String()) + } + + if mockAgentRunCount != 1 { + t.Errorf("expected 1 run attempt, got %d", mockAgentRunCount) + } + + if tc.expectedPayload != nil { + var gotPayload map[string]any + if err := json.Unmarshal([]byte(receivedContent), &gotPayload); err != nil { + t.Fatalf("failed to unmarshal received content: %v", err) + } + if diff := cmp.Diff(tc.expectedPayload, gotPayload); diff != "" { + t.Errorf("payload mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/server/adkrest/controllers/triggers/pubsub.go b/server/adkrest/controllers/triggers/pubsub.go index fea0912ca..b1338a058 100644 --- a/server/adkrest/controllers/triggers/pubsub.go +++ b/server/adkrest/controllers/triggers/pubsub.go @@ -27,7 +27,7 @@ import ( "google.golang.org/adk/session" ) -const defaultUserID = "pubsub-caller" +const pubSubDefaultUserID = "pubsub-caller" // PubSubController handles the PubSub trigger endpoints. type PubSubController struct { @@ -52,22 +52,47 @@ func NewPubSubController(sessionService session.Service, agentLoader agent.Loade // PubSubTriggerHandler handles the PubSub trigger endpoint. func (c *PubSubController) PubSubTriggerHandler(w http.ResponseWriter, r *http.Request) { + // Parse the request to the request model. + var req models.PubSubTriggerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("failed to decode request: %v", err)) + return + } + + agentMessage, err := messageContentFromPubSub(req) + if err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("failed to retrieve message content: %v", err)) + return + } + + appName, err := appName(r) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to retrieve app name: %v", err)) + return + } + + userID := req.Subscription + if userID == "" { + userID = pubSubDefaultUserID + } + + // Semaphore limits concurrent agent calls based on the TriggerConfig. if c.semaphore != nil { c.semaphore <- struct{}{} defer func() { <-c.semaphore }() } - // Parse the request to the request model. - var req models.PubSubTriggerRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, fmt.Sprintf("failed to decode request: %v", err)) + if _, err := c.runner.RunAgent(r.Context(), appName, userID, agentMessage); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to run agent: %v", err)) return } - // Decode base64 message data. + respondSuccess(w) +} + +func messageContentFromPubSub(req models.PubSubTriggerRequest) (string, error) { messageContent := make(map[string]any) if len(req.Message.Data) > 0 { - // Avoids encoding the data twice later with json.Marshal. messageContent["data"] = string(req.Message.Data) } // Add attributes to the messageContent if present @@ -76,26 +101,12 @@ func (c *PubSubController) PubSubTriggerHandler(w http.ResponseWriter, r *http.R } if len(messageContent) == 0 { - respondError(w, http.StatusBadRequest, "empty message data and attributes") - return + return "", fmt.Errorf("empty message data and attributes") } agentMessage, err := json.Marshal(messageContent) if err != nil { - respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to marshal agent message: %v", err)) - return + return "", fmt.Errorf("failed to marshal agent message: %v", err) } - - appName, err := appName(r) - if err != nil { - respondError(w, http.StatusInternalServerError, err.Error()) - return - } - - if _, err := c.runner.RunAgent(r.Context(), appName, req.Subscription, string(agentMessage)); err != nil { - respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to run agent: %v", err)) - return - } - - respondSuccess(w) + return string(agentMessage), nil } diff --git a/server/adkrest/controllers/triggers/triggers.go b/server/adkrest/controllers/triggers/triggers.go index 6bf6e9aca..32beaa507 100644 --- a/server/adkrest/controllers/triggers/triggers.go +++ b/server/adkrest/controllers/triggers/triggers.go @@ -45,10 +45,6 @@ type RetriableRunner struct { } func (r *RetriableRunner) RunAgent(ctx context.Context, appName, userID, messageContent string) ([]*session.Event, error) { - if userID == "" { - userID = defaultUserID - } - // Each retry = new session sessReq := &session.CreateRequest{ AppName: appName, diff --git a/server/adkrest/internal/models/triggers.go b/server/adkrest/internal/models/triggers.go index 08e6e5a15..be6bc05c4 100644 --- a/server/adkrest/internal/models/triggers.go +++ b/server/adkrest/internal/models/triggers.go @@ -14,6 +14,8 @@ package models +import "encoding/json" + // PubSubTriggerRequest represents the request for the PubSub trigger. // See: https://cloud.google.com/pubsub/docs/push#receive_push type PubSubTriggerRequest struct { @@ -37,6 +39,37 @@ type PubSubMessage struct { OrderingKey string `json:"orderingKey,omitempty"` } +// EventarcTriggerRequest represents the request for the Eventarc trigger. +// Eventarc / CloudEvents request format. +// +// Eventarc delivers events as CloudEvents over HTTP in two modes: +// +// 1. **Structured content mode** (JSON body): All CloudEvents attributes +// and the event data are in the JSON body. Used by direct HTTP callers. +// 2. **Binary content mode** (Eventarc default): CloudEvents attributes are +// sent as “ce-*“ HTTP headers, and the body contains only the event +// data — typically a Pub/Sub message wrapper for Pub/Sub-sourced events: +// “{"message": {"data": "", ...}, "subscription": "..."}“. +// +// See: https://cloud.google.com/eventarc/docs/cloudevents +type EventarcTriggerRequest struct { + // Unique identifier for the event + ID string `json:"id"` + // Identifies the source of the event + Source string `json:"source"` + // The type of event data + Type string `json:"type"` + // The CloudEvents specification version used for this event + SpecVersion string `json:"specversion"` + // Event generation time, in RFC 3339 format (optional) + Time string `json:"time,omitempty"` + // In structured mode, ``data`` is always present. + // In binary mode, the entire body is the data (often a Pub/Sub wrapper). + // But sometimes it can be just a JSON object. + // E.g. https://googleapis.github.io/google-cloudevents/examples/binary/firestore/DocumentEventData-simple.json + Data json.RawMessage `json:"data,omitempty"` +} + // TriggerResponse represents the standard response for Pub/Sub and Eventarc triggers. type TriggerResponse struct { // Processing status: 'success' or error message. From adeb90bbedd681c00b34b5f81e259f4edb1c4d35 Mon Sep 17 00:00:00 2001 From: Mikalai Senkevich Date: Mon, 13 Apr 2026 14:11:12 +0200 Subject: [PATCH 17/86] feat: Add agent engine deployment to ADK GO CLI (#715) Co-authored-by: Karol Droste --- cmd/adkgo/adkgo.go | 1 + .../deploy/agentengine/agentengine.go | 308 ++++++++++++++++++ cmd/launcher/web/web.go | 2 +- go.mod | 32 +- go.sum | 72 ++-- 5 files changed, 362 insertions(+), 53 deletions(-) create mode 100644 cmd/adkgo/internal/deploy/agentengine/agentengine.go diff --git a/cmd/adkgo/adkgo.go b/cmd/adkgo/adkgo.go index b4575e08f..d87ec3f82 100644 --- a/cmd/adkgo/adkgo.go +++ b/cmd/adkgo/adkgo.go @@ -16,6 +16,7 @@ package main import ( + _ "google.golang.org/adk/cmd/adkgo/internal/deploy/agentengine" _ "google.golang.org/adk/cmd/adkgo/internal/deploy/cloudrun" "google.golang.org/adk/cmd/adkgo/internal/root" ) diff --git a/cmd/adkgo/internal/deploy/agentengine/agentengine.go b/cmd/adkgo/internal/deploy/agentengine/agentengine.go new file mode 100644 index 000000000..a20f6ca98 --- /dev/null +++ b/cmd/adkgo/internal/deploy/agentengine/agentengine.go @@ -0,0 +1,308 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package agentengine handles command line parameters and execution logic for agentengine deployment. + +package agentengine + +import ( + "context" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + aiplatform "cloud.google.com/go/aiplatform/apiv1" + "cloud.google.com/go/aiplatform/apiv1/aiplatformpb" + "github.com/spf13/cobra" + "google.golang.org/api/option" + + "google.golang.org/adk/cmd/adkgo/internal/deploy" + "google.golang.org/adk/internal/cli/util" +) + +type gCloudFlags struct { + region string + projectName string +} + +type agentEngineServiceFlags struct { + name string + displayName string + serverPort int + api bool // enable api or not +} + +type buildFlags struct { + tempDir string + execPath string + execFile string + dockerfileBuildPath string + archivePath string +} + +type sourceFlags struct { + srcBasePath string + entryPointPath string + origEntryPointPath string + sourceDir string +} + +type deployAgentEngineFlags struct { + gcloud gCloudFlags + agentEngine agentEngineServiceFlags + build buildFlags + source sourceFlags +} + +var flags deployAgentEngineFlags + +// agentEngineCmd represents the agentEngine command +var agentEngineCmd = &cobra.Command{ + Use: "agentengine", + Short: "Deploys the application to Agent Engine.", + Long: `Deploys the application to Agent Engine. It creates a source archive, uploads it to create a Reasoning Engine, and cleans up temporary files.`, + RunE: func(cmd *cobra.Command, args []string) error { + return flags.deployOnagentEngine() + }, +} + +// init creates flags and adds subcommand to parent +func init() { + deploy.DeployCmd.AddCommand(agentEngineCmd) + + agentEngineCmd.PersistentFlags().StringVarP(&flags.gcloud.region, "region", "r", "", "GCP Region") + agentEngineCmd.PersistentFlags().StringVarP(&flags.gcloud.projectName, "project_name", "p", "", "GCP Project Name") + agentEngineCmd.PersistentFlags().StringVarP(&flags.agentEngine.name, "name", "s", "", "Agent Engine name") + agentEngineCmd.PersistentFlags().StringVarP(&flags.build.tempDir, "temp_dir", "t", "", "Temp dir for build, defaults to os.TempDir() if not specified") + agentEngineCmd.PersistentFlags().IntVar(&flags.agentEngine.serverPort, "server_port", 8080, "agentEngine server port") + agentEngineCmd.PersistentFlags().StringVarP(&flags.source.entryPointPath, "entry_point_path", "e", "", "Path to an entry point (go 'main')") + agentEngineCmd.PersistentFlags().StringVarP(&flags.source.sourceDir, "source_dir", "d", "", "Directory to archive, defaults to current working directory") + agentEngineCmd.PersistentFlags().BoolVar(&flags.agentEngine.api, "api", true, "Enable API") +} + +// computeFlags uses command line arguments to create a full config +func (f *deployAgentEngineFlags) computeFlags() error { + return util.LogStartStop("Computing flags & preparing temp", + func(p util.Printer) error { + f.source.origEntryPointPath = flags.source.entryPointPath + absp, err := filepath.Abs(flags.source.entryPointPath) + if err != nil { + return fmt.Errorf("cannot make an absolute path from '%v': %w", f.source.entryPointPath, err) + } + f.source.entryPointPath = absp + + if flags.build.tempDir == "" { + flags.build.tempDir = os.TempDir() + } + absp, err = filepath.Abs(flags.build.tempDir) + if err != nil { + return fmt.Errorf("cannot make an absolute path from '%v': %w", f.build.tempDir, err) + } + f.build.tempDir, err = os.MkdirTemp(absp, "agentEngine_"+time.Now().Format("20060102_150405__")+"*") + if err != nil { + return fmt.Errorf("cannot create a temporary sub directory in '%v': %w", absp, err) + } + p("Using temp dir:", f.build.tempDir) + + // come up with a executable name based on entry point path + dir, file := path.Split(f.source.entryPointPath) + f.source.srcBasePath = dir + f.source.entryPointPath = file + if f.build.execPath == "" { + exec, err := util.StripExtension(f.source.entryPointPath, ".go") + if err != nil { + return fmt.Errorf("cannot strip '.go' extension from entry point path '%v': %w", f.source.entryPointPath, err) + } + f.build.execFile = exec + f.build.execPath = path.Join(f.build.tempDir, exec) + } + f.build.dockerfileBuildPath = path.Join(f.build.tempDir, "Dockerfile") + f.build.archivePath = path.Join(f.build.tempDir, "archive.tgz") + + dateTimeString := time.Now().Format(time.RFC3339) + f.agentEngine.displayName = f.agentEngine.name + if f.agentEngine.displayName == "" { + f.agentEngine.displayName = "ADK Agent: " + dateTimeString + } + + return nil + }) +} + +func (f *deployAgentEngineFlags) cleanTemp() error { + return util.LogStartStop("Cleaning temp", + func(p util.Printer) error { + p("Clean temp starting with", f.build.tempDir) + err := os.RemoveAll(f.build.tempDir) + if err != nil { + return fmt.Errorf("failed to clean temp directory %v: %w", f.build.tempDir, err) + } + return nil + }) +} + +// prepareDockerfile creates a temporary Dockerfile which will be executed by agentEngine +func (f *deployAgentEngineFlags) prepareDockerfile() error { + return util.LogStartStop("Preparing Dockerfile", + func(p util.Printer) error { + p("Writing:", f.build.dockerfileBuildPath) + + var b strings.Builder + b.WriteString(` +FROM golang:1.25 as builder +WORKDIR /app +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ` + f.build.execFile + ` ` + f.source.origEntryPointPath + ` + +FROM gcr.io/distroless/static-debian11 + +COPY --from=builder /app/` + f.build.execFile + ` /app/` + f.build.execFile + ` +EXPOSE ` + strconv.Itoa(flags.agentEngine.serverPort) + ` +# Command to run the executable when the container starts +CMD ["/app/` + f.build.execFile + `", "web", "-port", "` + strconv.Itoa(flags.agentEngine.serverPort) + `"`) + + if flags.agentEngine.api { + b.WriteString(`, "api"`) + } + + b.WriteString(`]`) + return os.WriteFile(f.build.dockerfileBuildPath, []byte(b.String()), 0o600) + }) +} + +// createArchive creates a tar archive containing the source code and Dockerfile +func (f *deployAgentEngineFlags) createArchive() error { + return util.LogStartStop("Creating source archive", + func(p util.Printer) error { + workspaceRoot := f.source.sourceDir + if workspaceRoot == "" { + var err error + workspaceRoot, err = os.Getwd() + if err != nil { + return fmt.Errorf("cannot get current working directory: %w", err) + } + } + p("Creating:", f.build.archivePath) + cmd := exec.Command("tar", "-czf", f.build.archivePath, + "-C", workspaceRoot, "--exclude=.git", "--exclude=adkgo", ".", + "-C", f.build.tempDir, "Dockerfile") + return util.LogCommand(cmd, p) + }) +} + +// gcloudDeployToAgentEngine invokes gcloud to deploy source on agentEngine +func (f *deployAgentEngineFlags) gcloudDeployToAgentEngine() error { + return util.LogStartStop("Deploying to Agent Engine", + func(p util.Printer) error { + ctx := context.Background() + parent := fmt.Sprintf("projects/%s/locations/%s", f.gcloud.projectName, f.gcloud.region) + endpoint := fmt.Sprintf("%s-aiplatform.googleapis.com:443", f.gcloud.region) + client, err := aiplatform.NewReasoningEngineClient(ctx, option.WithEndpoint(endpoint)) + if err != nil { + return fmt.Errorf("cannot create ReasoningEngineClient: %w", err) + } + defer func() { + if err := client.Close(); err != nil { + p("Warning: failed to close ReasoningEngineClient: %v", err) + } + }() + + archiveContent, err := os.ReadFile(f.build.archivePath) + if err != nil { + return fmt.Errorf("cannot read archive file: %w", err) + } + + req := &aiplatformpb.CreateReasoningEngineRequest{ + Parent: parent, + ReasoningEngine: &aiplatformpb.ReasoningEngine{ + DisplayName: f.agentEngine.displayName, + Spec: &aiplatformpb.ReasoningEngineSpec{ + DeploymentSource: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec_{ + SourceCodeSpec: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec{ + Source: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec_InlineSource_{ + InlineSource: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec_InlineSource{ + SourceArchive: archiveContent, + }, + }, + LanguageSpec: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec_ImageSpec_{ + ImageSpec: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec_ImageSpec{}, + }, + }, + }, + AgentFramework: "google-adk", + DeploymentSpec: &aiplatformpb.ReasoningEngineSpec_DeploymentSpec{ + Env: []*aiplatformpb.EnvVar{ + {Name: "GOOGLE_CLOUD_REGION", Value: f.gcloud.region}, + {Name: "NUM_WORKERS", Value: "1"}, + {Name: "GOOGLE_CLOUD_AGENT_ENGINE_ENABLE_TELEMETRY", Value: "true"}, + {Name: "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", Value: "true"}, + }, + SecretEnv: []*aiplatformpb.SecretEnvVar{ + {Name: "GOOGLE_API_KEY", SecretRef: &aiplatformpb.SecretRef{Secret: "GOOGLE_API_KEY", Version: "latest"}}, + }, + }, + }, + }, + } + p("Sending CreateReasoningEngine request...") + op, err := client.CreateReasoningEngine(ctx, req) + if err != nil { + return fmt.Errorf("CreateReasoningEngine failed: %w", err) + } + + p("Waiting for operation to complete...") + re, err := op.Wait(ctx) + if err != nil { + return fmt.Errorf("operation failed: %w", err) + } + + p("Deployed Reasoning Engine:", re.Name) + p("Display Name:", re.DisplayName) + + return nil + }) +} + +// deployOnagentEngine executes the sequence of actions preparing and deploying the agent to agentEngine +func (f *deployAgentEngineFlags) deployOnagentEngine() error { + fmt.Println(flags) + + err := f.computeFlags() + if err != nil { + return err + } + err = f.prepareDockerfile() + if err != nil { + return err + } + err = f.createArchive() + if err != nil { + return err + } + err = f.gcloudDeployToAgentEngine() + if err != nil { + return err + } + err = f.cleanTemp() + if err != nil { + return err + } + + return nil +} diff --git a/cmd/launcher/web/web.go b/cmd/launcher/web/web.go index 94524a544..73c0e957f 100644 --- a/cmd/launcher/web/web.go +++ b/cmd/launcher/web/web.go @@ -112,7 +112,7 @@ func (w *webLauncher) Parse(args []string) ([]string, error) { keyToSublauncher := make(map[string]Sublauncher) for _, l := range w.sublaunchers { if _, ok := keyToSublauncher[l.Keyword()]; ok { - return nil, fmt.Errorf("cannot create universal launcher. Keywords for sublaunchers should be unique and they are not: '%s'", l.Keyword()) + return nil, fmt.Errorf("cannot create web launcher. Keywords for sublaunchers should be unique and they are not: '%s'", l.Keyword()) } keyToSublauncher[l.Keyword()] = l } diff --git a/go.mod b/go.mod index 37438ba76..68e5e1b26 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( cloud.google.com/go v0.123.0 - cloud.google.com/go/aiplatform v1.105.0 + cloud.google.com/go/aiplatform v1.121.0 cloud.google.com/go/storage v1.56.1 github.com/a2aproject/a2a-go v0.3.13 github.com/awalterschulze/gographviz v2.0.3+incompatible @@ -24,9 +24,9 @@ require ( go.opentelemetry.io/otel/log v0.16.0 go.opentelemetry.io/otel/sdk v1.40.0 go.opentelemetry.io/otel/trace v1.40.0 - golang.org/x/oauth2 v0.34.0 - golang.org/x/sync v0.19.0 - google.golang.org/api v0.252.0 + golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.20.0 + google.golang.org/api v0.272.0 google.golang.org/genai v1.40.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 @@ -40,11 +40,11 @@ require github.com/hashicorp/golang-lru/v2 v2.0.7 require ( cel.dev/expr v0.25.1 // indirect - cloud.google.com/go/auth v0.17.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect - cloud.google.com/go/longrunning v0.7.0 // indirect + cloud.google.com/go/longrunning v0.8.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect @@ -61,8 +61,8 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.18.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -84,14 +84,14 @@ require ( go.opentelemetry.io/otel/sdk/log v0.16.0 go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto v0.0.0-20251014184007-4626949a642f // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect modernc.org/libc v1.22.3 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 51a556ea9..2ac6ef276 100644 --- a/go.sum +++ b/go.sum @@ -2,20 +2,20 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/aiplatform v1.105.0 h1:Tbc2iEp7vbzgk6Vs4QexfNo8/nl+E+Na+FEreRZdhcM= -cloud.google.com/go/aiplatform v1.105.0/go.mod h1:4rwKOMdubQOND81AlO3EckcskvEFCYSzXKfn42GMm8k= -cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= -cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= +cloud.google.com/go/aiplatform v1.121.0 h1:8y8sNfVAW1DVhFbSbI7d8rrqBGGJFk6EoV6atidlyQc= +cloud.google.com/go/aiplatform v1.121.0/go.mod h1:juMdDWeNphHV40KhWdN+563zNCOKNmLJjk5D2TA43ls= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= -cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= -cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= -cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/storage v1.56.1 h1:n6gy+yLnHn0hTwBFzNn8zJ1kqWfR91wzdM8hjRF4wP0= @@ -84,10 +84,10 @@ github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8 github.com/google/safehtml v0.1.0/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= -github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= +github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -173,37 +173,37 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.252.0 h1:xfKJeAJaMwb8OC9fesr369rjciQ704AjU/psjkKURSI= -google.golang.org/api v0.252.0/go.mod h1:dnHOv81x5RAmumZ7BWLShB/u7JZNeyalImxHmtTHxqw= +google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= google.golang.org/genai v1.40.0 h1:kYxyQSH+vsib8dvsgyLJzsVEIv5k3ZmHJyVqdvGncmc= google.golang.org/genai v1.40.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto v0.0.0-20251014184007-4626949a642f h1:vLd1CJuJOUgV6qijD7KT5Y2ZtC97ll4dxjTUappMnbo= -google.golang.org/genproto v0.0.0-20251014184007-4626949a642f/go.mod h1:PI3KrSadr00yqfv6UDvgZGFsmLqeRIwt8x4p5Oo7CdM= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 203a070ee7e4f02701e79785298c7146b4813ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Mon, 13 Apr 2026 13:22:46 +0100 Subject: [PATCH 18/86] Update content processor to exclude toolconfirmation.FunctionCallName (#717) * Update content processor exclude * remove mds --------- Co-authored-by: Dmitry Pasiukevich <20398573+dpasiukevich@users.noreply.github.com> --- internal/llminternal/contents_processor.go | 23 +++++++---- .../llminternal/contents_processor_test.go | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/internal/llminternal/contents_processor.go b/internal/llminternal/contents_processor.go index 3f776305d..360f953e7 100644 --- a/internal/llminternal/contents_processor.go +++ b/internal/llminternal/contents_processor.go @@ -29,6 +29,7 @@ import ( "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" "google.golang.org/adk/session" + "google.golang.org/adk/tool/toolconfirmation" ) // ContentRequestProcessor populates the LLMRequest's Contents based on @@ -82,7 +83,7 @@ func buildContentsDefault(agentName, invocationBranch string, events []*session. if !eventBelongsToBranch(invocationBranch, ev) { continue } - if isAuthEvent(ev) { + if shouldExcludeEvent(ev) { continue } if isOtherAgentReply(agentName, ev) { @@ -507,19 +508,27 @@ func stringify(v any) string { // requestEUCFunctionCallName is a special function to handle credential // request. -const requestEUCFunctionCallName = "adk_request_credential" +const ( + requestEUCFunctionCallName = "adk_request_credential" +) -func isAuthEvent(ev *session.Event) bool { +func shouldExcludeEvent(ev *session.Event) bool { c := utils.Content(ev) if c == nil { return false } for _, p := range c.Parts { - if p.FunctionCall != nil && p.FunctionCall.Name == requestEUCFunctionCallName { - return true + if p.FunctionCall != nil { + switch p.FunctionCall.Name { + case requestEUCFunctionCallName, toolconfirmation.FunctionCallName: + return true + } } - if p.FunctionResponse != nil && p.FunctionResponse.Name == requestEUCFunctionCallName { - return true + if p.FunctionResponse != nil { + switch p.FunctionResponse.Name { + case requestEUCFunctionCallName, toolconfirmation.FunctionCallName: + return true + } } } return false diff --git a/internal/llminternal/contents_processor_test.go b/internal/llminternal/contents_processor_test.go index 572089bfa..ac5dbbdb3 100644 --- a/internal/llminternal/contents_processor_test.go +++ b/internal/llminternal/contents_processor_test.go @@ -308,6 +308,46 @@ func TestContentsRequestProcessor(t *testing.T) { }, }, }, + { + name: "ExcludeToolConfirmation", + events: []*session.Event{ + { + Author: "AgentA", + LLMResponse: model.LLMResponse{ + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{ + { + FunctionCall: &genai.FunctionCall{ + ID: "call_confirm_123", + Name: "adk_request_confirmation", + Args: map[string]any{"message": "Confirm delete?"}, + }, + }, + }, + }, + }, + }, + { + Author: "user", + LLMResponse: model.LLMResponse{ + Content: &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + { + FunctionResponse: &genai.FunctionResponse{ + ID: "call_confirm_123", + Name: "adk_request_confirmation", + Response: map[string]any{"confirmed": true}, + }, + }, + }, + }, + }, + }, + }, + want: nil, + }, { name: "FilterByBranch", branch: "branch1.task1", From 2ef1b4f70b971504cbcb5a0ce35b15b34a393e6d Mon Sep 17 00:00:00 2001 From: Anastasia Date: Mon, 13 Apr 2026 14:39:06 +0200 Subject: [PATCH 19/86] feat: update cloudrun deployment script to include eventarc subrouter (#716) Adds the eventarc trigger configuration to the cloudrun deployment docker container --- cmd/adkgo/internal/deploy/cloudrun/cloudrun.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go b/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go index 108812fd3..e3688f23e 100644 --- a/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go +++ b/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go @@ -52,6 +52,8 @@ type cloudRunServiceFlags struct { webui bool // enable webui or not pubsub bool // enable pubsub trigger or not pubsubTrigger triggerConfigFlags + eventarc bool // enable eventarc trigger or not + eventarcTrigger triggerConfigFlags } type localProxyFlags struct { @@ -113,6 +115,11 @@ func init() { cloudrunCmd.PersistentFlags().DurationVar(&flags.cloudRun.pubsubTrigger.baseDelay, "pubsub_base_delay", 1*time.Second, "Base delay for PubSub trigger retry exponential backoff") cloudrunCmd.PersistentFlags().DurationVar(&flags.cloudRun.pubsubTrigger.maxDelay, "pubsub_max_delay", 10*time.Second, "Maximum delay for PubSub trigger retry exponential backoff") cloudrunCmd.PersistentFlags().IntVar(&flags.cloudRun.pubsubTrigger.maxRuns, "pubsub_max_concurrent_runs", 100, "Maximum concurrent PubSub trigger runs") + cloudrunCmd.PersistentFlags().BoolVar(&flags.cloudRun.eventarc, "eventarc", false, "Enable Eventarc subrouter") + cloudrunCmd.PersistentFlags().IntVar(&flags.cloudRun.eventarcTrigger.maxRetries, "eventarc_max_retries", 3, "Maximum retries for HTTP 429 errors from Eventarc triggers") + cloudrunCmd.PersistentFlags().DurationVar(&flags.cloudRun.eventarcTrigger.baseDelay, "eventarc_base_delay", 1*time.Second, "Base delay for Eventarc trigger retry exponential backoff") + cloudrunCmd.PersistentFlags().DurationVar(&flags.cloudRun.eventarcTrigger.maxDelay, "eventarc_max_delay", 10*time.Second, "Maximum delay for Eventarc trigger retry exponential backoff") + cloudrunCmd.PersistentFlags().IntVar(&flags.cloudRun.eventarcTrigger.maxRuns, "eventarc_max_concurrent_runs", 100, "Maximum concurrent Eventarc trigger runs") } // computeFlags uses command line arguments to create a full config @@ -217,6 +224,13 @@ CMD ["/app/` + f.build.execFile + `", "web", "-port", "` + strconv.Itoa(flags.cl b.WriteString(fmt.Sprintf(`, "--trigger_max_delay", "%s"`, flags.cloudRun.pubsubTrigger.maxDelay.String())) b.WriteString(fmt.Sprintf(`, "--trigger_max_concurrent_runs", "%d"`, flags.cloudRun.pubsubTrigger.maxRuns)) } + if flags.cloudRun.eventarc { + b.WriteString(`, "eventarc"`) + b.WriteString(fmt.Sprintf(`, "--trigger_max_retries", "%d"`, flags.cloudRun.eventarcTrigger.maxRetries)) + b.WriteString(fmt.Sprintf(`, "--trigger_base_delay", "%s"`, flags.cloudRun.eventarcTrigger.baseDelay.String())) + b.WriteString(fmt.Sprintf(`, "--trigger_max_delay", "%s"`, flags.cloudRun.eventarcTrigger.maxDelay.String())) + b.WriteString(fmt.Sprintf(`, "--trigger_max_concurrent_runs", "%d"`, flags.cloudRun.eventarcTrigger.maxRuns)) + } b.WriteString(`]`) return os.WriteFile(f.build.dockerfileBuildPath, []byte(b.String()), 0o600) }) From 336105ae53f1673315f0232d9344a017bd604eb5 Mon Sep 17 00:00:00 2001 From: Artsiom Shut <63152812+foxfrikses@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:39:46 +0200 Subject: [PATCH 20/86] feat: Allow toolsets implement toolinternal.RequestProcessor. (#730) While for tools it's a requirement, for toolsets it's optional. The ability of toolsets to implement RequestProcessor is required to implement Agent Skills feature as a toolset. --- agent/llmagent/state_agent_test.go | 68 ++++++++++++++++ internal/llminternal/base_flow.go | 29 ++++++- internal/llminternal/base_flow_test.go | 108 +++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 4 deletions(-) diff --git a/agent/llmagent/state_agent_test.go b/agent/llmagent/state_agent_test.go index d8370a2b9..fef38ba37 100644 --- a/agent/llmagent/state_agent_test.go +++ b/agent/llmagent/state_agent_test.go @@ -29,6 +29,7 @@ import ( "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" "google.golang.org/adk/runner" "google.golang.org/adk/session" @@ -649,3 +650,70 @@ func TestToolCallbacksAgent(t *testing.T) { }) } } + +type mockToolset struct{} + +func (m *mockToolset) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { + utils.AppendInstructions(req, "Extra instruction from mockToolset") + return nil +} +func (m *mockToolset) Name() string { return "test_toolset" } +func (m *mockToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { return nil, nil } + +func TestAgentToolsetPreprocessEffect(t *testing.T) { + var capturedReq *model.LLMRequest + ctx := t.Context() + service := session.InMemoryService() + fakeLLM := &FakeLLM{ + GenerateContentFunc: func(ctx context.Context, req *model.LLMRequest, stream bool) (model.LLMResponse, error) { + capturedReq = req + return model.LLMResponse{ + Content: genai.NewContentFromText("fake response", genai.RoleModel), + }, nil + }, + } + toolset := &mockToolset{} + agentConfig := llmagent.Config{ + Name: "toolset_effect_agent", + Instruction: "Agent instruction.", + Model: fakeLLM, + Toolsets: []tool.Toolset{toolset}, + } + rootAgent, err := llmagent.New(agentConfig) + if err != nil { + t.Fatalf("Failed to create LLM Agent: %v", err) + } + runner, err := runner.New(runner.Config{ + AppName: "test_app", + Agent: rootAgent, + SessionService: service, + }) + if err != nil { + t.Fatalf("Failed to create runner: %v", err) + } + createSessionReq := &session.CreateRequest{AppName: "test_app", UserID: "test_user"} + createSessionResp, err := service.Create(ctx, createSessionReq) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + sessionID := createSessionResp.Session.ID() + userContent := genai.NewContentFromText("Hello", genai.RoleUser) + + eventStream := runner.Run(ctx, "test_user", sessionID, userContent, agent.RunConfig{}) + + for _, err := range eventStream { + if err != nil { + t.Fatalf("Error during agent run: %v", err) + } + } + if capturedReq == nil { + t.Fatalf("LLMRequest was not captured") + } + systemInstruction := "" + if capturedReq.Config != nil && capturedReq.Config.SystemInstruction != nil { + systemInstruction = strings.Join(utils.TextParts(capturedReq.Config.SystemInstruction), " ") + } + if got, want := systemInstruction, "Extra instruction from mockToolset"; !strings.Contains(got, want) { + t.Errorf("got SystemInstruction = %q, want it to contain %q", got, want) + } +} diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index 4d0012e8e..1a5dfa021 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -262,10 +262,13 @@ func (f *Flow) preprocess(ctx agent.InvocationContext, req *model.LLMRequest) it } } - if f.Tools != nil { - if err := toolPreprocess(ctx, req, f.Tools); err != nil { - yield(nil, err) - } + if err := toolPreprocess(ctx, req, f.Tools); err != nil { + yield(nil, err) + return + } + if err := toolsetPreprocess(ctx, req); err != nil { + yield(nil, err) + return } } } @@ -288,6 +291,24 @@ func toolPreprocess(ctx agent.InvocationContext, req *model.LLMRequest, tools [] return nil } +func toolsetPreprocess(ctx agent.InvocationContext, req *model.LLMRequest) error { + llmAgent, ok := ctx.Agent().(Agent) + if !ok { + return nil + } + for _, toolset := range Reveal(llmAgent).Toolsets { + processor, ok := toolset.(toolinternal.RequestProcessor) + if !ok { + continue // Not all toolsets implement RequestProcessor. + } + toolCtx := toolinternal.NewToolContext(ctx, "", nil, nil) + if err := processor.ProcessRequest(toolCtx, req); err != nil { + return fmt.Errorf("process request by toolset %q: %w", toolset.Name(), err) + } + } + return nil +} + func newResponseWithEventID(resp *model.LLMResponse) *responseWithEventID { return &responseWithEventID{resp, uuid.New().String()} } diff --git a/internal/llminternal/base_flow_test.go b/internal/llminternal/base_flow_test.go index 9a8f2ec64..3b211df9c 100644 --- a/internal/llminternal/base_flow_test.go +++ b/internal/llminternal/base_flow_test.go @@ -21,6 +21,7 @@ import ( "github.com/google/go-cmp/cmp" "google.golang.org/genai" + "google.golang.org/adk/agent" icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/internal/toolinternal" "google.golang.org/adk/model" @@ -68,6 +69,31 @@ func (m *mockFunctionTool) Declaration() *genai.FunctionDeclaration { return nil } +type mockToolset struct { + name string +} + +func (m *mockToolset) Name() string { return m.name } +func (m *mockToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { + return nil, nil +} + +type mockRequestProcessorToolset struct { + name string + process func(ctx tool.Context, req *model.LLMRequest) error +} + +func (m *mockRequestProcessorToolset) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { + if m.process != nil { + return m.process(ctx, req) + } + return nil +} +func (m *mockRequestProcessorToolset) Name() string { return m.name } +func (m *mockRequestProcessorToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { + return nil, nil +} + type testCase struct { name string tool toolinternal.FunctionTool @@ -575,3 +601,85 @@ func TestMergeEventActions(t *testing.T) { }) } } + +func TestPreprocess_Toolset(t *testing.T) { + noOpAgent, err := agent.New(agent.Config{Name: "no-op"}) + if err != nil { + t.Fatalf("Failed to create agent: %v", err) + } + + tests := []struct { + name string + agent agent.Agent + wantModel string + wantError bool + }{ + { + name: "agent not llminternal.Agent", + agent: noOpAgent, + wantError: false, + }, + { + name: "agent has no toolsets", + agent: &mockLLMAgent{s: &State{}}, + wantError: false, + }, + { + name: "toolset implements RequestProcessor, error", + agent: &mockLLMAgent{ + s: &State{ + Toolsets: []tool.Toolset{&mockRequestProcessorToolset{ + name: "toolset", + process: func(_ tool.Context, _ *model.LLMRequest) error { + return errors.New("process error") + }, + }}, + }, + }, + wantError: true, + }, + { + name: "toolsets, success", + agent: &mockLLMAgent{ + s: &State{ + Toolsets: []tool.Toolset{ + &mockToolset{name: "toolset_without_processor"}, + &mockRequestProcessorToolset{ + name: "toolset_with_processor", + process: func(_ tool.Context, req *model.LLMRequest) error { + req.Model = "modified-model" + return nil + }, + }, + }, + }, + }, + wantError: false, + wantModel: "modified-model", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f := &Flow{} + ctx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{Agent: tc.agent}) + req := &model.LLMRequest{} + + events := f.preprocess(ctx, req) + + var gotErr error + for _, err := range events { + if err != nil { + gotErr = err + break + } + } + if (gotErr != nil) != tc.wantError { + t.Errorf("preprocess() error = %v, wantError %v", gotErr, tc.wantError) + } + if req.Model != tc.wantModel { + t.Errorf("preprocess() model = %s, wantModel %s", req.Model, tc.wantModel) + } + }) + } +} From a68fb11068429e6a20a4ddbb181422e806800942 Mon Sep 17 00:00:00 2001 From: Artsiom Shut <63152812+foxfrikses@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:33:59 +0200 Subject: [PATCH 21/86] feat: Implement SkillToolset. (#733) SkillToolset encapsulates the Agent Skills feature. This toolset ensures agent is equipped with all the tools needed to use skills and is provided with proper instructions. What skill toolset enable agent to do: - List available skills. - Read instructions of the skills (by skill name). - Read specific resources of the skills (by resource path). Current implementation is very basic, e.g.: - There is no script execution support yet. - Skill activation is history-based: skill instructions and resources are provided to the llm as function call results in conversation history. - Every resource should be specifically mentioned in the SKILL.md file: the agent is unable to list resources available for the skills. --- examples/skills/main.go | 83 ++++++++ .../skills/skills/grocery-prices/SKILL.md | 15 ++ .../grocery-prices/assets/prices_pl.json | 22 +++ .../grocery-prices/assets/prices_us.json | 22 +++ examples/skills/skills/weather/SKILL.md | 12 ++ .../skills/weather/references/weather_pl.json | 6 + .../skills/weather/references/weather_us.json | 6 + .../internal/skilltool/list_skills.go | 70 +++++++ .../internal/skilltool/load_skill.go | 84 ++++++++ .../internal/skilltool/load_skill_resource.go | 80 ++++++++ .../internal/skilltool/tools_test.go | 187 ++++++++++++++++++ tool/skilltoolset/toolset.go | 117 +++++++++++ tool/skilltoolset/toolset_test.go | 125 ++++++++++++ 13 files changed, 829 insertions(+) create mode 100644 examples/skills/main.go create mode 100644 examples/skills/skills/grocery-prices/SKILL.md create mode 100644 examples/skills/skills/grocery-prices/assets/prices_pl.json create mode 100644 examples/skills/skills/grocery-prices/assets/prices_us.json create mode 100644 examples/skills/skills/weather/SKILL.md create mode 100644 examples/skills/skills/weather/references/weather_pl.json create mode 100644 examples/skills/skills/weather/references/weather_us.json create mode 100644 tool/skilltoolset/internal/skilltool/list_skills.go create mode 100644 tool/skilltoolset/internal/skilltool/load_skill.go create mode 100644 tool/skilltoolset/internal/skilltool/load_skill_resource.go create mode 100644 tool/skilltoolset/internal/skilltool/tools_test.go create mode 100644 tool/skilltoolset/toolset.go create mode 100644 tool/skilltoolset/toolset_test.go diff --git a/examples/skills/main.go b/examples/skills/main.go new file mode 100644 index 000000000..946215366 --- /dev/null +++ b/examples/skills/main.go @@ -0,0 +1,83 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package provides an example of using skills via skill toolset. +package main + +import ( + "context" + "log" + "os" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/cmd/launcher" + "google.golang.org/adk/cmd/launcher/full" + "google.golang.org/adk/model/gemini" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/skilltoolset" + "google.golang.org/adk/tool/skilltoolset/skill" +) + +// IMPORTANT: run from the examples/skills directory. +// It relies on the relative path ('./skills') from the examples/skills dir. +// +// go run main.go +// +/* + Example user query: + + What countries do you know current prices for? List the countries. + Then find the prices for each. Then re-organize these prices into a table: + products on one side - countries on another. +*/ +func main() { + ctx := context.Background() + + model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + APIKey: os.Getenv("GOOGLE_API_KEY"), + }) + if err != nil { + log.Fatalf("Failed to create model: %v", err) + } + + skillToolset, err := skilltoolset.New(ctx, skilltoolset.Config{ + Source: skill.NewFileSystemSource(os.DirFS("./skills")), + }) + if err != nil { + log.Fatalf("Failed to create skill toolset: %v", err) + } + + a, err := llmagent.New(llmagent.Config{ + Name: "skills_agent", + Model: model, + Description: "Agent to demonstrate using skills.", + Instruction: "You are a helpful assistant.", + Toolsets: []tool.Toolset{skillToolset}, + }) + if err != nil { + log.Fatalf("Failed to create agent: %v", err) + } + + config := &launcher.Config{ + AgentLoader: agent.NewSingleLoader(a), + } + + l := full.NewLauncher() + if err = l.Execute(ctx, config, os.Args[1:]); err != nil { + log.Fatalf("Run failed: %v\n\n%s", err, l.CommandLineSyntax()) + } +} diff --git a/examples/skills/skills/grocery-prices/SKILL.md b/examples/skills/skills/grocery-prices/SKILL.md new file mode 100644 index 000000000..64403140e --- /dev/null +++ b/examples/skills/skills/grocery-prices/SKILL.md @@ -0,0 +1,15 @@ +--- +name: grocery-prices +description: A skill to calculate grocery prices with country-specific taxes. +--- +# Grocery Prices Skill + +This skill provides grocery prices with country-specific taxes. + +## Instructions +1. When asked about grocery prices or totals for a specific country, check if there is a resource file for the country in the `assets/` directory. +2. The file will be named `prices_.json`. +3. Apply taxes to specific categories based on the country: + - For **US** (us): Add a 20% tax to items in the **"electronics"** and **"alcohol"** categories. + - For **Poland** (pl): Add a 23% tax to items in the **"electronics"** and **"alcohol"** categories. +4. Calculate the final price including tax and report it to the user. diff --git a/examples/skills/skills/grocery-prices/assets/prices_pl.json b/examples/skills/skills/grocery-prices/assets/prices_pl.json new file mode 100644 index 000000000..7d46660bf --- /dev/null +++ b/examples/skills/skills/grocery-prices/assets/prices_pl.json @@ -0,0 +1,22 @@ +[ + { + "item": "Milk", + "price": 4.00, + "category": "Dairy" + }, + { + "item": "Bread", + "price": 5.00, + "category": "Bakery" + }, + { + "item": "Beer", + "price": 12.00, + "category": "Alcohol" + }, + { + "item": "Headphones", + "price": 200.00, + "category": "Electronics" + } +] \ No newline at end of file diff --git a/examples/skills/skills/grocery-prices/assets/prices_us.json b/examples/skills/skills/grocery-prices/assets/prices_us.json new file mode 100644 index 000000000..f78aa91c4 --- /dev/null +++ b/examples/skills/skills/grocery-prices/assets/prices_us.json @@ -0,0 +1,22 @@ +[ + { + "item": "Milk", + "price": 2.50, + "category": "Dairy" + }, + { + "item": "Bread", + "price": 3.00, + "category": "Bakery" + }, + { + "item": "Beer", + "price": 10.00, + "category": "Alcohol" + }, + { + "item": "Headphones", + "price": 50.00, + "category": "Electronics" + } +] \ No newline at end of file diff --git a/examples/skills/skills/weather/SKILL.md b/examples/skills/skills/weather/SKILL.md new file mode 100644 index 000000000..57e19b890 --- /dev/null +++ b/examples/skills/skills/weather/SKILL.md @@ -0,0 +1,12 @@ +--- +name: weather +description: A skill to check weather in different countries. +--- +# Weather Skill + +This skill provides weather information for different countries. + +## Instructions +1. When asked about weather in a specific country, check if there is a resource file for that country in the `references/` directory. +2. The resource file will be named `weather_.json`. Supports `us` and `pl`. +3. Report the weather for the country to the user. diff --git a/examples/skills/skills/weather/references/weather_pl.json b/examples/skills/skills/weather/references/weather_pl.json new file mode 100644 index 000000000..29ab591da --- /dev/null +++ b/examples/skills/skills/weather/references/weather_pl.json @@ -0,0 +1,6 @@ +{ + "country": "Poland", + "temperature": "15°C", + "condition": "Cloudy", + "forecast": "Rain expected in the evening" +} \ No newline at end of file diff --git a/examples/skills/skills/weather/references/weather_us.json b/examples/skills/skills/weather/references/weather_us.json new file mode 100644 index 000000000..a9fbdbc50 --- /dev/null +++ b/examples/skills/skills/weather/references/weather_us.json @@ -0,0 +1,6 @@ +{ + "country": "US", + "temperature": "77°F", + "condition": "Sunny", + "forecast": "Sunny all day" +} \ No newline at end of file diff --git a/tool/skilltoolset/internal/skilltool/list_skills.go b/tool/skilltoolset/internal/skilltool/list_skills.go new file mode 100644 index 000000000..88cae6923 --- /dev/null +++ b/tool/skilltoolset/internal/skilltool/list_skills.go @@ -0,0 +1,70 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skilltool + +import ( + "html" + "strings" + + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" + "google.golang.org/adk/tool/skilltoolset/skill" +) + +// ListSkillsArgs represents the input for ListSkills tool. +type ListSkillsArgs struct{} + +// ListSkillsResult represents the output for ListSkills tool. +type ListSkillsResult struct { + SkillsXML string `json:"skills"` +} + +// ListSkills creates a tool.Tool to list available skills. +func ListSkills(source skill.Source) (tool.Tool, error) { + return functiontool.New( + functiontool.Config{ + Name: "list_skills", + Description: "Lists all available skills with their names and descriptions.", + }, + func(ctx tool.Context, args ListSkillsArgs) (*ListSkillsResult, error) { + return listSkills(ctx, args, source) + }, + ) +} + +func listSkills(ctx tool.Context, _ ListSkillsArgs, source skill.Source) (*ListSkillsResult, error) { + skills, err := source.ListFrontmatters(ctx) + if err != nil { + return nil, err + } + return &ListSkillsResult{SkillsXML: SkillsToXML(skills)}, nil +} + +func SkillsToXML(frontmatters []*skill.Frontmatter) string { + var sb strings.Builder + sb.WriteString("\n") + for _, fm := range frontmatters { + sb.WriteString("\n") + sb.WriteString("\n") + sb.WriteString(html.EscapeString(fm.Name)) + sb.WriteString("\n\n") + sb.WriteString("\n") + sb.WriteString(html.EscapeString(fm.Description)) + sb.WriteString("\n\n") + sb.WriteString("\n") + } + sb.WriteString("") + return sb.String() +} diff --git a/tool/skilltoolset/internal/skilltool/load_skill.go b/tool/skilltoolset/internal/skilltool/load_skill.go new file mode 100644 index 000000000..7e2a787e2 --- /dev/null +++ b/tool/skilltoolset/internal/skilltool/load_skill.go @@ -0,0 +1,84 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package skilltool provides the standard tools for the skill toolset. +package skilltool + +import ( + "fmt" + + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" + "google.golang.org/adk/tool/skilltoolset/skill" +) + +// LoadSkillArgs represents the input to load a skill. +type LoadSkillArgs struct { + Name string `json:"name" jsonschema:"The name of the skill to load."` +} + +type FrontmatterJSON struct { + Name string `json:"name"` + Description string `json:"description"` + License string `json:"license,omitempty"` + Compatibility string `json:"compatibility,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + AllowedTools []string `json:"allowed-tools,omitempty"` +} + +// LoadSkillResult represents the output of a loaded skill. +type LoadSkillResult struct { + SkillName string `json:"skill_name,omitempty"` + Instructions string `json:"instructions,omitempty"` + Frontmatter *FrontmatterJSON `json:"frontmatter,omitempty"` +} + +// LoadSkill creates a tool.Tool to load a skill's instructions. +func LoadSkill(source skill.Source) (tool.Tool, error) { + return functiontool.New( + functiontool.Config{ + Name: "load_skill", + Description: "Loads the SKILL.md instructions for a given skill.", + }, + func(ctx tool.Context, args LoadSkillArgs) (*LoadSkillResult, error) { + return loadSkill(ctx, args, source) + }, + ) +} + +func loadSkill(ctx tool.Context, args LoadSkillArgs, source skill.Source) (*LoadSkillResult, error) { + if args.Name == "" { + return nil, fmt.Errorf("skill name is required to load a skill") + } + frontmatter, err := source.LoadFrontmatter(ctx, args.Name) + if err != nil { + return nil, fmt.Errorf("load frontmatter for skill %q: %w", args.Name, err) + } + instructions, err := source.LoadInstructions(ctx, args.Name) + if err != nil { + return nil, fmt.Errorf("load instructions for skill %q: %w", args.Name, err) + } + return &LoadSkillResult{ + SkillName: args.Name, + Instructions: instructions, + Frontmatter: &FrontmatterJSON{ + Name: frontmatter.Name, + Description: frontmatter.Description, + License: frontmatter.License, + Compatibility: frontmatter.Compatibility, + Metadata: frontmatter.Metadata, + AllowedTools: frontmatter.AllowedTools, + }, + }, nil +} diff --git a/tool/skilltoolset/internal/skilltool/load_skill_resource.go b/tool/skilltoolset/internal/skilltool/load_skill_resource.go new file mode 100644 index 000000000..42abe0496 --- /dev/null +++ b/tool/skilltoolset/internal/skilltool/load_skill_resource.go @@ -0,0 +1,80 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skilltool + +import ( + "fmt" + "io" + + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" + "google.golang.org/adk/tool/skilltoolset/skill" +) + +const maxResourceSize = 10 * 1024 * 1024 // 10 MiB + +// LoadSkillResourceArgs represents the input for retrieving a resource out of a skill's resources. +type LoadSkillResourceArgs struct { + SkillName string `json:"skill_name" jsonschema:"The name of the skill."` + ResourcePath string `json:"resource_path" jsonschema:"The relative path to the resource (e.g., 'references/my_doc.md', 'assets/template.txt', or 'scripts/setup.sh')."` +} + +// LoadSkillResourceResult encapsulates the resource content. +type LoadSkillResourceResult struct { + SkillName string `json:"skill_name,omitempty"` + Path string `json:"path,omitempty"` + Content string `json:"content,omitempty"` +} + +// LoadSkillResource creates a tool.Tool to load a resource file for a skill. +func LoadSkillResource(source skill.Source) (tool.Tool, error) { + return functiontool.New( + functiontool.Config{ + Name: "load_skill_resource", + Description: "Loads a resource file (e.g., from references/ or assets/) associated with the specified skill.", + }, + func(ctx tool.Context, args LoadSkillResourceArgs) (*LoadSkillResourceResult, error) { + return loadSkillResource(ctx, args, source) + }, + ) +} + +func loadSkillResource(ctx tool.Context, args LoadSkillResourceArgs, source skill.Source) (*LoadSkillResourceResult, error) { + if args.SkillName == "" { + return nil, fmt.Errorf("skill name is required to load a resource") + } + if args.ResourcePath == "" { + return nil, fmt.Errorf("resource path is required to load a resource for skill %q", args.SkillName) + } + reader, err := source.LoadResource(ctx, args.SkillName, args.ResourcePath) + if err != nil { + return nil, fmt.Errorf("load resource '%s' from skill '%s': %w", args.ResourcePath, args.SkillName, err) + } + defer func() { + _ = reader.Close() + }() + content, err := io.ReadAll(io.LimitReader(reader, maxResourceSize+1)) + if err != nil { + return nil, fmt.Errorf("read resource '%s' from skill '%s': %w", args.ResourcePath, args.SkillName, err) + } + if int64(len(content)) > maxResourceSize { + return nil, fmt.Errorf("resource '%s' from skill '%s' is too large (limit: %d bytes)", args.ResourcePath, args.SkillName, maxResourceSize) + } + return &LoadSkillResourceResult{ + SkillName: args.SkillName, + Path: args.ResourcePath, + Content: string(content), + }, nil +} diff --git a/tool/skilltoolset/internal/skilltool/tools_test.go b/tool/skilltoolset/internal/skilltool/tools_test.go new file mode 100644 index 000000000..d181e55f3 --- /dev/null +++ b/tool/skilltoolset/internal/skilltool/tools_test.go @@ -0,0 +1,187 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skilltool_test + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + icontext "google.golang.org/adk/internal/context" + "google.golang.org/adk/internal/toolinternal" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/skilltoolset/internal/skilltool" + "google.golang.org/adk/tool/skilltoolset/skill" +) + +type mockSource struct { + frontmatters []*skill.Frontmatter + resources map[string]map[string]string + instructions map[string]string +} + +func (m *mockSource) ListFrontmatters(ctx context.Context) ([]*skill.Frontmatter, error) { + return m.frontmatters, nil +} + +func (m *mockSource) ListResources(ctx context.Context, name, subpath string) ([]string, error) { + var res []string + for p := range m.resources[name] { + if strings.HasPrefix(p, subpath) { + res = append(res, p) + } + } + return res, nil +} + +func (m *mockSource) LoadFrontmatter(ctx context.Context, name string) (*skill.Frontmatter, error) { + for _, fm := range m.frontmatters { + if fm.Name == name { + return fm, nil + } + } + return nil, skill.ErrSkillNotFound +} + +func (m *mockSource) LoadInstructions(ctx context.Context, name string) (string, error) { + inst, ok := m.instructions[name] + if !ok { + return "", skill.ErrSkillNotFound + } + return inst, nil +} + +func (m *mockSource) LoadResource(ctx context.Context, name, resourcePath string) (io.ReadCloser, error) { + res, ok := m.resources[name][resourcePath] + if !ok { + return nil, skill.ErrResourceNotFound + } + return io.NopCloser(strings.NewReader(res)), nil +} + +func createToolContext(t *testing.T) tool.Context { + invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) + return toolinternal.NewToolContext(invCtx, "", nil, nil) +} + +func TestListSkills(t *testing.T) { + source := &mockSource{ + frontmatters: []*skill.Frontmatter{ + {Name: "skill1", Description: "description1"}, + {Name: "skill2", Description: "description2"}, + }, + } + + tTool, err := skilltool.ListSkills(source) + if err != nil { + t.Fatalf("ListSkills failed: %v", err) + } + + funcTool, ok := tTool.(toolinternal.FunctionTool) + if !ok { + t.Fatal("tool does not implement toolinternal.FunctionTool") + } + + result, err := funcTool.Run(createToolContext(t), map[string]any{}) + if err != nil { + t.Fatalf("tool.Run failed: %v", err) + } + + want := map[string]any{ + "skills": "\n\n\nskill1\n\n\ndescription1\n\n\n\n\nskill2\n\n\ndescription2\n\n\n", + } + + if diff := cmp.Diff(want, result); diff != "" { + t.Errorf("result mismatch (-want +got):\n%s", diff) + } +} + +func TestLoadSkill(t *testing.T) { + source := &mockSource{ + frontmatters: []*skill.Frontmatter{ + { + Name: "skill1", + Description: "description1", + Metadata: map[string]string{ + "key1": "value1", + }, + }, + }, + instructions: map[string]string{ + "skill1": "instructions1", + }, + } + tool, err := skilltool.LoadSkill(source) + if err != nil { + t.Fatalf("LoadSkill failed: %v", err) + } + functionTool, ok := tool.(toolinternal.FunctionTool) + if !ok { + t.Fatal("LoadSkill tool does not implement toolinternal.FunctionTool") + } + + got, err := functionTool.Run(createToolContext(t), map[string]any{"name": "skill1"}) + if err != nil { + t.Fatalf("LoadSkill tool.Run failed: %v", err) + } + want := map[string]any{ + "skill_name": "skill1", + "instructions": "instructions1", + "frontmatter": map[string]any{ + "name": "skill1", + "description": "description1", + "metadata": map[string]any{ + "key1": "value1", + }, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("LoadSkill result mismatch (-want +got):\n%s", diff) + } +} + +func TestLoadSkillResource(t *testing.T) { + source := &mockSource{ + resources: map[string]map[string]string{ + "skill1": {"assets/data.txt": "content1"}, + }, + } + tool, err := skilltool.LoadSkillResource(source) + if err != nil { + t.Fatalf("LoadSkillResource: %v", err) + } + functionTool, ok := tool.(toolinternal.FunctionTool) + if !ok { + t.Fatalf("LoadSkillResource: tool does not implement toolinternal.FunctionTool") + } + result, err := functionTool.Run(createToolContext(t), map[string]any{ + "skill_name": "skill1", + "resource_path": "assets/data.txt", + }) + if err != nil { + t.Fatalf("tool.Run failed: %v", err) + } + want := map[string]any{ + "skill_name": "skill1", + "path": "assets/data.txt", + "content": "content1", + } + if diff := cmp.Diff(want, result); diff != "" { + t.Errorf("result mismatch (-want +got):\n%s", diff) + } +} diff --git a/tool/skilltoolset/toolset.go b/tool/skilltoolset/toolset.go new file mode 100644 index 000000000..3e681ad16 --- /dev/null +++ b/tool/skilltoolset/toolset.go @@ -0,0 +1,117 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skilltoolset + +import ( + "context" + "fmt" + + "google.golang.org/adk/agent" + "google.golang.org/adk/internal/utils" + "google.golang.org/adk/model" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/skilltoolset/internal/skilltool" + "google.golang.org/adk/tool/skilltoolset/skill" +) + +const ( + defaultName string = "SkillToolset" + defaultSkillSystemInstruction string = `You can use specialized 'skills' to help you with complex tasks. You MUST use the skill tools to interact with these skills. + +Skills are folders of instructions and resources that extend your capabilities for specialized tasks. Each skill folder contains: +- **SKILL.md** (required): The main instruction file with skill metadata and detailed markdown instructions. +- **references/** (Optional): Additional documentation or examples for skill usage. +- **assets/** (Optional): Templates, scripts or other resources used by the skill. +- **scripts/** (Optional): Executable scripts that can be run via bash. + +This is very important: + +` + + "1. If a skill seems relevant to the current user query, you MUST use the `load_skill` tool with `skill_name=\"\"` to read its full instructions before proceeding.\n" + + "2. Once you have read the instructions, follow them exactly as documented before replying to the user. For example, If the instruction lists multiple steps, please make sure you complete all of them in order.\n" + + "3. The `load_skill_resource` tool is for viewing files within a skill's directory (e.g., `references/*`, `assets/*`, `scripts/*`). Do NOT use other tools to access these files.\n" +) + +// Config holds the configuration for creating a Skill Toolset. +type Config struct { + Source skill.Source + // Optional name of the toolset. If empty, default name will be used. + Name string + // Optional system instruction. If empty, default instruction will be used. + SystemInstruction string +} + +// SkillToolset provides a toolset for skills. +type SkillToolset struct { + name string + tools []tool.Tool + source skill.Source + systemInstruction string +} + +// New creates a new Skill Toolset based on the provided configuration. +func New(ctx context.Context, cfg Config) (*SkillToolset, error) { + if cfg.Source == nil { + return nil, fmt.Errorf("skill source must be provided") + } + name := defaultName + if cfg.Name != "" { + name = cfg.Name + } + instruction := defaultSkillSystemInstruction + if cfg.SystemInstruction != "" { + instruction = cfg.SystemInstruction + } + listTool, err := skilltool.ListSkills(cfg.Source) + if err != nil { + return nil, fmt.Errorf("create list skills tool: %w", err) + } + loadTool, err := skilltool.LoadSkill(cfg.Source) + if err != nil { + return nil, fmt.Errorf("create load skill tool: %w", err) + } + loadResourceTool, err := skilltool.LoadSkillResource(cfg.Source) + if err != nil { + return nil, fmt.Errorf("create load skill resource tool: %w", err) + } + return &SkillToolset{ + name: name, + tools: []tool.Tool{listTool, loadTool, loadResourceTool}, + source: cfg.Source, + systemInstruction: instruction, + }, nil +} + +// Name implements tool.Toolset. Returns the name of the toolset. +func (ts *SkillToolset) Name() string { return ts.name } + +// Tools implements tool.Toolset. It returns a list of tools agent can use to +// interact with skills. +func (ts *SkillToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { return ts.tools, nil } + +// ProcessRequest implements toolinternal.RequestProcessor. It attaches +// the list of available skills and the system instruction explaining to the +// agent what it can do with these skills. +func (ts *SkillToolset) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { + skills, err := ts.source.ListFrontmatters(ctx) + if err != nil { + return err + } + if len(skills) == 0 { + return nil + } + utils.AppendInstructions(req, ts.systemInstruction, skilltool.SkillsToXML(skills)) + return nil +} diff --git a/tool/skilltoolset/toolset_test.go b/tool/skilltoolset/toolset_test.go new file mode 100644 index 000000000..755169d0f --- /dev/null +++ b/tool/skilltoolset/toolset_test.go @@ -0,0 +1,125 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skilltoolset_test + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "google.golang.org/adk/model" + "google.golang.org/adk/tool/skilltoolset" + "google.golang.org/adk/tool/skilltoolset/skill" +) + +type mockSource struct { + skill.Source + frontmatters []*skill.Frontmatter +} + +func (m *mockSource) ListFrontmatters(ctx context.Context) ([]*skill.Frontmatter, error) { + return m.frontmatters, nil +} + +func TestProcessRequest(t *testing.T) { + source := &mockSource{ + frontmatters: []*skill.Frontmatter{ + { + Name: "skill0", + Description: "description0", + }, + { + Name: "skill1", + Description: "description1", + Compatibility: "...", + Metadata: map[string]string{ + "flag1": "key1", + "flag2": "key2", + }, + }, + }, + } + ts, err := skilltoolset.New(t.Context(), skilltoolset.Config{Source: source}) + if err != nil { + t.Fatalf("skilltoolset.New failed: %v", err) + } + req := &model.LLMRequest{} + + err = ts.ProcessRequest(nil, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + if req.Config == nil { + t.Fatalf("ProcessRequest: req.Config is nil") + } + if req.Config.SystemInstruction == nil { + t.Fatalf("ProcessRequest: req.Config.SystemInstruction is nil") + } + if len(req.Config.SystemInstruction.Parts) != 1 { + t.Fatalf("ProcessRequest: got %d parts, expected 1", len(req.Config.SystemInstruction.Parts)) + } + gotText := req.Config.SystemInstruction.Parts[0].Text + for _, want := range []string{"SKILL.md", "skills", "", "skill0", "description1"} { + if !strings.Contains(gotText, want) { + t.Errorf("ProcessRequest: got %q, want to contain %q", gotText, want) + } + } +} + +func TestProcessRequest_NoSkills(t *testing.T) { + ts, err := skilltoolset.New(t.Context(), skilltoolset.Config{Source: &mockSource{}}) + if err != nil { + t.Fatalf("skilltoolset.New failed: %v", err) + } + req := &model.LLMRequest{} + + err = ts.ProcessRequest(nil, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + if req.Config != nil && req.Config.SystemInstruction != nil && len(req.Config.SystemInstruction.Parts) > 0 { + t.Errorf("ProcessRequest: got %d parts, expected 0", len(req.Config.SystemInstruction.Parts)) + } +} + +func TestNew_MissingSource(t *testing.T) { + _, err := skilltoolset.New(t.Context(), skilltoolset.Config{}) + if err == nil { + t.Errorf("skilltoolset.New: expected error when source is missing") + } +} + +func TestTools(t *testing.T) { + toolset, err := skilltoolset.New(t.Context(), skilltoolset.Config{Source: &mockSource{}}) + if err != nil { + t.Fatalf("skilltoolset.New failed: %v", err) + } + + tools, err := toolset.Tools(nil) + if err != nil { + t.Fatalf("Tools failed: %v", err) + } + var got []string + for _, t := range tools { + got = append(got, t.Name()) + } + want := []string{"list_skills", "load_skill", "load_skill_resource"} + if diff := cmp.Diff(want, got, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("Tools result mismatch (-want +got):\n%s", diff) + } +} From 149505174d92f962412da6b932f2fed68b8c8af5 Mon Sep 17 00:00:00 2001 From: Anastasia Date: Fri, 17 Apr 2026 15:01:16 +0200 Subject: [PATCH 22/86] fix: adk-web sse error format (#734) fix adk-web sse error format so it corresponds with SSE specifications --- server/adkrest/controllers/runtime.go | 77 ++++++---- server/adkrest/controllers/runtime_test.go | 161 +++++++++++++++++++++ server/adkrest/internal/routers/runtime.go | 2 +- 3 files changed, 212 insertions(+), 28 deletions(-) diff --git a/server/adkrest/controllers/runtime.go b/server/adkrest/controllers/runtime.go index 928989a2f..11085eeba 100644 --- a/server/adkrest/controllers/runtime.go +++ b/server/adkrest/controllers/runtime.go @@ -18,6 +18,7 @@ import ( "context" "encoding/json" "fmt" + "log" "net/http" "time" @@ -87,32 +88,42 @@ func (c *RuntimeAPIController) runAgent(ctx context.Context, runAgentRequest mod } // RunSSEHandler executes an agent run and streams the resulting events using Server-Sent Events (SSE). -func (c *RuntimeAPIController) RunSSEHandler(rw http.ResponseWriter, req *http.Request) error { - rw.Header().Set("Content-Type", "text/event-stream") - rw.Header().Set("Cache-Control", "no-cache") - rw.Header().Set("Connection", "keep-alive") - +func (c *RuntimeAPIController) RunSSEHandler(rw http.ResponseWriter, req *http.Request) { // set custom deadlines for this request - it overrides server-wide timeouts rc := http.NewResponseController(rw) deadline := time.Now().Add(c.sseTimeout) err := rc.SetWriteDeadline(deadline) if err != nil { - return newStatusError(fmt.Errorf("failed to set write deadline: %w", err), http.StatusInternalServerError) + http.Error(rw, "failed to set write deadline: "+err.Error(), http.StatusInternalServerError) + return } runAgentRequest, err := decodeRequestBody(req) if err != nil { - return err + http.Error(rw, "failed to decode request body: "+err.Error(), http.StatusBadRequest) + return } err = c.validateSessionExists(req.Context(), runAgentRequest.AppName, runAgentRequest.UserId, runAgentRequest.SessionId) if err != nil { - return err + http.Error(rw, "failed to find the session: "+err.Error(), http.StatusNotFound) + return } r, rCfg, err := c.getRunner(runAgentRequest) if err != nil { - return err + http.Error(rw, "failed to get runner: "+err.Error(), http.StatusInternalServerError) + return + } + + // Flush as soon as possible so the client doesn't drop connection. + // Add the headers after the error handling to avoid wrong content type. + rw.Header().Set("Content-Type", "text/event-stream") + rw.Header().Set("Cache-Control", "no-cache") + rw.Header().Set("Connection", "keep-alive") + if err := rc.Flush(); err != nil { + http.Error(rw, "failed to flush headers", http.StatusInternalServerError) + return } opts := []runner.RunOption{} @@ -123,41 +134,53 @@ func (c *RuntimeAPIController) RunSSEHandler(rw http.ResponseWriter, req *http.R for event, err := range resp { if err != nil { - _, err := fmt.Fprintf(rw, "Error while running agent: %v\n", err) - if err != nil { - return newStatusError(fmt.Errorf("failed to write response: %w", err), http.StatusInternalServerError) - } - err = rc.Flush() + err := flashErrorEvent(rc, rw, err) + // The error is returned only when we cannot communicate with the client + // Exit the handler as connection is closed. if err != nil { - return newStatusError(fmt.Errorf("failed to flush: %w", err), http.StatusInternalServerError) + log.Printf("failed to flash error event: %v", err) + return } - continue } - err := flashEvent(rc, rw, *event) + if event == nil { + continue + } + // Skip reporting error if it fails to marshal to the client (to avoid recursive error reporting). + marshalledData, err := json.Marshal(models.FromSessionEvent(*event)) if err != nil { - return err + log.Printf("failed to marshal event: %v", err) + return + } + err = flashEvent(rc, rw, string(marshalledData)) + if err != nil { + log.Printf("failed to flash event: %v", err) + return } } - return nil } -func flashEvent(rc *http.ResponseController, rw http.ResponseWriter, event session.Event) error { - _, err := fmt.Fprintf(rw, "data: ") +func flashErrorEvent(rc *http.ResponseController, rw http.ResponseWriter, origError error) error { + _, err := fmt.Fprintf(rw, "event: error\n") if err != nil { - return newStatusError(fmt.Errorf("failed to write response: %w", err), http.StatusInternalServerError) + return fmt.Errorf("write error event: %w", err) } - err = json.NewEncoder(rw).Encode(models.FromSessionEvent(event)) + safeErrorJSON, err := json.Marshal(map[string]string{"error": origError.Error()}) if err != nil { - return newStatusError(fmt.Errorf("failed to encode response: %w", err), http.StatusInternalServerError) + // Skip reporting error if it fails to marshal to the client (to avoid recursive error reporting). + return fmt.Errorf("marshal error event: %w", err) } - _, err = fmt.Fprintf(rw, "\n") + return flashEvent(rc, rw, string(safeErrorJSON)) +} + +func flashEvent(rc *http.ResponseController, rw http.ResponseWriter, data string) error { + _, err := fmt.Fprintf(rw, "data: %s\n\n", data) if err != nil { - return newStatusError(fmt.Errorf("failed to write response: %w", err), http.StatusInternalServerError) + return fmt.Errorf("write response: %w", err) } err = rc.Flush() if err != nil { - return newStatusError(fmt.Errorf("failed to flush: %w", err), http.StatusInternalServerError) + return fmt.Errorf("flush event: %w", err) } return nil } diff --git a/server/adkrest/controllers/runtime_test.go b/server/adkrest/controllers/runtime_test.go index ba4830e99..a6672fb83 100644 --- a/server/adkrest/controllers/runtime_test.go +++ b/server/adkrest/controllers/runtime_test.go @@ -15,11 +15,24 @@ package controllers import ( + "bytes" + "encoding/json" + "fmt" + "iter" + "net/http" + "net/http/httptest" + "strings" "testing" "time" + "google.golang.org/genai" + + "google.golang.org/adk/agent" "google.golang.org/adk/plugin" "google.golang.org/adk/runner" + "google.golang.org/adk/server/adkrest/internal/fakes" + "google.golang.org/adk/server/adkrest/internal/models" + "google.golang.org/adk/session" ) func TestNewRuntimeAPIController_PluginsAssignment(t *testing.T) { @@ -76,3 +89,151 @@ func TestNewRuntimeAPIController_PluginsAssignment(t *testing.T) { }) } } + +type recorderWithDeadline struct { + *httptest.ResponseRecorder +} + +func (r *recorderWithDeadline) SetWriteDeadline(t time.Time) error { + return nil +} + +type testAgentResult struct { + event *session.Event + err error +} + +func testAgent(results []testAgentResult) func(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + for _, res := range results { + if !yield(res.event, res.err) { + return + } + } + } + } +} + +func makeEvent(id, author, text string) *session.Event { + e := session.NewEvent(id) + e.Author = author + e.LLMResponse.Content = &genai.Content{ + Parts: []*genai.Part{{Text: text}}, + } + return e +} + +func TestRunSSEHandler(t *testing.T) { + tc := []struct { + name string + results []testAgentResult + wantStatus int + wantBody []string + }{ + { + name: "success case", + results: []testAgentResult{ + {event: makeEvent("invocation-1", "testApp", "Hello from agent"), err: nil}, + }, + wantStatus: http.StatusOK, + wantBody: []string{"data: {", "Hello from agent"}, + }, + { + name: "error case", + results: []testAgentResult{ + {err: fmt.Errorf("agent failed")}, + }, + wantStatus: http.StatusOK, + wantBody: []string{"event: error\ndata: {\"error\":\"agent failed\"}\n\n"}, + }, + { + name: "interleaved success and error", + results: []testAgentResult{ + {event: makeEvent("invocation-1", "testApp", "Hello from agent"), err: nil}, + {err: fmt.Errorf("agent failed")}, + {event: makeEvent("invocation-1", "testApp", "More data"), err: nil}, + {err: fmt.Errorf("agent failed again")}, + }, + wantStatus: http.StatusOK, + wantBody: []string{ + "data: {", "Hello from agent", + "event: error\ndata: {\"error\":\"agent failed\"}\n\n", + "data: {", "More data", + "event: error\ndata: {\"error\":\"agent failed again\"}\n\n", + }, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + // Setup fake agent with testAgent(tt.results) + fakeAgent, err := agent.New(agent.Config{ + Name: "testApp", + Run: testAgent(tt.results), + }) + if err != nil { + t.Fatalf("agent.New failed: %v", err) + } + + // Setup fake session service + id := fakes.SessionKey{ + AppName: "testApp", + UserID: "testUser", + SessionID: "testSession", + } + sessionService := fakes.FakeSessionService{ + Sessions: map[fakes.SessionKey]fakes.TestSession{ + id: { + Id: id, + SessionState: fakes.TestState{}, + SessionEvents: fakes.TestEvents{}, + UpdatedAt: time.Now(), + }, + }, + } + + // Setup controller + controller := NewRuntimeAPIController( + &sessionService, + nil, + agent.NewSingleLoader(fakeAgent), + nil, + 10*time.Second, + runner.PluginConfig{}, + ) + + // Create request + reqObj := models.RunAgentRequest{ + AppName: "testApp", + UserId: "testUser", + SessionId: "testSession", + Streaming: true, + NewMessage: genai.Content{ + Parts: []*genai.Part{{Text: "Hello"}}, + }, + } + reqBytes, _ := json.Marshal(reqObj) + req := httptest.NewRequest(http.MethodPost, "/run-sse", bytes.NewBuffer(reqBytes)) + + // Record response + rr := httptest.NewRecorder() + w := &recorderWithDeadline{rr} + + // Call handler + controller.RunSSEHandler(w, req) + + // Verify response + if rr.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code) + } + + body := rr.Body.String() + for _, s := range tt.wantBody { + if !strings.Contains(body, s) { + t.Errorf("expected body to contain %q, got %s", s, body) + } + } + }) + } +} diff --git a/server/adkrest/internal/routers/runtime.go b/server/adkrest/internal/routers/runtime.go index e27bc79e2..3819ce64a 100644 --- a/server/adkrest/internal/routers/runtime.go +++ b/server/adkrest/internal/routers/runtime.go @@ -43,7 +43,7 @@ func (r *RuntimeAPIRouter) Routes() Routes { Name: "RunAgentSse", Methods: []string{http.MethodPost, http.MethodOptions}, Pattern: "/run_sse", - HandlerFunc: controllers.NewErrorHandler(r.runtimeController.RunSSEHandler), + HandlerFunc: r.runtimeController.RunSSEHandler, }, } } From 8c0e0fa954e510247fda49a83a84938a75b11853 Mon Sep 17 00:00:00 2001 From: Anastasia Date: Fri, 17 Apr 2026 17:14:41 +0200 Subject: [PATCH 23/86] fix: add traceCapacity to the api config (#731) --- cmd/launcher/web/api/api.go | 5 +++++ server/adkrest/handler.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/launcher/web/api/api.go b/cmd/launcher/web/api/api.go index 7383b342b..e4379dcff 100644 --- a/cmd/launcher/web/api/api.go +++ b/cmd/launcher/web/api/api.go @@ -36,6 +36,7 @@ type apiConfig struct { frontendAddress string pathPrefix string sseWriteTimeout time.Duration + traceCapacity int } // apiLauncher can launch ADK REST API @@ -81,6 +82,9 @@ func (a *apiLauncher) SetupSubrouters(router *mux.Router, config *launcher.Confi ArtifactService: config.ArtifactService, SSEWriteTimeout: a.config.sseWriteTimeout, PluginConfig: config.PluginConfig, + DebugConfig: adkrest.DebugTelemetryConfig{ + TraceCapacity: a.config.traceCapacity, + }, }) if err != nil { return fmt.Errorf("failed to create REST server: %w", err) @@ -138,6 +142,7 @@ func NewLauncher() weblauncher.Sublauncher { fs.StringVar(&config.frontendAddress, "webui_address", "localhost:8080", "ADK WebUI address as seen from the user browser. It's used to allow CORS requests. Please specify only hostname and (optionally) port.") fs.StringVar(&config.pathPrefix, "path_prefix", "/api", "ADK REST API path prefix. Default is '/api'.") fs.DurationVar(&config.sseWriteTimeout, "sse-write-timeout", 120*time.Second, "SSE server write timeout (i.e. '10s', '2m' - see time.ParseDuration for details) - for writing the SSE response after reading the headers & body") + fs.IntVar(&config.traceCapacity, "trace_capacity", 10000, "Maximum number of traces to keep in memory.") return &apiLauncher{ config: config, diff --git a/server/adkrest/handler.go b/server/adkrest/handler.go index b0dc54a6c..1bc32d331 100644 --- a/server/adkrest/handler.go +++ b/server/adkrest/handler.go @@ -67,7 +67,7 @@ type ServerConfig struct { ArtifactService artifact.Service SSEWriteTimeout time.Duration PluginConfig runner.PluginConfig - DebugConfig *DebugTelemetryConfig + DebugConfig DebugTelemetryConfig } // DebugTelemetryConfig contains parameters for the debug telemetry. From b89683bc2979968838c0ddb2924181cef1c713e7 Mon Sep 17 00:00:00 2001 From: Artsiom Shut <63152812+foxfrikses@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:58:44 +0200 Subject: [PATCH 24/86] feat: Add skill Source proxy for preloading skills. (#745) WithCompletePreloadSource proxy allows to load the skills to memory on initialization. It offers the fastest possible data access speed upon initialization, at the expense of higher memory usage and longer initialization. --- examples/skills/main.go | 10 +- tool/skilltoolset/skill/complete_preload.go | 205 +++++++ .../skill/complete_preload_test.go | 551 ++++++++++++++++++ 3 files changed, 763 insertions(+), 3 deletions(-) create mode 100644 tool/skilltoolset/skill/complete_preload.go create mode 100644 tool/skilltoolset/skill/complete_preload_test.go diff --git a/examples/skills/main.go b/examples/skills/main.go index 946215366..f5980046c 100644 --- a/examples/skills/main.go +++ b/examples/skills/main.go @@ -54,9 +54,13 @@ func main() { log.Fatalf("Failed to create model: %v", err) } - skillToolset, err := skilltoolset.New(ctx, skilltoolset.Config{ - Source: skill.NewFileSystemSource(os.DirFS("./skills")), - }) + source := skill.NewFileSystemSource(os.DirFS("./skills")) + source, _, err = skill.WithCompletePreloadSource(ctx, source) + if err != nil { + log.Fatalf("Failed to preload skills: %v", err) + } + + skillToolset, err := skilltoolset.New(ctx, skilltoolset.Config{Source: source}) if err != nil { log.Fatalf("Failed to create skill toolset: %v", err) } diff --git a/tool/skilltoolset/skill/complete_preload.go b/tool/skilltoolset/skill/complete_preload.go new file mode 100644 index 000000000..d3e5ad740 --- /dev/null +++ b/tool/skilltoolset/skill/complete_preload.go @@ -0,0 +1,205 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skill + +import ( + "bytes" + "context" + "fmt" + "io" + "path" + "slices" + "strings" + "sync" +) + +const maxResourceSize = 10 * 1024 * 1024 // 10MB + +type completePreloadSkillData struct { + frontmatter *Frontmatter + instructions string + // resources enables fast lookup. + resources map[string][]byte + // sortedResourcePaths enables fast filtered listing of resources in + // deterministic order. + sortedResourcePaths []string +} + +type completePreloadSource struct { + base Source + mu sync.RWMutex + skills map[string]*completePreloadSkillData + // sortedFrontmatters enables listing of frontmatters in deterministic order. + sortedFrontmatters []*Frontmatter +} + +// WithCompletePreloadSource returns a Source proxy that wraps the given source +// and fully preloads all skill data (Frontmatters, Instructions, and Resources) +// into memory upon creation. +// This offers the fastest access speed after initialization, at the expense of +// higher initial load time and memory usage. +// Additionally, 'reload' callback is returned: calling it actualizes data by +// loading all the data anew. +// +// NOTE: source.ListResources must list all resources for subpath ".". +func WithCompletePreloadSource(ctx context.Context, source Source) (Source, func(context.Context) error, error) { + s := &completePreloadSource{base: source} + if err := s.reload(ctx); err != nil { + return nil, nil, err + } + return s, s.reload, nil +} + +func (s *completePreloadSource) ListFrontmatters(ctx context.Context) ([]*Frontmatter, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.sortedFrontmatters, nil +} + +func (s *completePreloadSource) LoadFrontmatter(ctx context.Context, name string) (*Frontmatter, error) { + s.mu.RLock() + defer s.mu.RUnlock() + data, ok := s.skills[name] + if !ok { + return nil, ErrSkillNotFound + } + return data.frontmatter, nil +} + +func (s *completePreloadSource) LoadInstructions(ctx context.Context, name string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + data, ok := s.skills[name] + if !ok { + return "", ErrSkillNotFound + } + return data.instructions, nil +} + +func (s *completePreloadSource) LoadResource(ctx context.Context, name, resourcePath string) (io.ReadCloser, error) { + s.mu.RLock() + defer s.mu.RUnlock() + skill, ok := s.skills[name] + if !ok { + return nil, ErrSkillNotFound + } + resource, ok := skill.resources[path.Clean(resourcePath)] + if !ok { + return nil, ErrResourceNotFound + } + return io.NopCloser(bytes.NewReader(resource)), nil +} + +func (s *completePreloadSource) ListResources(ctx context.Context, name, subpath string) ([]string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + data, ok := s.skills[name] + if !ok { + return nil, ErrSkillNotFound + } + + cleanPath := path.Clean(subpath) + if cleanPath == "." || cleanPath == "/" { + return data.sortedResourcePaths, nil + } + + var result []string + if !strings.HasSuffix(subpath, "/") { + if _, matchesSpecificResource := data.resources[cleanPath]; matchesSpecificResource { + result = append(result, cleanPath) + } + } + // Optimization for skills with many resources: binary search to find the + // first resource path that could match the prefix. + prefix := cleanPath + "/" // clean path is guaranteed to not end with "/". + startIdx, _ := slices.BinarySearch(data.sortedResourcePaths, prefix) + for i := startIdx; i < len(data.sortedResourcePaths); i++ { + resourcePath := data.sortedResourcePaths[i] + if !strings.HasPrefix(resourcePath, prefix) { + break // Nothing else will match since resourcePaths is sorted. + } + result = append(result, resourcePath) + } + return result, nil +} + +func (s *completePreloadSource) reload(ctx context.Context) error { + frontmatters, err := s.base.ListFrontmatters(ctx) + if err != nil { + return fmt.Errorf("list frontmatters: %w", err) + } + frontmatters = slices.Clone(frontmatters) + slices.SortFunc(frontmatters, func(a, b *Frontmatter) int { + return strings.Compare(a.Name, b.Name) + }) + + skills := make(map[string]*completePreloadSkillData, len(frontmatters)) + for _, frontmatter := range frontmatters { + if err := ctx.Err(); err != nil { + return err + } + + if _, exists := skills[frontmatter.Name]; exists { + return fmt.Errorf("%w: %q", ErrDuplicateSkill, frontmatter.Name) + } + + instructions, err := s.base.LoadInstructions(ctx, frontmatter.Name) + if err != nil { + return fmt.Errorf("load instructions for skill %q: %w", frontmatter.Name, err) + } + + resourcePaths, err := s.base.ListResources(ctx, frontmatter.Name, ".") + if err != nil { + return fmt.Errorf("list resources for skill %q: %w", frontmatter.Name, err) + } + + var cleanResourcePaths []string + resources := make(map[string][]byte) + for _, resourcePath := range resourcePaths { + if err := ctx.Err(); err != nil { + return err + } + resource, err := s.base.LoadResource(ctx, frontmatter.Name, resourcePath) + if err != nil { + return fmt.Errorf("resource path %q in skill %q: %w", resourcePath, frontmatter.Name, err) + } + data, err := io.ReadAll(io.LimitReader(resource, maxResourceSize+1)) + _ = resource.Close() + if err != nil { + return fmt.Errorf("read resource path %q in skill %q: %w", resourcePath, frontmatter.Name, err) + } + if len(data) > maxResourceSize { + return fmt.Errorf("resource %q in skill %q exceeds %d bytes limit", resourcePath, frontmatter.Name, maxResourceSize) + } + cleanPath := path.Clean(resourcePath) + resources[cleanPath] = data + cleanResourcePaths = append(cleanResourcePaths, cleanPath) + } + slices.Sort(cleanResourcePaths) + + skills[frontmatter.Name] = &completePreloadSkillData{ + frontmatter: frontmatter, + instructions: instructions, + sortedResourcePaths: cleanResourcePaths, + resources: resources, + } + } + + s.mu.Lock() + s.skills = skills + s.sortedFrontmatters = frontmatters + s.mu.Unlock() + return nil +} diff --git a/tool/skilltoolset/skill/complete_preload_test.go b/tool/skilltoolset/skill/complete_preload_test.go new file mode 100644 index 000000000..9db4af8be --- /dev/null +++ b/tool/skilltoolset/skill/complete_preload_test.go @@ -0,0 +1,551 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skill + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "slices" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestWithCompletePreloadSource(t *testing.T) { + // Test initial load. + mock := &mockCompletePreloadBaseSource{ + frontmatters: []*Frontmatter{ + {Name: "skill1", Description: "desc1"}, + {Name: "skill2", Description: "desc2"}, + }, + instructions: map[string]string{ + "skill1": "instructions1", + "skill2": "instructions2", + }, + resources: map[string]map[string][]byte{ + "skill1": { + "assets/image.png": []byte("image-data"), + "references/doc.txt": []byte("doc-data"), + "scripts/script.py": []byte("script-data"), + }, + "skill2": {}, + }, + } + + source, reload, err := WithCompletePreloadSource(t.Context(), mock) + if err != nil { + t.Fatalf("WithCompletePreloadSource failed: %v", err) + } + + if err := mock.verifyCallCount(1, 2, 2, 3); err != nil { + t.Fatal(err) + } + collected, err := collectSourceData(t.Context(), source) + if err != nil { + t.Fatalf("collectSourceData failed: %v", err) + } + if err := mock.verifyCallCount(1, 2, 2, 3); err != nil { + t.Errorf("the base mock should not have been called: %v", err) + } + wantCollected := &collectedSourceData{ + Frontmatters: []*Frontmatter{ + {Name: "skill1", Description: "desc1"}, + {Name: "skill2", Description: "desc2"}, + }, + Instructions: map[string]string{ + "skill1": "instructions1", + "skill2": "instructions2", + }, + Resources: map[string]map[string][]byte{ + "skill1": { + "assets/image.png": []byte("image-data"), + "references/doc.txt": []byte("doc-data"), + "scripts/script.py": []byte("script-data"), + }, + }, + } + if diff := cmp.Diff(wantCollected, collected); diff != "" { + t.Errorf("collectedSourceData mismatch (-want +got):\n%s", diff) + } + + // Test reload. + mock.frontmatters = []*Frontmatter{{Name: "skill1", Description: "desc1_updated"}} + mock.instructions["skill1"] = "instructions1_updated" + mock.resources = map[string]map[string][]byte{ + "skill1": {"assets/image.png": []byte("image-data-2")}, + } + + err = reload(t.Context()) + if err != nil { + t.Fatalf("Reload failed: %v", err) + } + + if err := mock.verifyCallCount(1+1, 2+1, 2+1, 3+1); err != nil { + t.Error(err) + } + collected, err = collectSourceData(t.Context(), source) + if err != nil { + t.Fatalf("collectSourceData failed: %v", err) + } + if err := mock.verifyCallCount(1+1, 2+1, 2+1, 3+1); err != nil { + t.Errorf("the base mock should not have been called: %v", err) + } + wantCollected = &collectedSourceData{ + Frontmatters: []*Frontmatter{{Name: "skill1", Description: "desc1_updated"}}, + Instructions: map[string]string{"skill1": "instructions1_updated"}, + Resources: map[string]map[string][]byte{ + "skill1": {"assets/image.png": []byte("image-data-2")}, + }, + } + if diff := cmp.Diff(wantCollected, collected); diff != "" { + t.Errorf("collectedSourceData mismatch (-want +got):\n%s", diff) + } +} + +func TestWithCompletePreloadSource_ReloadFailureDoesNotInvalidateCache(t *testing.T) { + mock := &mockCompletePreloadBaseSource{ + frontmatters: []*Frontmatter{{Name: "skill1"}}, + instructions: map[string]string{"skill1": "instructions1"}, + resources: map[string]map[string][]byte{ + "skill1": {"assets/image.png": []byte("image-data")}, + }, + } + + source, reload, err := WithCompletePreloadSource(t.Context(), mock) + if err != nil { + t.Fatalf("WithCompletePreloadSource failed: %v", err) + } + collected, err := collectSourceData(t.Context(), source) + if err != nil { + t.Fatalf("collectSourceData failed: %v", err) + } + wantCollected := &collectedSourceData{ + Frontmatters: []*Frontmatter{{Name: "skill1"}}, + Instructions: map[string]string{"skill1": "instructions1"}, + Resources: map[string]map[string][]byte{ + "skill1": {"assets/image.png": []byte("image-data")}, + }, + } + if diff := cmp.Diff(wantCollected, collected); diff != "" { + t.Errorf("collectedSourceData mismatch (-want +got):\n%s", diff) + } + + mock.frontmatters = []*Frontmatter{{Name: "skill2"}} + mock.instructions = map[string]string{"skill2": "instructions2"} + mock.resources = map[string]map[string][]byte{ + "skill2": {"assets/image2.png": []byte("image-data-2")}, + } + mock.listResourcesErr = fmt.Errorf("list resources error") + + err = reload(t.Context()) + if err == nil { + t.Fatalf("Reload should have failed") + } + + collected, err = collectSourceData(t.Context(), source) + if err != nil { + t.Fatalf("collectSourceData failed: %v", err) + } + // should be unchanged. + if diff := cmp.Diff(wantCollected, collected); diff != "" { + t.Errorf("collectedSourceData mismatch (-want +got):\n%s", diff) + } +} + +func TestWithCompletePreloadSource_ListResources_Filtering(t *testing.T) { + mock := &mockCompletePreloadBaseSource{ + frontmatters: []*Frontmatter{{Name: "skill1"}}, + instructions: map[string]string{"skill1": "instructions1"}, + resources: map[string]map[string][]byte{ + "skill1": { + "assets/image.png": []byte("image-data"), + "references/doc.txt": []byte("doc-data"), + "scripts/script.py.js": []byte("js-script-data"), + "scripts/script.py": []byte("py-script-data"), + "scripts/script": []byte("script-data"), + "scripts/script/another_script": []byte("another-script-data"), + }, + }, + } + + source, _, err := WithCompletePreloadSource(t.Context(), mock) + if err != nil { + t.Fatalf("WithCompletePreloadSource failed: %v", err) + } + + tests := []struct { + name string + subpath string + wantPaths []string + }{ + { + name: "Folder match", + subpath: "assets", + wantPaths: []string{"assets/image.png"}, + }, + { + name: "Folder match with trailing slash", + subpath: "assets/", + wantPaths: []string{"assets/image.png"}, + }, + { + name: "Another folder match", + subpath: "references", + wantPaths: []string{"references/doc.txt"}, + }, + { + name: "Specific resource request", + subpath: "scripts/script.py", + wantPaths: []string{"scripts/script.py"}, + }, + { + // Whether or not it is possible to have both a file and a directory + // with the same name is dependent on the base source implementation. + name: "Match both directory and file with the same name", + subpath: "scripts/script", + wantPaths: []string{"scripts/script/another_script", "scripts/script"}, + }, + { + name: "No match", + subpath: "nonexistent", + wantPaths: nil, + }, + { + name: "Root match", + subpath: ".", + wantPaths: []string{"assets/image.png", "references/doc.txt", "scripts/script", "scripts/script.py", "scripts/script/another_script", "scripts/script.py.js"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + paths, err := source.ListResources(t.Context(), "skill1", tc.subpath) + if err != nil { + t.Fatalf("ListResources failed: %v", err) + } + if diff := cmp.Diff(tc.wantPaths, paths, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("ListResources mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestWithCompletePreloadSource_LoadResource(t *testing.T) { + mock := &mockCompletePreloadBaseSource{ + frontmatters: []*Frontmatter{{Name: "skill1"}}, + instructions: map[string]string{"skill1": "instructions1"}, + resources: map[string]map[string][]byte{ + "skill1": { + "assets/image1.png": []byte("image-data-1"), + "assets/dir/.././image2.png": []byte("image-data-2"), + }, + }, + } + source, _, err := WithCompletePreloadSource(t.Context(), mock) + if err != nil { + t.Fatalf("WithCompletePreloadSource failed: %v", err) + } + + tests := []struct { + name string + path string + want string + wantErr error + }{ + { + name: "Direct path", + path: "assets/image1.png", + want: "image-data-1", + }, + { + name: "Complex path", + path: "assets/../././assets/./image2.png", + want: "image-data-2", + }, + { + name: "Not existing resource", + path: "assets/nonexistent.png", + wantErr: ErrResourceNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resource, err := source.LoadResource(t.Context(), "skill1", tc.path) + + if !errors.Is(err, tc.wantErr) { + t.Fatalf("LoadResource(%q, %q) error: %v, wantErr: %v", "skill1", tc.path, err, tc.wantErr) + } + if err != nil { + return + } + data, err := io.ReadAll(resource) + if err != nil { + t.Fatalf("LoadResource(%q, %q): io.ReadAll failed: %v", "skill1", tc.path, err) + } + if tc.want != string(data) { + t.Fatalf("LoadResource(%q, %q) = %q, want %q", "skill1", tc.path, string(data), tc.want) + } + }) + } +} + +func TestWithCompletePreloadSource_ResourceSizeLimit(t *testing.T) { + maximumSizeData := make([]byte, maxResourceSize) + mock := &mockCompletePreloadBaseSource{ + frontmatters: []*Frontmatter{{Name: "skill1"}}, + instructions: map[string]string{"skill1": "instructions1"}, + resources: map[string]map[string][]byte{ + "skill1": {"assets/justRight.png": maximumSizeData}, + }, + } + exceedMaximumSizeData := make([]byte, maxResourceSize+1) + exceedMock := &mockCompletePreloadBaseSource{ + frontmatters: []*Frontmatter{{Name: "skill1"}}, + instructions: map[string]string{"skill1": "instructions1"}, + resources: map[string]map[string][]byte{ + "skill1": {"assets/tooLarge.png": exceedMaximumSizeData}, + }, + } + + _, _, err := WithCompletePreloadSource(t.Context(), mock) + _, _, exceedErr := WithCompletePreloadSource(t.Context(), exceedMock) + + if err != nil { + t.Fatalf("WithCompletePreloadSource() error for maximum size data: %v", err) + } + if exceedErr == nil { + t.Fatalf("WithCompletePreloadSource() should have failed for exceed maximum size data: %v", exceedErr) + } +} + +func TestWithCompletePreloadSource_Errors(t *testing.T) { + goodSource := func() *mockCompletePreloadBaseSource { + return &mockCompletePreloadBaseSource{ + frontmatters: []*Frontmatter{{Name: "skill1"}}, + instructions: map[string]string{"skill1": "instructions1"}, + resources: map[string]map[string][]byte{ + "skill1": {"assets/image.png": []byte("image-data")}, + }, + } + } + testError := fmt.Errorf("test error") + + successSource := goodSource() + listfrontmattersFailedSource := goodSource() + loadInstructionsFailedSource := goodSource() + listResourcesFailedSource := goodSource() + loadResourceFailedSource := goodSource() + duplicateSkillSource := goodSource() + + listfrontmattersFailedSource.listFrontmattersErr = testError + loadInstructionsFailedSource.loadInstructionsErr = testError + listResourcesFailedSource.listResourcesErr = testError + loadResourceFailedSource.loadResourceErr = testError + duplicateSkillSource.frontmatters = append(duplicateSkillSource.frontmatters, duplicateSkillSource.frontmatters...) + + tests := []struct { + name string + mock *mockCompletePreloadBaseSource + want error + }{ + { + name: "Succeed", // for completeness + mock: successSource, + want: nil, + }, + { + name: "ListFrontmatters error", + mock: listfrontmattersFailedSource, + want: testError, + }, + { + name: "LoadInstructions error", + mock: loadInstructionsFailedSource, + want: testError, + }, + { + name: "ListResources error", + mock: listResourcesFailedSource, + want: testError, + }, + { + name: "LoadResource error", + mock: loadResourceFailedSource, + want: testError, + }, + { + name: "Duplicate skill error", + mock: duplicateSkillSource, + want: ErrDuplicateSkill, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, _, err := WithCompletePreloadSource(t.Context(), tc.mock) + if !errors.Is(err, tc.want) { + t.Errorf("WithCompletePreloadSource() error = %v, want error %v", err, tc.want) + } + }) + } +} + +type mockCompletePreloadBaseSource struct { + Source + frontmatters []*Frontmatter + instructions map[string]string + resources map[string]map[string][]byte + listFrontmattersCalls int + listResourcesCalls int + loadInstructionsCalls int + loadResourceCalls int + listFrontmattersErr error + loadInstructionsErr error + listResourcesErr error + loadResourceErr error +} + +func (m *mockCompletePreloadBaseSource) ListFrontmatters(ctx context.Context) ([]*Frontmatter, error) { + m.listFrontmattersCalls++ + if m.listFrontmattersErr != nil { + return nil, m.listFrontmattersErr + } + return m.frontmatters, nil +} + +func (m *mockCompletePreloadBaseSource) LoadInstructions(ctx context.Context, name string) (string, error) { + m.loadInstructionsCalls++ + if m.loadInstructionsErr != nil { + return "", m.loadInstructionsErr + } + if v, ok := m.instructions[name]; ok { + return v, nil + } + return "", ErrSkillNotFound +} + +func (m *mockCompletePreloadBaseSource) ListResources(ctx context.Context, name, subpath string) ([]string, error) { + m.listResourcesCalls++ + if m.listResourcesErr != nil { + return nil, m.listResourcesErr + } + res, ok := m.resources[name] + if !ok { + return nil, ErrSkillNotFound + } + var paths []string + for p := range res { + paths = append(paths, p) + } + slices.Sort(paths) + if subpath != "." { + return nil, fmt.Errorf("unexpected subpath %q: mock only supports '.' as required by complete preload", subpath) + } + return paths, nil +} + +func (m *mockCompletePreloadBaseSource) LoadResource(ctx context.Context, name, resourcePath string) (io.ReadCloser, error) { + m.loadResourceCalls++ + if m.loadResourceErr != nil { + return nil, m.loadResourceErr + } + res, ok := m.resources[name] + if !ok { + return nil, ErrSkillNotFound + } + data, ok := res[resourcePath] + if !ok { + return nil, ErrResourceNotFound + } + return io.NopCloser(bytes.NewReader(data)), nil +} + +func (m *mockCompletePreloadBaseSource) verifyCallCount(listFrontmattersCalls, listResourcesCalls, loadInstructionsCalls, loadResourceCalls int) error { + if m.listFrontmattersCalls != listFrontmattersCalls { + return fmt.Errorf("listFrontmattersCalls: %d, want %d", m.listFrontmattersCalls, listFrontmattersCalls) + } + if m.listResourcesCalls != listResourcesCalls { + return fmt.Errorf("listResourcesCalls: %d, want %d", m.listResourcesCalls, listResourcesCalls) + } + if m.loadInstructionsCalls != loadInstructionsCalls { + return fmt.Errorf("loadInstructionsCalls: %d, want %d", m.loadInstructionsCalls, loadInstructionsCalls) + } + if m.loadResourceCalls != loadResourceCalls { + return fmt.Errorf("loadResourceCalls: %d, want %d", m.loadResourceCalls, loadResourceCalls) + } + return nil +} + +type collectedSourceData struct { + // Fields are exported to enable comparison using cmp.Diff. + Frontmatters []*Frontmatter + Instructions map[string]string + Resources map[string]map[string][]byte +} + +func collectSourceData(ctx context.Context, source Source) (*collectedSourceData, error) { + collected := &collectedSourceData{} + frontmatters, err := source.ListFrontmatters(ctx) + if err != nil { + return nil, err + } + collected.Frontmatters = frontmatters + for _, fm := range frontmatters { + loadedFrontmatter, err := source.LoadFrontmatter(ctx, fm.Name) + if err != nil { + return nil, err + } + if diff := cmp.Diff(fm, loadedFrontmatter); diff != "" { + return nil, fmt.Errorf("frontmatter mismatch for %q:%s", fm.Name, diff) + } + instructions, err := source.LoadInstructions(ctx, fm.Name) + if err != nil { + return nil, err + } + if collected.Instructions == nil { + collected.Instructions = make(map[string]string) + } + collected.Instructions[fm.Name] = instructions + resources, err := source.ListResources(ctx, fm.Name, ".") + if err != nil { + return nil, err + } + if len(resources) == 0 { + continue + } + if collected.Resources == nil { + collected.Resources = make(map[string]map[string][]byte) + } + collected.Resources[fm.Name] = make(map[string][]byte) + for _, resourcePath := range resources { + resource, err := source.LoadResource(ctx, fm.Name, resourcePath) + if err != nil { + return nil, fmt.Errorf("loading resource %q:%s: %w", fm.Name, resourcePath, err) + } + data, err := io.ReadAll(resource) + _ = resource.Close() + if err != nil { + return nil, fmt.Errorf("reading resource %q:%s: %w", fm.Name, resourcePath, err) + } + collected.Resources[fm.Name][resourcePath] = data + } + } + return collected, nil +} From df0396730963b4440d01f70d1d256140e3dbd2c9 Mon Sep 17 00:00:00 2001 From: Artsiom Shut <63152812+foxfrikses@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:58:55 +0200 Subject: [PATCH 25/86] feat: Add preload frontmatters skill source proxy. (#748) Given instructions and resource files can be large, it might turn out to be expensive to load them all to RAM. Fortunately, at this stage of skill toolset implementation, instructions and resources are most likely going to be read just once - and then they'll stay in the conversation history. Frontmatters, on the other hand, are going to be listed frequently, and frontmatters are relatively lightweight, thus a proxy caching only the frontmatters (as opposed to the full preload) can help reach maximum benefit. --- .../skilltoolset/skill/frontmatter_preload.go | 84 ++++++++ .../skill/frontmatter_preload_test.go | 188 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 tool/skilltoolset/skill/frontmatter_preload.go create mode 100644 tool/skilltoolset/skill/frontmatter_preload_test.go diff --git a/tool/skilltoolset/skill/frontmatter_preload.go b/tool/skilltoolset/skill/frontmatter_preload.go new file mode 100644 index 000000000..deff0826b --- /dev/null +++ b/tool/skilltoolset/skill/frontmatter_preload.go @@ -0,0 +1,84 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skill + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" +) + +type frontmatterPreloadSource struct { + Source + mu sync.RWMutex + frontmatters map[string]*Frontmatter // for fast lookup. + sorted []*Frontmatter // for deterministic listing. +} + +// WithFrontmatterPreloadSource returns a Source proxy that wraps the given +// source and eagerly preloads all skill Frontmatters into memory upon creation. +// This optimizes future calls to ListFrontmatters and LoadFrontmatter. +// Additionally, 'reload' callback is returned: calling it actualizes +// data by loading frontmatters anew. +func WithFrontmatterPreloadSource(ctx context.Context, source Source) (Source, func(context.Context) error, error) { + s := &frontmatterPreloadSource{Source: source} + if err := s.reload(ctx); err != nil { + return nil, nil, err + } + return s, s.reload, nil +} + +func (s *frontmatterPreloadSource) ListFrontmatters(ctx context.Context) ([]*Frontmatter, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.sorted, nil +} + +func (s *frontmatterPreloadSource) LoadFrontmatter(ctx context.Context, name string) (*Frontmatter, error) { + s.mu.RLock() + defer s.mu.RUnlock() + frontmatter, exists := s.frontmatters[name] + if !exists { + return nil, ErrSkillNotFound + } + return frontmatter, nil +} + +func (s *frontmatterPreloadSource) reload(ctx context.Context) error { + sorted, err := s.Source.ListFrontmatters(ctx) + if err != nil { + return fmt.Errorf("preload: %w", err) + } + sorted = slices.Clone(sorted) + slices.SortFunc(sorted, func(lhs, rhs *Frontmatter) int { + return strings.Compare(lhs.Name, rhs.Name) + }) + + frontmatters := make(map[string]*Frontmatter, len(sorted)) + for _, frontmatter := range sorted { + if _, exists := frontmatters[frontmatter.Name]; exists { + return fmt.Errorf("%w: %q", ErrDuplicateSkill, frontmatter.Name) + } + frontmatters[frontmatter.Name] = frontmatter + } + + s.mu.Lock() + s.frontmatters = frontmatters + s.sorted = sorted + s.mu.Unlock() + return nil +} diff --git a/tool/skilltoolset/skill/frontmatter_preload_test.go b/tool/skilltoolset/skill/frontmatter_preload_test.go new file mode 100644 index 000000000..5d4b4024c --- /dev/null +++ b/tool/skilltoolset/skill/frontmatter_preload_test.go @@ -0,0 +1,188 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skill + +import ( + "context" + "errors" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestWithFrontmatterPreloadSource_Success(t *testing.T) { + // Test Init. + mock := &mockFrontmatterPreloadBaseSource{ + frontmatters: []*Frontmatter{{Name: "B"}, {Name: "A"}}, + } + source, reload, err := WithFrontmatterPreloadSource(t.Context(), mock) + if err != nil { + t.Fatalf("WithFrontmatterPreloadSource() returned unexpected error: %v", err) + } + if got, want := mock.listFrontmattersCalls, 1; got != want { + t.Errorf("mock.listFrontmattersCalls = %v, want %v", got, want) + } + got, err := source.ListFrontmatters(t.Context()) + if err != nil { + t.Fatalf("source.ListFrontmatters() returned unexpected error: %v", err) + } + want := []*Frontmatter{{Name: "A"}, {Name: "B"}} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ListFrontmatters mismatch (-want +got):\n%s", diff) + } + + // Test Reload. + mock.frontmatters = []*Frontmatter{{Name: "C"}} + err = reload(t.Context()) + if err != nil { + t.Fatalf("reload() returned unexpected error: %v", err) + } + if got, want := mock.listFrontmattersCalls, 2; got != want { + t.Errorf("mock.listFrontmattersCalls = %v, want %v after reload", got, want) + } + got, err = source.ListFrontmatters(t.Context()) + if err != nil { + t.Fatalf("source.ListFrontmatters() returned unexpected error: %v", err) + } + want = []*Frontmatter{{Name: "C"}} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ListFrontmatters mismatch after reload (-want +got):\n%s", diff) + } +} + +func TestWithFrontmatterPreloadSource_DuplicateSkill(t *testing.T) { + mock := &mockFrontmatterPreloadBaseSource{ + frontmatters: []*Frontmatter{{Name: "A"}, {Name: "A"}}, + } + + _, _, err := WithFrontmatterPreloadSource(t.Context(), mock) + + if !errors.Is(err, ErrDuplicateSkill) { + t.Errorf("WithFrontmatterPreloadSource error = %v, want %v", err, ErrDuplicateSkill) + } +} + +func TestWithFrontmatterPreloadSource_ReloadFailureDoesNotInvalidateCache(t *testing.T) { + mock := &mockFrontmatterPreloadBaseSource{frontmatters: []*Frontmatter{{Name: "A"}}} + source, reload, err := WithFrontmatterPreloadSource(t.Context(), mock) + if err != nil { + t.Fatalf("WithFrontmatterPreloadSource failed: %v", err) + } + testErr := errors.New("reload error") + mock.listFrontmattersErr = testErr + + err = reload(t.Context()) + + // If reload fails, the cache should still be valid. + if !errors.Is(err, testErr) { + t.Fatalf("Reload error = %v, want %v", err, testErr) + } + got, err := source.ListFrontmatters(t.Context()) + if err != nil { + t.Fatalf("ListFrontmatters failed: %v", err) + } + if diff := cmp.Diff([]*Frontmatter{{Name: "A"}}, got); diff != "" { + t.Errorf("ListFrontmatters mismatch (-want +got):\n%s", diff) + } +} + +func TestFrontmatterPreloadSource_ListFrontmatters(t *testing.T) { + mock := &mockFrontmatterPreloadBaseSource{frontmatters: []*Frontmatter{{Name: "A"}}} + + source, _, err := WithFrontmatterPreloadSource(t.Context(), mock) + if err != nil { + t.Fatalf("WithFrontmatterPreloadSource failed: %v", err) + } + + // Should be called once on init. + if got, want := mock.listFrontmattersCalls, 1; got != want { + t.Errorf("mock.listFrontmattersCalls = %v, want %v", got, want) + } + // Calls should be cached. + got1, err := source.ListFrontmatters(t.Context()) + if err != nil { + t.Fatalf("ListFrontmatters failed: %v", err) + } + got2, err := source.ListFrontmatters(t.Context()) + if err != nil { + t.Fatalf("ListFrontmatters failed: %v", err) + } + // Should not change since list is cached. + if got, want := mock.listFrontmattersCalls, 1; got != want { + t.Errorf("mock.listFrontmattersCalls = %v, want %v", got, want) + } + if diff := cmp.Diff(got1, got2); diff != "" { + t.Errorf("ListFrontmatters results differ (-got1 +got2):\n%s", diff) + } +} + +func TestFrontmatterPreloadSource_LoadFrontmatter(t *testing.T) { + mock := &mockFrontmatterPreloadBaseSource{ + frontmatters: []*Frontmatter{{Name: "A", Description: "desc A"}}, + } + + source, _, err := WithFrontmatterPreloadSource(t.Context(), mock) + if err != nil { + t.Fatalf("WithFrontmatterPreloadSource failed: %v", err) + } + + // Success case. + got, err := source.LoadFrontmatter(t.Context(), "A") + if err != nil { + t.Fatalf("source.LoadFrontmatter() returned unexpected error: %v", err) + } + want := &Frontmatter{Name: "A", Description: "desc A"} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("LoadFrontmatter mismatch (-want +got):\n%s", diff) + } + + // NotFound case. + _, err = source.LoadFrontmatter(t.Context(), "B") + if !errors.Is(err, ErrSkillNotFound) { + t.Errorf("LoadFrontmatter error = %v, want %v", err, ErrSkillNotFound) + } + + // Check LoadFrontmatter was not called for existing or non-existing skill - + // they were preloaded. + if got, want := mock.loadFrontmatterCalls, 0; got != want { + t.Errorf("mock.loadFrontmatterCalls = %v, want %v (cached)", got, want) + } +} + +type mockFrontmatterPreloadBaseSource struct { + Source + frontmatters []*Frontmatter + listFrontmattersErr error + listFrontmattersCalls int + loadFrontmatterCalls int +} + +func (m *mockFrontmatterPreloadBaseSource) ListFrontmatters(ctx context.Context) ([]*Frontmatter, error) { + m.listFrontmattersCalls++ + if m.listFrontmattersErr != nil { + return nil, m.listFrontmattersErr + } + return m.frontmatters, nil +} + +func (m *mockFrontmatterPreloadBaseSource) LoadFrontmatter(ctx context.Context, name string) (*Frontmatter, error) { + m.loadFrontmatterCalls++ + for _, fm := range m.frontmatters { + if fm.Name == name { + return fm, nil + } + } + return nil, ErrSkillNotFound +} From 4093a8add38f7c15ab8c8fa2068b6505a92db091 Mon Sep 17 00:00:00 2001 From: Artsiom Shut <63152812+foxfrikses@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:30:40 +0200 Subject: [PATCH 26/86] feat: Add merged skill source proxy. (#747) This proxy allows combining multiple skill sources into a single source. --- tool/skilltoolset/skill/merged_source.go | 107 +++++ tool/skilltoolset/skill/merged_source_test.go | 410 ++++++++++++++++++ 2 files changed, 517 insertions(+) create mode 100644 tool/skilltoolset/skill/merged_source.go create mode 100644 tool/skilltoolset/skill/merged_source_test.go diff --git a/tool/skilltoolset/skill/merged_source.go b/tool/skilltoolset/skill/merged_source.go new file mode 100644 index 000000000..f8cd98a4b --- /dev/null +++ b/tool/skilltoolset/skill/merged_source.go @@ -0,0 +1,107 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skill + +import ( + "context" + "errors" + "fmt" + "io" + "slices" +) + +type mergedSource struct { + sources []Source +} + +// NewMergedSource creates a Source that combines multiple underlying +// Source implementations. The sources are queried in the order they are +// provided. +func NewMergedSource(sources ...Source) Source { + return &mergedSource{sources: slices.Clone(sources)} +} + +func (m *mergedSource) ListFrontmatters(ctx context.Context) ([]*Frontmatter, error) { + var allFrontmatters []*Frontmatter + names := make(map[string]bool) // Track skill names to detect duplicates. + + for _, source := range m.sources { + frontmatters, err := source.ListFrontmatters(ctx) + if err != nil { + return nil, err + } + for _, fm := range frontmatters { + if names[fm.Name] { + return nil, fmt.Errorf("%w: %q", ErrDuplicateSkill, fm.Name) + } + names[fm.Name] = true + } + allFrontmatters = append(allFrontmatters, frontmatters...) + } + + return allFrontmatters, nil +} + +func (m *mergedSource) ListResources(ctx context.Context, name, subpath string) ([]string, error) { + for _, source := range m.sources { + res, err := source.ListResources(ctx, name, subpath) + if err == nil { + return res, nil + } + if !errors.Is(err, ErrSkillNotFound) { + return nil, err + } + } + return nil, ErrSkillNotFound +} + +func (m *mergedSource) LoadFrontmatter(ctx context.Context, name string) (*Frontmatter, error) { + for _, source := range m.sources { + frontmatter, err := source.LoadFrontmatter(ctx, name) + if err == nil { + return frontmatter, nil + } + if !errors.Is(err, ErrSkillNotFound) { + return nil, err + } + } + return nil, ErrSkillNotFound +} + +func (m *mergedSource) LoadInstructions(ctx context.Context, name string) (string, error) { + for _, source := range m.sources { + instructions, err := source.LoadInstructions(ctx, name) + if err == nil { + return instructions, nil + } + if !errors.Is(err, ErrSkillNotFound) { + return "", err + } + } + return "", ErrSkillNotFound +} + +func (m *mergedSource) LoadResource(ctx context.Context, name, resourcePath string) (io.ReadCloser, error) { + for _, source := range m.sources { + resource, err := source.LoadResource(ctx, name, resourcePath) + if err == nil { + return resource, nil + } + if !errors.Is(err, ErrSkillNotFound) { + return nil, err + } + } + return nil, ErrSkillNotFound +} diff --git a/tool/skilltoolset/skill/merged_source_test.go b/tool/skilltoolset/skill/merged_source_test.go new file mode 100644 index 000000000..ad4e42cd7 --- /dev/null +++ b/tool/skilltoolset/skill/merged_source_test.go @@ -0,0 +1,410 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skill + +import ( + "bytes" + "context" + "errors" + "io" + "maps" + "path" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestMergedSource_ListFrontmatters(t *testing.T) { + testErr := errors.New("test error") + + tests := []struct { + name string + sources []Source + want []*Frontmatter + wantErr error + }{ + { + name: "duplicates in a single source", + sources: []Source{ + &mockMergedBaseSource{frontmatters: []*Frontmatter{{Name: "skill1"}, {Name: "skill1"}}}, + }, + wantErr: ErrDuplicateSkill, + }, + { + name: "duplicates among sources", + sources: []Source{ + &mockMergedBaseSource{frontmatters: []*Frontmatter{{Name: "skill1"}}}, + &mockMergedBaseSource{frontmatters: []*Frontmatter{{Name: "skill1"}}}, + }, + wantErr: ErrDuplicateSkill, + }, + { + name: "error in source is returned", + sources: []Source{ + &mockMergedBaseSource{listFrontmattersErr: testErr}, + }, + wantErr: testErr, + }, + { + name: "frontmatters are in the order of sources", + sources: []Source{ + &mockMergedBaseSource{frontmatters: []*Frontmatter{{Name: "D"}, {Name: "B"}}}, + &mockMergedBaseSource{frontmatters: []*Frontmatter{{Name: "A"}, {Name: "C"}}}, + }, + want: []*Frontmatter{{Name: "D"}, {Name: "B"}, {Name: "A"}, {Name: "C"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + merged := NewMergedSource(tc.sources...) + got, err := merged.ListFrontmatters(t.Context()) + if !errors.Is(err, tc.wantErr) { + t.Errorf("ListFrontmatters() error = %v, want %v", err, tc.wantErr) + } + if err != nil { + return + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("ListFrontmatters mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestMergedSource_LoadFrontmatter(t *testing.T) { + testErr := errors.New("test error") + + tests := []struct { + name string + sources []Source + skillName string + want *Frontmatter + wantErr error + }{ + { + name: "returns first success and stops search", + sources: []Source{ + &mockMergedBaseSource{frontmatters: []*Frontmatter{{Name: "skill A"}}}, + &mockMergedBaseSource{frontmatters: []*Frontmatter{{Name: "skill B", Description: "desc B"}}}, + &mockMergedBaseSource{frontmatters: []*Frontmatter{{Name: "skill B", Description: "desc B dup"}}}, + &mockMergedBaseSource{loadFrontmatterErr: testErr}, + }, + skillName: "skill B", + want: &Frontmatter{Name: "skill B", Description: "desc B"}, + }, + { + name: "error precedence", + sources: []Source{ + &mockMergedBaseSource{loadFrontmatterErr: testErr}, + &mockMergedBaseSource{frontmatters: []*Frontmatter{{Name: "skill A"}}}, + }, + skillName: "skill A", + wantErr: testErr, + }, + { + name: "skill not found", + sources: []Source{&mockMergedBaseSource{}, &mockMergedBaseSource{}}, + skillName: "skill C", + wantErr: ErrSkillNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + merged := NewMergedSource(tc.sources...) + got, err := merged.LoadFrontmatter(t.Context(), tc.skillName) + if !errors.Is(err, tc.wantErr) { + t.Errorf("LoadFrontmatter(%q) error = %v, want %v", tc.skillName, err, tc.wantErr) + } + if err != nil { + return + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("LoadFrontmatter mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestMergedSource_LoadInstructions(t *testing.T) { + testErr := errors.New("test error") + + tests := []struct { + name string + sources []Source + skillName string + want string + wantErr error + }{ + { + name: "returns first success and stops search", + sources: []Source{ + &mockMergedBaseSource{instructions: map[string]string{"skill A": "inst A"}}, + &mockMergedBaseSource{instructions: map[string]string{"skill B": "inst B"}}, + &mockMergedBaseSource{instructions: map[string]string{"skill B": "inst B dup"}}, + &mockMergedBaseSource{loadInstructionsErr: testErr}, + }, + skillName: "skill B", + want: "inst B", + }, + { + name: "error precedence", + sources: []Source{ + &mockMergedBaseSource{loadInstructionsErr: testErr}, + &mockMergedBaseSource{instructions: map[string]string{"skill A": "inst A"}}, + }, + skillName: "skill A", + wantErr: testErr, + }, + { + name: "skill not found", + sources: []Source{&mockMergedBaseSource{}, &mockMergedBaseSource{}}, + skillName: "skill C", + wantErr: ErrSkillNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + merged := NewMergedSource(tc.sources...) + got, err := merged.LoadInstructions(t.Context(), tc.skillName) + if !errors.Is(err, tc.wantErr) { + t.Errorf("LoadInstructions(%q) error = %v, want %v", tc.skillName, err, tc.wantErr) + } + if err != nil { + return + } + if got != tc.want { + t.Errorf("LoadInstructions(%q) = %q, want %q", tc.skillName, got, tc.want) + } + }) + } +} + +func TestMergedSource_ListResources(t *testing.T) { + testErr := errors.New("test error") + + tests := []struct { + name string + sources []Source + skillName string + want []string + wantErr error + }{ + { + name: "returns first success and stops search", + sources: []Source{ + &mockMergedBaseSource{resources: map[string]map[string]string{"skill A": {"p1": "c1"}}}, + &mockMergedBaseSource{resources: map[string]map[string]string{"skill B": {"p2": "c2"}}}, + &mockMergedBaseSource{resources: map[string]map[string]string{"skill B": {"p3": "c3"}}}, + &mockMergedBaseSource{listResourcesErr: testErr}, + }, + skillName: "skill B", + want: []string{"p2"}, + }, + { + name: "error precedence", + sources: []Source{ + &mockMergedBaseSource{listResourcesErr: testErr}, + &mockMergedBaseSource{resources: map[string]map[string]string{"skill A": {"p1": "c1"}}}, + }, + skillName: "skill A", + wantErr: testErr, + }, + { + name: "ResourceNotFound stops query", + sources: []Source{ + &mockMergedBaseSource{ + resources: map[string]map[string]string{"skill A": {}}, + listResourcesErr: ErrResourceNotFound, + }, + &mockMergedBaseSource{resources: map[string]map[string]string{"skill A": {"p1": "c1"}}}, + }, + skillName: "skill A", + wantErr: ErrResourceNotFound, + }, + { + name: "skill not found", + sources: []Source{&mockMergedBaseSource{}, &mockMergedBaseSource{}}, + skillName: "skill C", + wantErr: ErrSkillNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + merged := NewMergedSource(tc.sources...) + got, err := merged.ListResources(t.Context(), tc.skillName, ".") + if !errors.Is(err, tc.wantErr) { + t.Errorf("ListResources(%q, %q) error = %v, want %v", tc.skillName, ".", err, tc.wantErr) + } + if err != nil { + return + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("ListResources mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestMergedSource_LoadResource(t *testing.T) { + testErr := errors.New("test error") + + tests := []struct { + name string + sources []Source + skillName string + resourcePath string + want string + wantErr error + }{ + { + name: "returns first success and stops search", + sources: []Source{ + &mockMergedBaseSource{resources: map[string]map[string]string{"skill A": {"p1": "c1"}}}, + &mockMergedBaseSource{resources: map[string]map[string]string{"skill B": {"p2": "c2"}}}, + &mockMergedBaseSource{resources: map[string]map[string]string{"skill B": {"p3": "c3"}}}, + &mockMergedBaseSource{loadResourceErr: testErr}, + }, + skillName: "skill B", + resourcePath: "p2", + want: "c2", + }, + { + name: "error precedence", + sources: []Source{ + &mockMergedBaseSource{loadResourceErr: testErr}, + &mockMergedBaseSource{resources: map[string]map[string]string{"skill A": {"p1": "c1"}}}, + }, + skillName: "skill A", + resourcePath: "p1", + wantErr: testErr, + }, + { + name: "ResourceNotFound stops query", + sources: []Source{ + &mockMergedBaseSource{ + resources: map[string]map[string]string{"skill A": {}}, + loadResourceErr: ErrResourceNotFound, + }, + &mockMergedBaseSource{resources: map[string]map[string]string{"skill A": {"p1": "c1"}}}, + }, + skillName: "skill A", + resourcePath: "p1", + wantErr: ErrResourceNotFound, + }, + { + name: "skill not found", + sources: []Source{ + &mockMergedBaseSource{}, + &mockMergedBaseSource{}, + }, + skillName: "skill C", + resourcePath: "p1", + wantErr: ErrSkillNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + merged := NewMergedSource(tc.sources...) + got, err := merged.LoadResource(t.Context(), tc.skillName, tc.resourcePath) + if !errors.Is(err, tc.wantErr) { + t.Errorf("LoadResource(%q, %q) error = %v, want %v", tc.skillName, tc.resourcePath, err, tc.wantErr) + } + if err != nil { + return + } + data, err := io.ReadAll(got) + if err != nil { + t.Fatal(err) + } + if string(data) != tc.want { + t.Errorf("LoadResource(%q, %q) = %q, want %q", tc.skillName, tc.resourcePath, string(data), tc.want) + } + }) + } +} + +type mockMergedBaseSource struct { + frontmatters []*Frontmatter + instructions map[string]string + resources map[string]map[string]string + + listFrontmattersErr error + loadFrontmatterErr error + loadInstructionsErr error + listResourcesErr error + loadResourceErr error +} + +func (m *mockMergedBaseSource) ListFrontmatters(ctx context.Context) ([]*Frontmatter, error) { + if m.listFrontmattersErr != nil { + return nil, m.listFrontmattersErr + } + return m.frontmatters, nil +} + +func (m *mockMergedBaseSource) ListResources(ctx context.Context, name, _ string) ([]string, error) { + if m.listResourcesErr != nil { + return nil, m.listResourcesErr + } + skillResource, found := m.resources[name] + if !found { + return nil, ErrSkillNotFound + } + return slices.SortedFunc(maps.Keys(skillResource), strings.Compare), nil +} + +func (m *mockMergedBaseSource) LoadFrontmatter(ctx context.Context, name string) (*Frontmatter, error) { + if m.loadFrontmatterErr != nil { + return nil, m.loadFrontmatterErr + } + for _, frontmatter := range m.frontmatters { + if frontmatter.Name == name { + return frontmatter, nil + } + } + return nil, ErrSkillNotFound +} + +func (m *mockMergedBaseSource) LoadInstructions(ctx context.Context, name string) (string, error) { + if m.loadInstructionsErr != nil { + return "", m.loadInstructionsErr + } + if v, ok := m.instructions[name]; ok { + return v, nil + } + return "", ErrSkillNotFound +} + +func (m *mockMergedBaseSource) LoadResource(ctx context.Context, name, resourcePath string) (io.ReadCloser, error) { + if m.loadResourceErr != nil { + return nil, m.loadResourceErr + } + skillResource, found := m.resources[name] + if !found { + return nil, ErrSkillNotFound + } + content, found := skillResource[path.Clean(resourcePath)] + if !found { + return nil, ErrResourceNotFound + } + return io.NopCloser(bytes.NewReader([]byte(content))), nil +} From a586408fe4376c7337496bcdaf3525f64eef194b Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Thu, 23 Apr 2026 21:08:10 +0200 Subject: [PATCH 27/86] Agent Engine support (#749) * Agent Engine support * Test fix * Fixes * Fixes * Error handling fix * Fixes * Fixes in encode * Fixed encode * sseTimeout fix * reflection fix * sseWriteTime fix * fix * encode fixes * linter fixes * various fixes * encode tests * linter fixes * fix * linter fixes * Fixes in encode --- .../deploy/agentengine/agentengine.go | 21 +- .../internal/deploy/cloudrun/cloudrun.go | 16 +- cmd/launcher/agentengine/agentengine.go | 28 ++ cmd/launcher/web/agentengine/agentengine.go | 119 +++++++ examples/agentengine/main.go | 106 +++++++ .../agentengine/controllers/agent_engine.go | 94 ++++++ .../controllers/method/create_session.go | 122 ++++++++ .../controllers/method/delete_session.go | 115 +++++++ .../controllers/method/get_session.go | 117 +++++++ .../controllers/method/list_sessions.go | 114 +++++++ .../agentengine/controllers/method/method.go | 31 ++ .../controllers/method/stream_query.go | 185 +++++++++++ server/agentengine/handler.go | 115 +++++++ .../agentengine/internal/helper/emit_json.go | 71 +++++ server/agentengine/internal/helper/encode.go | 290 ++++++++++++++++++ .../internal/helper/encode_test.go | 253 +++++++++++++++ server/agentengine/internal/models/query.go | 23 ++ server/agentengine/internal/models/session.go | 123 ++++++++ .../internal/models/stream_query.go | 39 +++ .../internal/routers/reasoning_engine.go | 43 +++ .../agentengine/internal/routers/routers.go | 53 ++++ .../routers/stream_reasoning_engine.go | 43 +++ 22 files changed, 2107 insertions(+), 14 deletions(-) create mode 100644 cmd/launcher/agentengine/agentengine.go create mode 100644 cmd/launcher/web/agentengine/agentengine.go create mode 100644 examples/agentengine/main.go create mode 100644 server/agentengine/controllers/agent_engine.go create mode 100644 server/agentengine/controllers/method/create_session.go create mode 100644 server/agentengine/controllers/method/delete_session.go create mode 100644 server/agentengine/controllers/method/get_session.go create mode 100644 server/agentengine/controllers/method/list_sessions.go create mode 100644 server/agentengine/controllers/method/method.go create mode 100644 server/agentengine/controllers/method/stream_query.go create mode 100644 server/agentengine/handler.go create mode 100644 server/agentengine/internal/helper/emit_json.go create mode 100644 server/agentengine/internal/helper/encode.go create mode 100644 server/agentengine/internal/helper/encode_test.go create mode 100644 server/agentengine/internal/models/query.go create mode 100644 server/agentengine/internal/models/session.go create mode 100644 server/agentengine/internal/models/stream_query.go create mode 100644 server/agentengine/internal/routers/reasoning_engine.go create mode 100644 server/agentengine/internal/routers/routers.go create mode 100644 server/agentengine/internal/routers/stream_reasoning_engine.go diff --git a/cmd/adkgo/internal/deploy/agentengine/agentengine.go b/cmd/adkgo/internal/deploy/agentengine/agentengine.go index a20f6ca98..206fa16ca 100644 --- a/cmd/adkgo/internal/deploy/agentengine/agentengine.go +++ b/cmd/adkgo/internal/deploy/agentengine/agentengine.go @@ -18,6 +18,7 @@ package agentengine import ( "context" + "encoding/json" "fmt" "os" "os/exec" @@ -34,6 +35,7 @@ import ( "google.golang.org/adk/cmd/adkgo/internal/deploy" "google.golang.org/adk/internal/cli/util" + "google.golang.org/adk/server/agentengine" ) type gCloudFlags struct { @@ -45,7 +47,6 @@ type agentEngineServiceFlags struct { name string displayName string serverPort int - api bool // enable api or not } type buildFlags struct { @@ -93,7 +94,6 @@ func init() { agentEngineCmd.PersistentFlags().IntVar(&flags.agentEngine.serverPort, "server_port", 8080, "agentEngine server port") agentEngineCmd.PersistentFlags().StringVarP(&flags.source.entryPointPath, "entry_point_path", "e", "", "Path to an entry point (go 'main')") agentEngineCmd.PersistentFlags().StringVarP(&flags.source.sourceDir, "source_dir", "d", "", "Directory to archive, defaults to current working directory") - agentEngineCmd.PersistentFlags().BoolVar(&flags.agentEngine.api, "api", true, "Enable API") } // computeFlags uses command line arguments to create a full config @@ -168,7 +168,7 @@ func (f *deployAgentEngineFlags) prepareDockerfile() error { FROM golang:1.25 as builder WORKDIR /app COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ` + f.build.execFile + ` ` + f.source.origEntryPointPath + ` +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ` + f.build.execFile + ` ` + f.source.origEntryPointPath + ` FROM gcr.io/distroless/static-debian11 @@ -177,9 +177,7 @@ EXPOSE ` + strconv.Itoa(flags.agentEngine.serverPort) + ` # Command to run the executable when the container starts CMD ["/app/` + f.build.execFile + `", "web", "-port", "` + strconv.Itoa(flags.agentEngine.serverPort) + `"`) - if flags.agentEngine.api { - b.WriteString(`, "api"`) - } + b.WriteString(`, "agentengine"`) b.WriteString(`]`) return os.WriteFile(f.build.dockerfileBuildPath, []byte(b.String()), 0o600) @@ -228,6 +226,16 @@ func (f *deployAgentEngineFlags) gcloudDeployToAgentEngine() error { return fmt.Errorf("cannot read archive file: %w", err) } + methods, err := agentengine.ListClassMethods() + if err != nil { + return fmt.Errorf("cannot list class methods: %w", err) + } + methodsJSON, err := json.Marshal(methods) + if err != nil { + return fmt.Errorf("cannot marshal methods: %w", err) + } + p("Methods:", string(methodsJSON)) + req := &aiplatformpb.CreateReasoningEngineRequest{ Parent: parent, ReasoningEngine: &aiplatformpb.ReasoningEngine{ @@ -257,6 +265,7 @@ func (f *deployAgentEngineFlags) gcloudDeployToAgentEngine() error { {Name: "GOOGLE_API_KEY", SecretRef: &aiplatformpb.SecretRef{Secret: "GOOGLE_API_KEY", Version: "latest"}}, }, }, + ClassMethods: methods, }, }, } diff --git a/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go b/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go index e3688f23e..f5391ad20 100644 --- a/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go +++ b/cmd/adkgo/internal/deploy/cloudrun/cloudrun.go @@ -219,17 +219,17 @@ CMD ["/app/` + f.build.execFile + `", "web", "-port", "` + strconv.Itoa(flags.cl } if flags.cloudRun.pubsub { b.WriteString(`, "pubsub"`) - b.WriteString(fmt.Sprintf(`, "--trigger_max_retries", "%d"`, flags.cloudRun.pubsubTrigger.maxRetries)) - b.WriteString(fmt.Sprintf(`, "--trigger_base_delay", "%s"`, flags.cloudRun.pubsubTrigger.baseDelay.String())) - b.WriteString(fmt.Sprintf(`, "--trigger_max_delay", "%s"`, flags.cloudRun.pubsubTrigger.maxDelay.String())) - b.WriteString(fmt.Sprintf(`, "--trigger_max_concurrent_runs", "%d"`, flags.cloudRun.pubsubTrigger.maxRuns)) + fmt.Fprintf(&b, `, "--trigger_max_retries", "%d"`, flags.cloudRun.pubsubTrigger.maxRetries) + fmt.Fprintf(&b, `, "--trigger_base_delay", "%s"`, flags.cloudRun.pubsubTrigger.baseDelay.String()) + fmt.Fprintf(&b, `, "--trigger_max_delay", "%s"`, flags.cloudRun.pubsubTrigger.maxDelay.String()) + fmt.Fprintf(&b, `, "--trigger_max_concurrent_runs", "%d"`, flags.cloudRun.pubsubTrigger.maxRuns) } if flags.cloudRun.eventarc { b.WriteString(`, "eventarc"`) - b.WriteString(fmt.Sprintf(`, "--trigger_max_retries", "%d"`, flags.cloudRun.eventarcTrigger.maxRetries)) - b.WriteString(fmt.Sprintf(`, "--trigger_base_delay", "%s"`, flags.cloudRun.eventarcTrigger.baseDelay.String())) - b.WriteString(fmt.Sprintf(`, "--trigger_max_delay", "%s"`, flags.cloudRun.eventarcTrigger.maxDelay.String())) - b.WriteString(fmt.Sprintf(`, "--trigger_max_concurrent_runs", "%d"`, flags.cloudRun.eventarcTrigger.maxRuns)) + fmt.Fprintf(&b, `, "--trigger_max_retries", "%d"`, flags.cloudRun.eventarcTrigger.maxRetries) + fmt.Fprintf(&b, `, "--trigger_base_delay", "%s"`, flags.cloudRun.eventarcTrigger.baseDelay.String()) + fmt.Fprintf(&b, `, "--trigger_max_delay", "%s"`, flags.cloudRun.eventarcTrigger.maxDelay.String()) + fmt.Fprintf(&b, `, "--trigger_max_concurrent_runs", "%d"`, flags.cloudRun.eventarcTrigger.maxRuns) } b.WriteString(`]`) return os.WriteFile(f.build.dockerfileBuildPath, []byte(b.String()), 0o600) diff --git a/cmd/launcher/agentengine/agentengine.go b/cmd/launcher/agentengine/agentengine.go new file mode 100644 index 000000000..4d60c6c9e --- /dev/null +++ b/cmd/launcher/agentengine/agentengine.go @@ -0,0 +1,28 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package agentengine provides easy way to deploy to AgentEngine. +package agentengine + +import ( + "google.golang.org/adk/cmd/launcher" + "google.golang.org/adk/cmd/launcher/universal" + "google.golang.org/adk/cmd/launcher/web" + webagentengine "google.golang.org/adk/cmd/launcher/web/agentengine" +) + +// NewLauncher returns a launcher capable of serving queries from AgentEngine. +func NewLauncher(agentEngineId string) launcher.Launcher { + return universal.NewLauncher(web.NewLauncher(webagentengine.NewLauncher(agentEngineId))) +} diff --git a/cmd/launcher/web/agentengine/agentengine.go b/cmd/launcher/web/agentengine/agentengine.go new file mode 100644 index 000000000..e66ef2b4c --- /dev/null +++ b/cmd/launcher/web/agentengine/agentengine.go @@ -0,0 +1,119 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package agentengine provides a sublauncher that provides web interface as required by Agent Engine +package agentengine + +import ( + "flag" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gorilla/mux" + + "google.golang.org/adk/cmd/launcher" + weblauncher "google.golang.org/adk/cmd/launcher/web" + "google.golang.org/adk/internal/cli/util" + "google.golang.org/adk/server/agentengine" +) + +// agentEngineConfig contains parameters for launching ADK Agent Engine server +type agentEngineConfig struct { + pathPrefix string + agentEngineID string + maxPayloadSize int64 + sseWriteTimeout time.Duration +} + +type agentEngineLauncher struct { + flags *flag.FlagSet // flags are used to parse command-line arguments + config *agentEngineConfig +} + +// NewLauncher creates new api launcher. It extends Web launcher +func NewLauncher(agentEngineId string) weblauncher.Sublauncher { + config := &agentEngineConfig{} + + fs := flag.NewFlagSet("web", flag.ContinueOnError) + fs.StringVar(&config.pathPrefix, "path_prefix", "/api", "ADK Agent Engine API path prefix. Default is '/api'.") + fs.Int64Var(&config.maxPayloadSize, "max_payload_size", 10*1024*1024, "The payload will be truncated after this amount of bytes") + fs.DurationVar(&config.sseWriteTimeout, "sse-write-timeout", 120*time.Second, "SSE server write timeout (i.e. '10s', '2m' - see time.ParseDuration for details) - for writing the SSE response after reading the headers & body") + + config.agentEngineID = agentEngineId + + return &agentEngineLauncher{ + config: config, + flags: fs, + } +} + +// CommandLineSyntax implements web.Sublauncher. Returns the command-line syntax for the agentEngine launcher. +func (a *agentEngineLauncher) CommandLineSyntax() string { + return util.FormatFlagUsage(a.flags) +} + +// SimpleDescription implements web.Sublauncher +func (a *agentEngineLauncher) SimpleDescription() string { + return "starts AgentEngine server which serves reasoning engine API while deployed to Agent Engine" +} + +// UserMessage implements web.Sublauncher. +func (a *agentEngineLauncher) UserMessage(webUrl string, printer func(v ...any)) { + // TODO(kdroste) description + printer(fmt.Sprintf(" agentEngine: you can access this server locally by %s%s", webUrl, "/api/reasoning_engine")) + printer(fmt.Sprintf(" %s%s", webUrl, "/api/stream_reasoning_engine")) + printer(" to access it while deployed to Agent Engine, you should use") + printer(" https://${LOCATION_ID}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${LOCATION_ID}/reasoningEngines/${RESOURCE_ID}:query") + printer(" or https://${LOCATION_ID}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${LOCATION_ID}/reasoningEngines/${RESOURCE_ID}:streamQuery") +} + +// SetupSubrouters adds the API router to the parent router. +func (a *agentEngineLauncher) SetupSubrouters(router *mux.Router, config *launcher.Config) error { + // Create the ADK AgentEngine API handler + apiHandler, err := agentengine.NewHandler(config, a.config.sseWriteTimeout, a.config.maxPayloadSize, a.config.agentEngineID) + if err != nil { + return fmt.Errorf("agentengine.NewHandler failed: %v", err) + } + + router.Methods("POST"). + PathPrefix(a.config.pathPrefix). + Handler(http.StripPrefix(a.config.pathPrefix, apiHandler)) + + return nil +} + +// Keyword implements web.Sublauncher. Returns the command-line keyword for A2A launcher. +func (a *agentEngineLauncher) Keyword() string { + return "agentengine" +} + +var _ weblauncher.Sublauncher = &agentEngineLauncher{} + +// Parse parses the command-line arguments for the API launcher. +func (a *agentEngineLauncher) Parse(args []string) ([]string, error) { + err := a.flags.Parse(args) + if err != nil || !a.flags.Parsed() { + return nil, fmt.Errorf("failed to parse agent engine flags: %v", err) + } + p := a.config.pathPrefix + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + a.config.pathPrefix = strings.TrimSuffix(p, "/") + + restArgs := a.flags.Args() + return restArgs, nil +} diff --git a/examples/agentengine/main.go b/examples/agentengine/main.go new file mode 100644 index 000000000..6c87c3b79 --- /dev/null +++ b/examples/agentengine/main.go @@ -0,0 +1,106 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package provides a quickstart for Agent Engine deployment +package main + +import ( + "context" + "log" + "math/rand/v2" + "os" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/cmd/launcher" + "google.golang.org/adk/cmd/launcher/agentengine" + "google.golang.org/adk/model/gemini" + "google.golang.org/adk/session/vertexai" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" +) + +func main() { + ctx := context.Background() + + // those values are provided by AgentEngine, visible after the deployment to the container + // for tesing, simply set those to your GCP project + projectID := os.Getenv("GOOGLE_CLOUD_PROJECT") + location := os.Getenv("GOOGLE_CLOUD_AGENT_ENGINE_LOCATION") + agentEngineID := os.Getenv("GOOGLE_CLOUD_AGENT_ENGINE_ID") + + model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + Backend: genai.BackendVertexAI, + Project: projectID, + Location: location, + }) + if err != nil { + log.Fatalf("Failed to create model: %v", err) + } + + type Input struct { + Min int `json:"min"` + Max int `json:"max"` + } + type Output struct { + Result int `json:"result"` + } + handler := func(ctx tool.Context, input Input) (Output, error) { + return Output{ + Result: input.Min + rand.IntN(input.Max-input.Min+1), + }, nil + } + randomTool, err := functiontool.New(functiontool.Config{ + Name: "random", + Description: "Returns a random number between min and max", + }, handler) + if err != nil { + log.Fatalf("Failed to create tool: %v", err) + } + + a, err := llmagent.New(llmagent.Config{ + Name: "ae_agent", + Model: model, + Description: "General helpful agent", + Instruction: "You are a helpful agent, you should answer any questions you are given. Use 'random' tool to provide random numbers.", + Tools: []tool.Tool{ + randomTool, + }, + }) + if err != nil { + log.Fatalf("Failed to create agent: %v", err) + } + + sessionService, err := vertexai.NewSessionService( + ctx, vertexai.VertexAIServiceConfig{ + ProjectID: projectID, + Location: location, + ReasoningEngine: agentEngineID, + }) + if err != nil { + log.Fatalf("Failed to create session service: %v", err) + } + + config := &launcher.Config{ + SessionService: sessionService, + AgentLoader: agent.NewSingleLoader(a), + } + + l := agentengine.NewLauncher(agentEngineID) + if err = l.Execute(ctx, config, os.Args[1:]); err != nil { + log.Fatalf("Run failed: %v\n\n%s", err, l.CommandLineSyntax()) + } +} diff --git a/server/agentengine/controllers/agent_engine.go b/server/agentengine/controllers/agent_engine.go new file mode 100644 index 000000000..0dde02998 --- /dev/null +++ b/server/agentengine/controllers/agent_engine.go @@ -0,0 +1,94 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controllers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "google.golang.org/adk/server/agentengine/controllers/method" + "google.golang.org/adk/server/agentengine/internal/models" + "google.golang.org/adk/session" +) + +// AgentEngineAPIController holds information about the supported methods +type AgentEngineAPIController struct { + handlers map[string]method.MethodHandler + service session.Service + maxPayloadSize int64 + sseTimeout time.Duration +} + +// NewAgentEngineAPIController creates a new AgentEngineAPIController. Verifies if registered methods are unique by name +func NewAgentEngineAPIController(service session.Service, sseTimeout time.Duration, maxPayloadSize int64, handlers []method.MethodHandler) (*AgentEngineAPIController, error) { + methodHandlers := map[string]method.MethodHandler{} + for _, handler := range handlers { + if _, ok := methodHandlers[handler.Name()]; ok { + return nil, fmt.Errorf("duplicate method name: %v", handler.Name()) + } + methodHandlers[handler.Name()] = handler + } + return &AgentEngineAPIController{service: service, handlers: methodHandlers, maxPayloadSize: maxPayloadSize, sseTimeout: sseTimeout}, nil +} + +// Query provides a way to invoke all the methods +func (c *AgentEngineAPIController) Query(rw http.ResponseWriter, req *http.Request) { + deadline := time.Now().Add(c.sseTimeout) + rc := http.NewResponseController(rw) + err := rc.SetWriteDeadline(deadline) + if err != nil { + // ignore the error + log.Printf("SetWriteDeadline failed: %v", err) + } + query := models.Query{} + var payload []byte + + if req.Body != nil && req.Body != http.NoBody { + var err error + + payload, err = io.ReadAll(io.LimitReader(req.Body, c.maxPayloadSize)) + if err != nil { + err = fmt.Errorf("io.ReadAll with LimitReader failed: %w", err) + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + err = json.Unmarshal(payload, &query) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + } + + err = c.handleQuery(req.Context(), rw, payload, query.ClassMethod) + if err != nil { + log.Printf("handleQuery failed: %v", err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } +} + +func (c *AgentEngineAPIController) handleQuery(context context.Context, rw http.ResponseWriter, payload []byte, classMethod string) error { + handler, ok := c.handlers[classMethod] + if !ok { + return fmt.Errorf("unrecognized class method: %v", classMethod) + } + return handler.Handle(context, rw, payload) +} diff --git a/server/agentengine/controllers/method/create_session.go b/server/agentengine/controllers/method/create_session.go new file mode 100644 index 000000000..bc27f37e3 --- /dev/null +++ b/server/agentengine/controllers/method/create_session.go @@ -0,0 +1,122 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package method + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "google.golang.org/protobuf/types/known/structpb" + + "google.golang.org/adk/server/agentengine/internal/models" + "google.golang.org/adk/session" +) + +type createSessionHandler struct { + sessionService session.Service + agentEngineID string + methodName string + apiMode string +} + +// NewCreateSessionHandler creates a new createSessionHandler. It can be used to serve "asynch_create_session" method +func NewCreateSessionHandler(sessionService session.Service, agentEngineID, methodName, apiMode string) *createSessionHandler { + return &createSessionHandler{sessionService: sessionService, agentEngineID: agentEngineID, methodName: methodName, apiMode: apiMode} +} + +// Metadata implements MethodHandler. +func (c *createSessionHandler) Metadata() (*structpb.Struct, error) { + metadata, err := structpb.NewStruct(map[string]any{ + "api_mode": c.apiMode, + "name": c.methodName, + "parameters": map[string]any{ + "properties": map[string]any{ + "session_id": map[string]any{ + "nullable": true, + "type": "string", + }, + "user_id": map[string]any{ + "type": "string", + }, + "state": map[string]any{ + "nullable": true, + "type": "object", + }, + }, + "required": []any{ + "user_id", + }, + "type": "object", + }, + "description": `Creates a new session. + +Args: + user_id (str): + Required. The ID of the user. + session_id (str): + Optional. The ID of the session. If not provided, an ID will be generated for the session. + state (dict[str, Any]): + Optional. The initial state of the session. + +Returns: + Session: The newly created session instance. +`, + }) + if err != nil { + return nil, fmt.Errorf("cannot create metadata for %s: %w", c.methodName, err) + } + return metadata, nil +} + +// Handle implements MethodHandler. +func (c *createSessionHandler) Handle(ctx context.Context, rw http.ResponseWriter, payload []byte) error { + var req models.CreateSessionRequest + + err := json.Unmarshal(payload, &req) + if err != nil { + return fmt.Errorf("json.Unmarshal() failed: %v", err) + } + + ssReq := &session.CreateRequest{ + AppName: c.agentEngineID, + UserID: req.Input.UserID, + SessionID: req.Input.SessionID, + State: req.Input.State, + } + resp, err := c.sessionService.Create(ctx, ssReq) + if err != nil { + return fmt.Errorf("c.sessionservice.Create() failed: %v", err) + } + + sd := models.FromSession(resp.Session) + + result := models.CreateSessionResponse{ + Output: sd, + } + err = json.NewEncoder(rw).Encode(result) + if err != nil { + return fmt.Errorf("json.NewEncoder failed: %v", err) + } + return nil +} + +// Name implements MethodHandler. +func (c *createSessionHandler) Name() string { + return c.methodName +} + +var _ MethodHandler = (*createSessionHandler)(nil) diff --git a/server/agentengine/controllers/method/delete_session.go b/server/agentengine/controllers/method/delete_session.go new file mode 100644 index 000000000..246de6bc7 --- /dev/null +++ b/server/agentengine/controllers/method/delete_session.go @@ -0,0 +1,115 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package method + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "google.golang.org/protobuf/types/known/structpb" + + "google.golang.org/adk/server/agentengine/internal/models" + "google.golang.org/adk/session" +) + +type deleteSessionHandler struct { + sessionservice session.Service + agentEngineID string + methodName string + apiMode string +} + +// NewDeleteSessionHandler creates a new deleteSessionHandler. It can be used to serve "async_delete_session" method +func NewDeleteSessionHandler(sessionservice session.Service, agentEngineID, methodName, apiMode string) *deleteSessionHandler { + return &deleteSessionHandler{sessionservice: sessionservice, agentEngineID: agentEngineID, methodName: methodName, apiMode: apiMode} +} + +// Metadata implements MethodHandler. +func (g *deleteSessionHandler) Metadata() (*structpb.Struct, error) { + metadata, err := structpb.NewStruct(map[string]any{ + "api_mode": g.apiMode, + "name": g.methodName, + "parameters": map[string]any{ + "properties": map[string]any{ + "session_id": map[string]any{ + "type": "string", + }, + "user_id": map[string]any{ + "type": "string", + }, + }, + "required": []any{ + "user_id", + "session_id", + }, + "type": "object", + }, + "description": `Deletes a session for the given user. + +Args: + user_id (str): + Required. The ID of the user. + session_id (str): + Required. The ID of the session. + +Returns: + on success returns an empty string. On error returns an error message. + +`, + }) + if err != nil { + return nil, fmt.Errorf("cannot create metadata for %s: %w", g.methodName, err) + } + return metadata, nil +} + +// Handle implements MethodHandler. +func (g *deleteSessionHandler) Handle(ctx context.Context, rw http.ResponseWriter, payload []byte) error { + var req models.DeleteSessionRequest + + err := json.Unmarshal(payload, &req) + if err != nil { + return fmt.Errorf("json.Unmarshal() failed: %v", err) + } + + ssReq := &session.DeleteRequest{ + AppName: g.agentEngineID, + UserID: req.Input.UserID, + SessionID: req.Input.SessionID, + } + err = g.sessionservice.Delete(ctx, ssReq) + output := "" + if err != nil { + output = err.Error() + } + + result := models.DeleteSessionResponse{ + Output: output, + } + err = json.NewEncoder(rw).Encode(result) + if err != nil { + return fmt.Errorf("json.NewEncoder failed: %v", err) + } + return nil +} + +// Name implements MethodHandler. +func (g *deleteSessionHandler) Name() string { + return g.methodName +} + +var _ MethodHandler = (*deleteSessionHandler)(nil) diff --git a/server/agentengine/controllers/method/get_session.go b/server/agentengine/controllers/method/get_session.go new file mode 100644 index 000000000..e1f89ca7a --- /dev/null +++ b/server/agentengine/controllers/method/get_session.go @@ -0,0 +1,117 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package method + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "google.golang.org/protobuf/types/known/structpb" + + "google.golang.org/adk/server/agentengine/internal/models" + "google.golang.org/adk/session" +) + +type getSessionHandler struct { + sessionservice session.Service + agentEngineID string + methodName string + apiMode string +} + +// NewGetSessionHandler creates a new getSessionHandler. It can be used to serve "async_get_session" method +func NewGetSessionHandler(sessionservice session.Service, agentEngineID, methodName, apiMode string) *getSessionHandler { + return &getSessionHandler{sessionservice: sessionservice, agentEngineID: agentEngineID, methodName: methodName, apiMode: apiMode} +} + +// Metadata implements MethodHandler. +func (g *getSessionHandler) Metadata() (*structpb.Struct, error) { + metadata, err := structpb.NewStruct(map[string]any{ + "api_mode": g.apiMode, + "name": g.methodName, + "parameters": map[string]any{ + "properties": map[string]any{ + "session_id": map[string]any{ + "type": "string", + }, + "user_id": map[string]any{ + "type": "string", + }, + }, + "required": []any{ + "user_id", + "session_id", + }, + "type": "object", + }, + "description": `Get a session for the given user. + +Args: + user_id (str): + Required. The ID of the user. + session_id (str): + Required. The ID of the session. + +Returns: + Session: The session instance (if any). Returns an error on failure + +`, + }) + if err != nil { + return nil, fmt.Errorf("cannot create metadata for %s: %w", g.methodName, err) + } + return metadata, nil +} + +// Handle implements MethodHandler. +func (g *getSessionHandler) Handle(ctx context.Context, rw http.ResponseWriter, payload []byte) error { + var req models.GetSessionRequest + + err := json.Unmarshal(payload, &req) + if err != nil { + return fmt.Errorf("json.Unmarshal() failed: %v", err) + } + + ssReq := &session.GetRequest{ + AppName: g.agentEngineID, + UserID: req.Input.UserID, + SessionID: req.Input.SessionID, + } + resp, err := g.sessionservice.Get(ctx, ssReq) + if err != nil { + return fmt.Errorf("c.sessionservice.Get() failed: %v", err) + } + + sd := models.FromSession(resp.Session) + + result := models.GetSessionResponse{ + Output: sd, + } + + err = json.NewEncoder(rw).Encode(result) + if err != nil { + return fmt.Errorf("json.NewEncoder failed: %v", err) + } + return nil +} + +// Name implements MethodHandler. +func (g *getSessionHandler) Name() string { + return g.methodName +} + +var _ MethodHandler = (*getSessionHandler)(nil) diff --git a/server/agentengine/controllers/method/list_sessions.go b/server/agentengine/controllers/method/list_sessions.go new file mode 100644 index 000000000..d5d3590d9 --- /dev/null +++ b/server/agentengine/controllers/method/list_sessions.go @@ -0,0 +1,114 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package method + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "google.golang.org/protobuf/types/known/structpb" + + "google.golang.org/adk/server/agentengine/internal/models" + "google.golang.org/adk/session" +) + +type listSessionHandler struct { + sessionservice session.Service + agentEngineID string + methodName string + apiMode string +} + +// NewListSessionHandler creates a new listSessionHandler. It can be used to serve "async_list_session" method +func NewListSessionHandler(sessionservice session.Service, agentEngineID, methodName, apiMode string) *listSessionHandler { + return &listSessionHandler{sessionservice: sessionservice, agentEngineID: agentEngineID, methodName: methodName, apiMode: apiMode} +} + +// Metadata implements MethodHandler. +func (l *listSessionHandler) Metadata() (*structpb.Struct, error) { + metadata, err := structpb.NewStruct(map[string]any{ + "api_mode": l.apiMode, + "name": l.methodName, + "parameters": map[string]any{ + "properties": map[string]any{ + "user_id": map[string]any{ + "type": "string", + }, + }, + "required": []any{ + "user_id", + }, + "type": "object", + }, + "description": `List sessions for the given user. + +Args: + user_id (str): + Required. The ID of the user. + +Returns: + ListSessionsResponse: The list of sessions with data. +`, + }) + if err != nil { + return nil, fmt.Errorf("cannot create metadata for %s: %w", l.methodName, err) + } + return metadata, nil +} + +// Handle implements MethodHandler. +func (l *listSessionHandler) Handle(ctx context.Context, rw http.ResponseWriter, payload []byte) error { + var req models.ListSessionRequest + + err := json.Unmarshal(payload, &req) + if err != nil { + return fmt.Errorf("json.Unmarshal() failed: %v", err) + } + + ssReq := &session.ListRequest{ + AppName: l.agentEngineID, + UserID: req.Input.UserID, + } + resp, err := l.sessionservice.List(ctx, ssReq) + if err != nil { + return fmt.Errorf("c.sessionservice.List() failed: %v", err) + } + + sessions := []models.SessionData{} + for _, sess := range resp.Sessions { + sd := models.FromSession(sess) + sessions = append(sessions, sd) + } + + result := models.ListSessionResponse{ + Output: models.Sessions{ + Sessions: sessions, + }, + } + err = json.NewEncoder(rw).Encode(result) + if err != nil { + return fmt.Errorf("json.NewEncoder failed: %v", err) + } + return nil +} + +// Name implements MethodHandler. +func (l *listSessionHandler) Name() string { + return l.methodName +} + +var _ MethodHandler = (*listSessionHandler)(nil) diff --git a/server/agentengine/controllers/method/method.go b/server/agentengine/controllers/method/method.go new file mode 100644 index 000000000..40da01901 --- /dev/null +++ b/server/agentengine/controllers/method/method.go @@ -0,0 +1,31 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// package method defines MethodHandler which is used to serve requests for +// an application deployed on agent engine +package method + +import ( + "context" + "net/http" + + "google.golang.org/protobuf/types/known/structpb" +) + +// MethodHandler is an interface which provides a structured way to serve methods on agentEngine +type MethodHandler interface { + Name() string // Name returns a name of the method, by which you call it + Handle(ctx context.Context, rw http.ResponseWriter, payload []byte) error // Handle provides a response to given payload + Metadata() (*structpb.Struct, error) // Metadata returns a struct which is used to specify application capabilities during the deployment +} diff --git a/server/agentengine/controllers/method/stream_query.go b/server/agentengine/controllers/method/stream_query.go new file mode 100644 index 000000000..0337adc8c --- /dev/null +++ b/server/agentengine/controllers/method/stream_query.go @@ -0,0 +1,185 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package method + +import ( + "context" + "encoding/json" + "fmt" + "iter" + "log" + "net/http" + + "google.golang.org/genai" + "google.golang.org/protobuf/types/known/structpb" + + "google.golang.org/adk/agent" + "google.golang.org/adk/cmd/launcher" + "google.golang.org/adk/runner" + "google.golang.org/adk/server/agentengine/internal/helper" + "google.golang.org/adk/server/agentengine/internal/models" + "google.golang.org/adk/session" +) + +type streamQueryHandler struct { + config *launcher.Config + methodName string + apiMode string + agentEngineID string +} + +// NewStreamQueryHandler creates a new streamQueryHandler. It can be used to serve "async_stream_query" method +func NewStreamQueryHandler(config *launcher.Config, agentEngineID, methodName, apiMode string) *streamQueryHandler { + return &streamQueryHandler{config: config, agentEngineID: agentEngineID, methodName: methodName, apiMode: apiMode} +} + +// Handle generates stream of json-encoded responses based on the payload. Error are also emitted as errors +func (s *streamQueryHandler) Handle(ctx context.Context, rw http.ResponseWriter, payload []byte) error { + streamErr := s.streamJSONL(ctx, rw, payload) + // streamJSONL will return error only before streaming. In that case we can handle it with HTTP Status, which is done in upstream + if streamErr != nil { + err := fmt.Errorf("s.streamJSONL() failed: %w", streamErr) + return err + } + return nil +} + +// streamJSONL streams a single line for each event or error +func (s *streamQueryHandler) streamJSONL(ctx context.Context, rw http.ResponseWriter, payload []byte) error { + var req models.StreamQueryRequest + + err := json.Unmarshal(payload, &req) + if err != nil { + err = fmt.Errorf("json.Unmarshal() failed: %v", err) + log.Print(err.Error()) + return err + } + + events, err := s.run(ctx, &req, &req.Input.Message, s.config) + if err != nil { + err = fmt.Errorf("s.run() failed: %w", err) + log.Print(err.Error()) + return err + } + + rw.Header().Set("Content-Type", "application/json") + rw.Header().Set("Cache-Control", "no-cache") + rw.Header().Set("Connection", "keep-alive") + // from this moment on we must not return error. Instead, it should be handled by using helper.EmitJSONError + + for event, err := range events { + log.Printf("Processing event: %+v err: %+v\n", event, err) + if err != nil { + log.Printf("error in events: %v\n", err) + e := helper.EmitJSONError(rw, err) + if e != nil { + e = fmt.Errorf("helper.EmitJSONError() failed: %w", e) + log.Print(e.Error()) + } + break + } + if event == nil { + continue + } + if event.LLMResponse.Content == nil { + continue + } + + chunk := *event + err = helper.EmitJSON(rw, chunk) + if err != nil { + e := fmt.Errorf("helper.EmitJSON() failed: %w", err) + log.Print(e.Error()) + e = helper.EmitJSONError(rw, e) + if e != nil { + e = fmt.Errorf("helper.EmitJSONError() failed: %w", e) + log.Print(e.Error()) + } + break + } + } + return nil +} + +// Name implements MethodHandler. +func (s *streamQueryHandler) Name() string { + return s.methodName +} + +var _ MethodHandler = (*streamQueryHandler)(nil) + +// Metadata implements MethodHandler. +func (s *streamQueryHandler) Metadata() (*structpb.Struct, error) { + classAsyncMethod, err := structpb.NewStruct(map[string]any{ + "api_mode": s.apiMode, + "name": s.methodName, + "parameters": map[string]any{ + "properties": map[string]any{ + "user_id": map[string]any{ + "type": "string", + }, + "session_id": map[string]any{ + "nullable": true, + "type": "string", + }, + "message": map[string]any{ + "additionalProperties": true, + "type": "object", + }, + }, + "required": []any{ + "message", + "user_id", + }, + "type": "object", + }, + "description": `Streams responses asynchronously from the ADK application. +Args: + message (genai.Content): + Required. The message to stream responses for. + user_id (str): + Required. The ID of the user. + session_id (str): + Optional. The ID of the session. If not provided, a new session will be created for the user. + +Yields: + Single lines with JSON encoded event each. Errors are also emitted as JSON. + +`, + }) + if err != nil { + return nil, fmt.Errorf("cannot create %s: %w", s.Name(), err) + } + return classAsyncMethod, nil +} + +func (s *streamQueryHandler) run(ctx context.Context, req *models.StreamQueryRequest, message *genai.Content, config *launcher.Config) (iter.Seq2[*session.Event, error], error) { + rootAgent := config.AgentLoader.RootAgent() + + r, err := runner.New(runner.Config{ + AppName: s.agentEngineID, + Agent: rootAgent, + SessionService: config.SessionService, + ArtifactService: config.ArtifactService, + PluginConfig: config.PluginConfig, + }) + if err != nil { + return nil, fmt.Errorf("failed to create runner: %v", err) + } + + return r.Run(ctx, req.Input.UserID, req.Input.SessionID, message, agent.RunConfig{ + StreamingMode: agent.StreamingModeSSE, + }), nil +} diff --git a/server/agentengine/handler.go b/server/agentengine/handler.go new file mode 100644 index 000000000..74aeb063c --- /dev/null +++ b/server/agentengine/handler.go @@ -0,0 +1,115 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// package agentengine brings functionality of serving commands for AgentEngine-deployed code +package agentengine + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "slices" + "strings" + "time" + + "github.com/gorilla/mux" + + "google.golang.org/protobuf/types/known/structpb" + + "google.golang.org/adk/cmd/launcher" + "google.golang.org/adk/server/agentengine/controllers" + "google.golang.org/adk/server/agentengine/controllers/method" + "google.golang.org/adk/server/agentengine/internal/routers" +) + +// NewHandler creates and returns an http.Handler for the AgentEngine API. +// Handles both streaming and non-streaming versions +func NewHandler(config *launcher.Config, sseWriteTimeout time.Duration, maxPayloadSize int64, agentEngineID string) (http.Handler, error) { + router := mux.NewRouter().StrictSlash(true) + + nonStreamAgentEngineController, err := controllers.NewAgentEngineAPIController(config.SessionService, sseWriteTimeout, maxPayloadSize, + listNonStreamHandlers(config, agentEngineID)) + if err != nil { + return nil, fmt.Errorf("controllers.NewAgentEngineAPIController failed (for non-streaming): %v", err) + } + + streamAgentEngineController, err := controllers.NewAgentEngineAPIController(config.SessionService, sseWriteTimeout, maxPayloadSize, + listStreamHandlers(config, agentEngineID)) + if err != nil { + return nil, fmt.Errorf("controllers.NewAgentEngineAPIController failed (for streaming): %v", err) + } + + setupRouter(router, + routers.NewReasoningEngineAPIRouter(nonStreamAgentEngineController), + routers.NewStreamReasoningEngineAPIRouter(streamAgentEngineController), + ) + + methods, err := ListClassMethods() + if err != nil { + return nil, fmt.Errorf("ListClassMethods() failed: %v", err) + } + + log.Println("Supported methods:") + for _, m := range methods { + sb := &strings.Builder{} + err = json.NewEncoder(sb).Encode(m) + if err != nil { + return nil, fmt.Errorf("json.NewEncoder failed: %v", err) + } + log.Println(sb.String()) + } + + return router, nil +} + +func setupRouter(router *mux.Router, subrouters ...routers.Router) *mux.Router { + routers.SetupSubRouters(router, subrouters...) + return router +} + +// listNonStreamHandlers returnes a list of handlers for non-streaming methods +func listNonStreamHandlers(config *launcher.Config, agentEngineID string) []method.MethodHandler { + return []method.MethodHandler{ + method.NewCreateSessionHandler(config.SessionService, agentEngineID, "async_create_session", "async"), + method.NewGetSessionHandler(config.SessionService, agentEngineID, "async_get_session", "async"), + method.NewListSessionHandler(config.SessionService, agentEngineID, "async_list_sessions", "async"), + method.NewDeleteSessionHandler(config.SessionService, agentEngineID, "async_delete_session", "async"), + } +} + +// listStreamHandlers returnes a list of handlers for streaming methods +func listStreamHandlers(config *launcher.Config, agentEngineID string) []method.MethodHandler { + return []method.MethodHandler{ + method.NewStreamQueryHandler(config, agentEngineID, "async_stream_query", "async_stream"), + } +} + +// ListClassMethods returns a list of structs, each describing a supported method. It is used to provide this information during the deployment +func ListClassMethods() ([]*structpb.Struct, error) { + // create fake config with defaults, just to use list(Non)StreamHandlers + config := &launcher.Config{} + + result := []*structpb.Struct{} + + handlers := slices.Concat(listNonStreamHandlers(config, ""), listStreamHandlers(config, "")) + for _, handler := range handlers { + m, err := handler.Metadata() + if err != nil { + return nil, err + } + result = append(result, m) + } + return result, nil +} diff --git a/server/agentengine/internal/helper/emit_json.go b/server/agentengine/internal/helper/emit_json.go new file mode 100644 index 000000000..8baa3056b --- /dev/null +++ b/server/agentengine/internal/helper/emit_json.go @@ -0,0 +1,71 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helper + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// flush flushes buffered data to the client. +func flush(rw http.ResponseWriter) error { + w := rw + for { + switch t := w.(type) { + case interface{ FlushError() error }: + return t.FlushError() + case http.Flusher: + t.Flush() + return nil + case rwUnwrapper: + w = t.Unwrap() + default: + return fmt.Errorf("not supported type to flush: %v %T", t, t) + } + } +} + +// this interface is used to get to the underlying http.ResponseWriter if wrapped. +type rwUnwrapper interface { + Unwrap() http.ResponseWriter +} + +// EmitJSON emits a line with JSON for an event. +func EmitJSON(rw http.ResponseWriter, o any) error { + snake := ConvertSnake(o) + err := json.NewEncoder(rw).Encode(snake) + if err != nil { + return fmt.Errorf("failed to encode SSE response chunk: %w", err) + } + err = flush(rw) + if err != nil { + return fmt.Errorf("failed to flush: %w", err) + } + + return nil +} + +// EmitJSONError emits a line with json describing the error +func EmitJSONError(rw http.ResponseWriter, origError error) error { + jsonErr := map[string]any{ + "error": origError.Error(), + } + err := EmitJSON(rw, jsonErr) + if err != nil { + return fmt.Errorf("failed to emit error: %w", err) + } + return nil +} diff --git a/server/agentengine/internal/helper/encode.go b/server/agentengine/internal/helper/encode.go new file mode 100644 index 000000000..ab99078f5 --- /dev/null +++ b/server/agentengine/internal/helper/encode.go @@ -0,0 +1,290 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helper + +import ( + "fmt" + "log" + "reflect" + "strings" + "time" +) + +// ConvertSnake returns an object corresponding the input object. It uses simple types, map[string]any and []any to reflect the input. +// All names are converted to snake case ('RequestedToolConfirmations' becomes 'requested_tool_confirmations') using simple method. +// There are some possible exceptions, which are handled in a custom way using pathToName. +// json tags are supported. +// struct embedding is supported. +func ConvertSnake(o any) any { + res, err := convertSnake("", "", o) + if err != nil { + log.Printf("Failed to convert: %+v of type %T: %v", o, o, err) + // better to return an original version than nothing + return o + } + return res +} + +// convertSnake does the job. It includes indent for debugging purposes +// uses reflect to traverse the object +func convertSnake(path, indent string, o any) (any, error) { + // handle nil + if o == nil { + return nil, nil + } + v := reflect.ValueOf(o) + switch v.Kind() { + case reflect.String: + // return string as-is + s, ok := o.(string) + if !ok { + // not a string, but a derived type + return o, nil + } + return s, nil + case reflect.Struct: + vt := v.Type() + // handle time.Time in a special way + if vt == reflect.TypeOf(time.Time{}) { + t := o.(time.Time) + return t.UnixMilli() / 1000.0, nil // returns a number of seconds "Unix-way" + } + + // this map will hold all the fields + m := make(map[string]any) + // iterate over the fields handling all the cases + for i := 0; i < v.NumField(); i++ { + fv := v.Field(i) + fvt := vt.Field(i) + tag := fvt.Tag.Get("json") + name, omitEmpty, omitZero, skip, err := fieldName(fvt.Name, tag) + if err != nil { + return nil, fmt.Errorf("failed to parse tag (%v): %w", tag, err) + } + // respect json "-" + if skip { + continue + } + // handle embedded structs + if fvt.Anonymous { + embed, err := convertSnake(path+"."+name, indent+". ", fv.Interface()) + if err != nil { + return nil, fmt.Errorf("failed to convert embedded struct with name:%v o: %+v %T err: %w", name, fv.Interface(), fv.Interface(), err) + } + // merge them to m + for k, v := range embed.(map[string]any) { + m[k] = v + } + continue + } + + // regular field + newPath := path + "." + name + newName := convertName(newPath, name) + if fv.CanInterface() { + val, err := convertSnake(newPath, indent+". ", fv.Interface()) + if err != nil { + return nil, fmt.Errorf("failed to convert regular struct field with path: %v err: %w", newPath, err) + } + if omitEmpty { + // check for emptiness + if val != nil { + // empty map + addIfNotEmpty(val, m, newName) + } + } else { + if val != nil { + m[newName] = val + } + } + } else { + // respect omitZero + val := convertValue(fv) + if val != 0 || !omitZero { + m[newName] = val + } + } + + } + return m, nil + case reflect.Slice: + res := []any{} + for i := 0; i < v.Len(); i++ { + elem, err := convertSnake(path+".[]", indent+" ", v.Index(i).Interface()) + if err != nil { + return nil, fmt.Errorf("failed to convert slice element with path: %v err: %w", path+".[]", err) + } + res = append(res, elem) + } + if len(res) == 0 { + return []any{}, nil + } + return res, nil + case reflect.Map: + res := make(map[string]any) + for _, k := range v.MapKeys() { + elem, err := convertSnake(path+"->", indent+" ", v.MapIndex(k).Interface()) + if err != nil { + return nil, fmt.Errorf("failed to convert map element with path: %v err: %w", path+"->", err) + } + res[k.String()] = elem + } + if len(res) == 0 { + return map[string]any{}, nil + } + return res, nil + case reflect.Ptr: + if v.IsNil() { + return nil, nil + } + return convertSnake(path+"*", indent+" ", v.Elem().Interface()) + case reflect.Bool: + return v.Bool(), nil + case reflect.Float32, reflect.Float64: + return v.Float(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int(), nil + + default: + return nil, fmt.Errorf("unsupported type: %v", v.Kind()) + } +} + +func addIfNotEmpty(val any, m map[string]any, newName string) { + switch t := val.(type) { + case map[string]any: + if len(t) != 0 { + m[newName] = val + } + case []any: + if len(t) != 0 { + m[newName] = val + } + case bool: + if t { + m[newName] = val + } + case string: + if t != "" { + m[newName] = val + } + default: + m[newName] = val + } +} + +// pathToName allows to provide a list of exceptions for a known input structures. +// key is a path for a name to be converted. Value is its custom replacement. +var pathToName = map[string]string{ + ".LongRunningToolIDs": "long_running_tool_ids", // long_running_tool_i_ds +} + +// convertName converts a name to snake case. +func convertName(path, name string) string { + // uncomment this to check how your data is processed + // fmt.Printf("convert(%s, %s)\n", path, name) + if res, ok := pathToName[path]; ok { + return res + } + + l := strings.ToLower(name) + b := &strings.Builder{} + afterUnderscore := true + for i := 0; i < len(name); i++ { + // Ab => _ab + if !afterUnderscore && i > 0 && i+1 < len(name) && name[i] != l[i] && name[i+1] == l[i+1] { + fmt.Fprintf(b, "_%c", l[i]) + afterUnderscore = true + continue + } + // aB => a_b + if !afterUnderscore && i+1 < len(name) && name[i] == l[i] && name[i+1] != l[i+1] { + fmt.Fprintf(b, "%c_", l[i]) + afterUnderscore = true + continue + } + afterUnderscore = false + fmt.Fprintf(b, "%c", l[i]) + } + return b.String() +} + +// parseTag handles json tags. Accepted format is comma-separated list of strings. +// "-", "omitempty" and "omitzero" are recognized. The remaining one is treated as a name +// returns an error if duplicates are found +func parseTag(tag string) (name string, omitEmpty, omitZero, skip bool, err error) { + if tag == "" { + return "", false, false, false, nil + } + if tag == "-" { + return "", false, false, true, nil + } + vals := strings.Split(tag, ",") + name = "" + omitEmpty = false + omitZero = false + skip = false + for _, val := range vals { + // ignore empty values + if val == "" { + continue + } + switch val { + case "omitempty": + if omitEmpty { + return "", false, false, false, fmt.Errorf("duplicate omitempty") + } + omitEmpty = true + case "omitzero": + if omitZero { + return "", false, false, false, fmt.Errorf("duplicate omitzero") + } + omitZero = true + default: + if name != "" { + return "", false, false, false, fmt.Errorf("duplicate name") + } + name = val + } + } + // allow empty name + return name, omitEmpty, omitZero, skip, nil +} + +// fieldName returns a name for the field after json tag is taken into the consideration +func fieldName(name, tag string) (newName string, omitEmpty, omitZero, skip bool, err error) { + newName, omitEmpty, omitZero, skip, err = parseTag(tag) + if newName == "" { + newName = name + } + return newName, omitEmpty, omitZero, skip, err +} + +// convertValue handles String, Int, Uint and Float +func convertValue(o reflect.Value) any { + if o.CanInt() { + return o.Int() + } + if o.CanUint() { + return o.Uint() + } + if o.CanFloat() { + return o.Float() + } + if o.Kind() == reflect.String { + return o.String() + } + return o +} diff --git a/server/agentengine/internal/helper/encode_test.go b/server/agentengine/internal/helper/encode_test.go new file mode 100644 index 000000000..886dcbfae --- /dev/null +++ b/server/agentengine/internal/helper/encode_test.go @@ -0,0 +1,253 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helper + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "google.golang.org/genai" + + "google.golang.org/adk/model" + "google.golang.org/adk/session" +) + +func TestNames(t *testing.T) { + tests := []struct { + name string + want string + }{ + { + name: "a", + want: "a", + }, + { + name: "A", + want: "a", + }, + { + name: "aa", + want: "aa", + }, + { + name: "Aa", + want: "aa", + }, + { + name: "AaAa", + want: "aa_aa", + }, + { + name: "ArtifactDelta", + want: "artifact_delta", + }, + { + name: "RequestedToolConfirmations", + want: "requested_tool_confirmations", + }, + { + name: "ID", + want: "id", + }, + { + name: "InvocationID", + want: "invocation_id", + }, + { + name: "LongRunningToolIDs", + want: "long_running_tool_i_ds", // special case, handled by exception - see pathName + }, + } + for _, tt := range tests { + snake := convertName("", tt.name) + if snake != tt.want { + t.Errorf("convertName(%q) = %q, want %q", tt.name, snake, tt.want) + } + } +} + +func TestEvent(t *testing.T) { + event := session.Event{ + ID: "1", + LongRunningToolIDs: []string{ + "1", + "2", + }, + } + o, err := convertSnake("", "", event) + if err != nil { + t.Errorf("convertSnake() failed: %v", err) + } + if m, ok := o.(map[string]any); ok { + if arr, ok := m["long_running_tool_ids"]; ok { + t.Logf("long_running_tool_ids: %v %T", arr, arr) + if arr, ok := arr.([]any); ok { + if len(arr) == 2 { + if arr[0] != "1" { + t.Errorf("long_running_tool_ids[0] is not 1") + } + if arr[1] != "2" { + t.Errorf("long_running_tool_ids[1] is not 2") + } + + } else { + t.Errorf("long_running_tool_ids is not an array of length 2") + } + } else { + t.Errorf("long_running_tool_ids is not an array") + } + } else { + t.Errorf("long_running_tool_ids not found") + } + } else { + t.Errorf("o is not a map") + } +} + +func TestEventLogProbs(t *testing.T) { + event := session.Event{ + ID: "1", + LongRunningToolIDs: []string{ + "1", + "2", + }, + LLMResponse: model.LLMResponse{ + Content: &genai.Content{ + Parts: []*genai.Part{ + { + Text: "Hello", + }, + }, + }, + }, + } + o, err := convertSnake("", "", event) + if err != nil { + t.Errorf("convertSnake() failed: %v", err) + } + if m, ok := o.(map[string]any); ok { + if arr, ok := m["long_running_tool_ids"]; ok { + t.Logf("long_running_tool_ids: %v %T", arr, arr) + if arr, ok := arr.([]any); ok { + if len(arr) == 2 { + if arr[0] != "1" { + t.Errorf("long_running_tool_ids[0] is not 1") + } + if arr[1] != "2" { + t.Errorf("long_running_tool_ids[1] is not 2") + } + + } else { + t.Errorf("long_running_tool_ids is not an array of length 2") + } + } else { + t.Errorf("long_running_tool_ids is not an array") + } + } else { + t.Errorf("long_running_tool_ids not found") + } + } else { + t.Errorf("o is not a map") + } +} + +func TestEmbedded(t *testing.T) { + type A struct { + anInteger int + aString string + } + type B struct { + A + anotherString string + } + b := B{ + A: A{ + anInteger: 1, + aString: "a", + }, + anotherString: "b", + } + got, err := convertSnake("", "", b) + if err != nil { + t.Errorf("convertSnake() failed: %v", err) + } + + want := map[string]any{ + "an_integer": int64(1), + "a_string": "a", + "another_string": "b", + } + + diff := cmp.Diff(got, want) + if diff != "" { + t.Errorf("convertSnake() = %v, want %v, diff: \n%v", got, want, diff) + } +} + +func TestOmitEmpty(t *testing.T) { + type A struct { + OmittableArr []int `json:"optional_array,omitempty"` + MandatoryArr []int `json:"must_array"` + OmmitableIntToIntMap map[int]int `json:"optional_map,omitempty"` + MandatoryIntToIntMap map[int]int `json:"must_map"` + } + tests := []struct { + name string + a A + want map[string]any + }{ + { + name: "nil, nil, nil, nil", + a: A{}, + want: map[string]any{ + "must_array": []any{}, + "must_map": map[string]any{}, + }, + }, + { + name: "empty, nil, nil, nil", + a: A{ + OmittableArr: []int{}, + }, + want: map[string]any{ + "must_array": []any{}, + "must_map": map[string]any{}, + }, + }, + { + name: "nil, [1], nil, nil", + a: A{ + OmittableArr: []int{1}, + }, want: map[string]any{ + "must_array": []any{}, + "must_map": map[string]any{}, + "optional_array": []any{ + int64(1), + }, + }, + }, + } + for _, tc := range tests { + got, err := convertSnake("", "", tc.a) + if err != nil { + t.Errorf("convertSnake() failed: %v", err) + } + diff := cmp.Diff(got, tc.want) + if diff != "" { + t.Errorf("convertSnake() = %v, want %v, diff: \n%v", got, tc.want, diff) + } + + } +} diff --git a/server/agentengine/internal/models/query.go b/server/agentengine/internal/models/query.go new file mode 100644 index 000000000..617c7a772 --- /dev/null +++ b/server/agentengine/internal/models/query.go @@ -0,0 +1,23 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// package models defines Request / Response models for generic Query and for more focused methods +package models + +// Query is generic JSON format for payload coming from aiplatform. It has Input field which type is specific to a particular class_method. +// Other models are providing those structures for particular class_methods +type Query struct { + ClassMethod string `json:"class_method"` + Input any `json:"input"` +} diff --git a/server/agentengine/internal/models/session.go b/server/agentengine/internal/models/session.go new file mode 100644 index 000000000..e84c6a9b8 --- /dev/null +++ b/server/agentengine/internal/models/session.go @@ -0,0 +1,123 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +import ( + "google.golang.org/adk/session" +) + +// CreateSessionRequest represents an request aligned with aiplatform standard +type CreateSessionRequest struct { + ClassMethod string `json:"class_method"` + Input CreateSessionInput `json:"input"` +} + +// CreateSessionInput contains input parameters +type CreateSessionInput struct { + UserID string `json:"user_id"` + SessionID string `json:"session_id,omitempty"` + State map[string]any `json:"state,omitempty"` +} + +// CreateSessionResponse contains response +type CreateSessionResponse struct { + Output SessionData `json:"output"` +} + +// SessionData represents data about the session +type SessionData struct { + UserID string `json:"user_id"` + LastUpdateTime float64 `json:"last_update_time"` + AppName string `json:"app_name"` + ID string `json:"id"` + State map[string]any `json:"state"` + Events []session.Event `json:"events"` +} + +// ListSessionRequest represents an request aligned with aiplatform standard +type ListSessionRequest struct { + ClassMethod string `json:"class_method"` + Input ListSessionInput `json:"input"` +} + +// ListSessionInput contains input parameters +type ListSessionInput struct { + UserID string `json:"user_id"` +} + +// ListSessionResponse contains response +type ListSessionResponse struct { + Output Sessions `json:"output"` +} + +// Sessions contains list of sessions +type Sessions struct { + Sessions []SessionData `json:"sessions"` +} + +// GetSessionRequest represents an request aligned with aiplatform standard +type GetSessionRequest struct { + ClassMethod string `json:"class_method"` + Input GetSessionInput `json:"input"` +} + +// GetSessionInput contains input parameters +type GetSessionInput struct { + UserID string `json:"user_id"` + SessionID string `json:"session_id"` +} + +// GetSessionResponse contains response +type GetSessionResponse struct { + Output SessionData `json:"output"` +} + +// DeleteSessionRequest represents an request aligned with aiplatform standard +type DeleteSessionRequest struct { + ClassMethod string `json:"class_method"` + Input DeleteSessionInput `json:"input"` +} + +// DeleteSessionInput contains input parameters +type DeleteSessionInput struct { + UserID string `json:"user_id"` + SessionID string `json:"session_id"` +} + +// DeleteSessionResponse contains response +type DeleteSessionResponse struct { + Output string `json:"output"` +} + +func FromSession(sess session.Session) SessionData { + stateMap := make(map[string]any) + for k, v := range sess.State().All() { + stateMap[k] = v + } + + evs := []session.Event{} + for ev := range sess.Events().All() { + evs = append(evs, *ev) + } + + return SessionData{ + UserID: sess.UserID(), + LastUpdateTime: float64(sess.LastUpdateTime().UnixNano()) / 1e9, // converts nanosec to sec + AppName: sess.AppName(), + ID: sess.ID(), + State: stateMap, + Events: evs, + } +} diff --git a/server/agentengine/internal/models/stream_query.go b/server/agentengine/internal/models/stream_query.go new file mode 100644 index 000000000..dcf7fec07 --- /dev/null +++ b/server/agentengine/internal/models/stream_query.go @@ -0,0 +1,39 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +import ( + "google.golang.org/genai" + + "google.golang.org/adk/session" +) + +// StreamQueryRequest is a struct representing JSON-encoded payload to async_stream_query method with dedicated Input. +type StreamQueryRequest struct { + ClassMethod string `json:"class_method"` + Input StreamQueryInput `json:"input"` +} + +// StreamQueryInput is the actual Input for async_stream_query method. +type StreamQueryInput struct { + UserID string `json:"user_id"` + SessionID string `json:"session_id"` + Message genai.Content `json:"message"` +} + +// StreamQueryResponse defines the content of event data for async_stream_query method. +// It is returned as one line with JSON-encoded StreamQuerySSEResponse +// Please mind that errors are also returned by this method +type StreamQuerySSEResponse session.Event diff --git a/server/agentengine/internal/routers/reasoning_engine.go b/server/agentengine/internal/routers/reasoning_engine.go new file mode 100644 index 000000000..85527266b --- /dev/null +++ b/server/agentengine/internal/routers/reasoning_engine.go @@ -0,0 +1,43 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routers + +import ( + "net/http" + + "google.golang.org/adk/server/agentengine/controllers" +) + +// ReasoningEngineAPIRouter defines the routes for the non-streaming version of ReasoningEngine. +type ReasoningEngineAPIRouter struct { + reasoningEngineController *controllers.AgentEngineAPIController +} + +// NewReasoningEngineAPIRouter creates a new ReasoningEngineAPIRouter. +func NewReasoningEngineAPIRouter(controller *controllers.AgentEngineAPIController) *ReasoningEngineAPIRouter { + return &ReasoningEngineAPIRouter{reasoningEngineController: controller} +} + +// Routes returns the routes for the ReasoningEngine API +func (r *ReasoningEngineAPIRouter) Routes() Routes { + return Routes{ + Route{ + Name: "ReasoningEngine", + Methods: []string{http.MethodPost}, + Pattern: "/reasoning_engine", + HandlerFunc: r.reasoningEngineController.Query, + }, + } +} diff --git a/server/agentengine/internal/routers/routers.go b/server/agentengine/internal/routers/routers.go new file mode 100644 index 000000000..ebf47c744 --- /dev/null +++ b/server/agentengine/internal/routers/routers.go @@ -0,0 +1,53 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package routers defines the HTTP routes for the ADK REST API. +package routers + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +// A Route defines the parameters for an api endpoint +type Route struct { + Name string + Methods []string + Pattern string + HandlerFunc http.HandlerFunc +} + +// Routes is a list of defined api endpoints +type Routes []Route + +// Router defines the required methods for retrieving api routes +type Router interface { + Routes() Routes +} + +// SetupSubRouters adds routes from subrouter to the naub router +func SetupSubRouters(router *mux.Router, subrouters ...Router) { + for _, api := range subrouters { + for _, route := range api.Routes() { + var handler http.Handler = route.HandlerFunc + + router. + Methods(route.Methods...). + Path(route.Pattern). + Name(route.Name). + Handler(handler) + } + } +} diff --git a/server/agentengine/internal/routers/stream_reasoning_engine.go b/server/agentengine/internal/routers/stream_reasoning_engine.go new file mode 100644 index 000000000..4bd77f395 --- /dev/null +++ b/server/agentengine/internal/routers/stream_reasoning_engine.go @@ -0,0 +1,43 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routers + +import ( + "net/http" + + "google.golang.org/adk/server/agentengine/controllers" +) + +// StreamReasoningEngineAPIRouter defines the routes for the Streaming version of Reasoning Engine. +type StreamReasoningEngineAPIRouter struct { + reasoningEngineController *controllers.AgentEngineAPIController +} + +// NewStreamReasoningEngineAPIRouter creates a new SessionsAPIRouter. +func NewStreamReasoningEngineAPIRouter(controller *controllers.AgentEngineAPIController) *StreamReasoningEngineAPIRouter { + return &StreamReasoningEngineAPIRouter{reasoningEngineController: controller} +} + +// Routes returns the routes for the Stream Reasoning Engine. +func (r *StreamReasoningEngineAPIRouter) Routes() Routes { + return Routes{ + Route{ + Name: "StreamReasoningEngine", + Methods: []string{http.MethodPost}, + Pattern: "/stream_reasoning_engine", + HandlerFunc: r.reasoningEngineController.Query, + }, + } +} From 346d720ca5adf7ca2bfed2d03150a1708ad53413 Mon Sep 17 00:00:00 2001 From: Mikalai Senkevich Date: Tue, 28 Apr 2026 12:56:03 +0200 Subject: [PATCH 28/86] refactor: remove manual session ID input and enable auto-creation in runner (#754) * refactor: remove manual session ID input and enable auto-creation in runner * refactor: remove unused output field from DeleteSessionResponse serialization --- .../controllers/method/create_session.go | 13 +++---------- .../controllers/method/delete_session.go | 7 ++----- .../agentengine/controllers/method/stream_query.go | 11 ++++++----- server/agentengine/internal/models/session.go | 5 ++--- 4 files changed, 13 insertions(+), 23 deletions(-) diff --git a/server/agentengine/controllers/method/create_session.go b/server/agentengine/controllers/method/create_session.go index bc27f37e3..b49d942ec 100644 --- a/server/agentengine/controllers/method/create_session.go +++ b/server/agentengine/controllers/method/create_session.go @@ -45,10 +45,6 @@ func (c *createSessionHandler) Metadata() (*structpb.Struct, error) { "name": c.methodName, "parameters": map[string]any{ "properties": map[string]any{ - "session_id": map[string]any{ - "nullable": true, - "type": "string", - }, "user_id": map[string]any{ "type": "string", }, @@ -67,8 +63,6 @@ func (c *createSessionHandler) Metadata() (*structpb.Struct, error) { Args: user_id (str): Required. The ID of the user. - session_id (str): - Optional. The ID of the session. If not provided, an ID will be generated for the session. state (dict[str, Any]): Optional. The initial state of the session. @@ -92,10 +86,9 @@ func (c *createSessionHandler) Handle(ctx context.Context, rw http.ResponseWrite } ssReq := &session.CreateRequest{ - AppName: c.agentEngineID, - UserID: req.Input.UserID, - SessionID: req.Input.SessionID, - State: req.Input.State, + AppName: c.agentEngineID, + UserID: req.Input.UserID, + State: req.Input.State, } resp, err := c.sessionService.Create(ctx, ssReq) if err != nil { diff --git a/server/agentengine/controllers/method/delete_session.go b/server/agentengine/controllers/method/delete_session.go index 246de6bc7..3c4d20e20 100644 --- a/server/agentengine/controllers/method/delete_session.go +++ b/server/agentengine/controllers/method/delete_session.go @@ -92,14 +92,11 @@ func (g *deleteSessionHandler) Handle(ctx context.Context, rw http.ResponseWrite SessionID: req.Input.SessionID, } err = g.sessionservice.Delete(ctx, ssReq) - output := "" if err != nil { - output = err.Error() + return fmt.Errorf("g.sessionservice.Delete() failed: %v", err) } - result := models.DeleteSessionResponse{ - Output: output, - } + result := models.DeleteSessionResponse{} err = json.NewEncoder(rw).Encode(result) if err != nil { return fmt.Errorf("json.NewEncoder failed: %v", err) diff --git a/server/agentengine/controllers/method/stream_query.go b/server/agentengine/controllers/method/stream_query.go index 0337adc8c..c3edabc9d 100644 --- a/server/agentengine/controllers/method/stream_query.go +++ b/server/agentengine/controllers/method/stream_query.go @@ -169,11 +169,12 @@ func (s *streamQueryHandler) run(ctx context.Context, req *models.StreamQueryReq rootAgent := config.AgentLoader.RootAgent() r, err := runner.New(runner.Config{ - AppName: s.agentEngineID, - Agent: rootAgent, - SessionService: config.SessionService, - ArtifactService: config.ArtifactService, - PluginConfig: config.PluginConfig, + AppName: s.agentEngineID, + Agent: rootAgent, + SessionService: config.SessionService, + ArtifactService: config.ArtifactService, + PluginConfig: config.PluginConfig, + AutoCreateSession: true, }) if err != nil { return nil, fmt.Errorf("failed to create runner: %v", err) diff --git a/server/agentengine/internal/models/session.go b/server/agentengine/internal/models/session.go index e84c6a9b8..b2a707370 100644 --- a/server/agentengine/internal/models/session.go +++ b/server/agentengine/internal/models/session.go @@ -26,9 +26,8 @@ type CreateSessionRequest struct { // CreateSessionInput contains input parameters type CreateSessionInput struct { - UserID string `json:"user_id"` - SessionID string `json:"session_id,omitempty"` - State map[string]any `json:"state,omitempty"` + UserID string `json:"user_id"` + State map[string]any `json:"state,omitempty"` } // CreateSessionResponse contains response From 61b0fd1247c5812bfc332f493ea8e8d9382d7e1f Mon Sep 17 00:00:00 2001 From: Mikalai Senkevich Date: Tue, 28 Apr 2026 14:35:20 +0200 Subject: [PATCH 29/86] feat: add support for updating existing Agent Engine instances (#755) * feat: add update functionality for Agent Engine instances via gcloud command flags * feat: add --update and --instance_name flags to support updating existing Agent Engine instances * feat: include and upload class methods in reasoning engine update request * refactor: replace update and instance_name flags with agent_engine_id for streamlined resource identification * refactor: replace update and instance_name flags with agent_engine_id for streamlined resource identification --- .../deploy/agentengine/agentengine.go | 87 ++++++++++++++++++- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/cmd/adkgo/internal/deploy/agentengine/agentengine.go b/cmd/adkgo/internal/deploy/agentengine/agentengine.go index 206fa16ca..2f9518c27 100644 --- a/cmd/adkgo/internal/deploy/agentengine/agentengine.go +++ b/cmd/adkgo/internal/deploy/agentengine/agentengine.go @@ -32,6 +32,7 @@ import ( "cloud.google.com/go/aiplatform/apiv1/aiplatformpb" "github.com/spf13/cobra" "google.golang.org/api/option" + "google.golang.org/protobuf/types/known/fieldmaskpb" "google.golang.org/adk/cmd/adkgo/internal/deploy" "google.golang.org/adk/internal/cli/util" @@ -44,9 +45,10 @@ type gCloudFlags struct { } type agentEngineServiceFlags struct { - name string - displayName string - serverPort int + name string + displayName string + serverPort int + agentEngineID string } type buildFlags struct { @@ -94,6 +96,7 @@ func init() { agentEngineCmd.PersistentFlags().IntVar(&flags.agentEngine.serverPort, "server_port", 8080, "agentEngine server port") agentEngineCmd.PersistentFlags().StringVarP(&flags.source.entryPointPath, "entry_point_path", "e", "", "Path to an entry point (go 'main')") agentEngineCmd.PersistentFlags().StringVarP(&flags.source.sourceDir, "source_dir", "d", "", "Directory to archive, defaults to current working directory") + agentEngineCmd.PersistentFlags().StringVar(&flags.agentEngine.agentEngineID, "agent_engine_id", "", "ID of the Agent Engine instance to update if it exists (default: \"\", which means a new instance will be created).") } // computeFlags uses command line arguments to create a full config @@ -288,6 +291,78 @@ func (f *deployAgentEngineFlags) gcloudDeployToAgentEngine() error { }) } +// gcloudUpdateAgentEngine invokes gcloud to update source on agentEngine +func (f *deployAgentEngineFlags) gcloudUpdateAgentEngine() error { + return util.LogStartStop("Updating Agent Engine", + func(p util.Printer) error { + ctx := context.Background() + name := fmt.Sprintf("projects/%s/locations/%s/reasoningEngines/%s", f.gcloud.projectName, f.gcloud.region, f.agentEngine.agentEngineID) + endpoint := fmt.Sprintf("%s-aiplatform.googleapis.com:443", f.gcloud.region) + client, err := aiplatform.NewReasoningEngineClient(ctx, option.WithEndpoint(endpoint)) + if err != nil { + return fmt.Errorf("cannot create ReasoningEngineClient: %w", err) + } + defer func() { + if err := client.Close(); err != nil { + p("Warning: failed to close ReasoningEngineClient: %v", err) + } + }() + + archiveContent, err := os.ReadFile(f.build.archivePath) + if err != nil { + return fmt.Errorf("cannot read archive file: %w", err) + } + + methods, err := agentengine.ListClassMethods() + if err != nil { + return fmt.Errorf("cannot list class methods: %w", err) + } + methodsJSON, err := json.Marshal(methods) + if err != nil { + return fmt.Errorf("cannot marshal methods: %w", err) + } + p("Methods:", string(methodsJSON)) + + req := &aiplatformpb.UpdateReasoningEngineRequest{ + ReasoningEngine: &aiplatformpb.ReasoningEngine{ + Name: name, + Spec: &aiplatformpb.ReasoningEngineSpec{ + DeploymentSource: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec_{ + SourceCodeSpec: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec{ + Source: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec_InlineSource_{ + InlineSource: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec_InlineSource{ + SourceArchive: archiveContent, + }, + }, + LanguageSpec: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec_ImageSpec_{ + ImageSpec: &aiplatformpb.ReasoningEngineSpec_SourceCodeSpec_ImageSpec{}, + }, + }, + }, + ClassMethods: methods, + }, + }, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"spec.source_code_spec", "spec.class_methods"}}, + } + p("Sending UpdateReasoningEngine request...") + op, err := client.UpdateReasoningEngine(ctx, req) + if err != nil { + return fmt.Errorf("UpdateReasoningEngine failed: %w", err) + } + + p("Waiting for operation to complete...") + re, err := op.Wait(ctx) + if err != nil { + return fmt.Errorf("operation failed: %w", err) + } + + p("Updated Reasoning Engine:", re.Name) + p("Display Name:", re.DisplayName) + + return nil + }) +} + // deployOnagentEngine executes the sequence of actions preparing and deploying the agent to agentEngine func (f *deployAgentEngineFlags) deployOnagentEngine() error { fmt.Println(flags) @@ -304,7 +379,11 @@ func (f *deployAgentEngineFlags) deployOnagentEngine() error { if err != nil { return err } - err = f.gcloudDeployToAgentEngine() + if f.agentEngine.agentEngineID != "" { + err = f.gcloudUpdateAgentEngine() + } else { + err = f.gcloudDeployToAgentEngine() + } if err != nil { return err } From fe25b9370ee12f1aa33e45387a9c61ad6e73855b Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Wed, 29 Apr 2026 15:13:07 +0200 Subject: [PATCH 30/86] Add support for simple text instead of full genai.Content for stream_query (#773) * Add support for simple text instead of full genai.Content for stream_query * Small non-functional fixes * Small non-functional fixes (~/go/bin/golangci-lint run --fix) --- .../controllers/method/stream_query.go | 33 +++- .../controllers/method/stream_query_test.go | 165 ++++++++++++++++++ .../internal/models/stream_query.go | 15 +- 3 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 server/agentengine/controllers/method/stream_query_test.go diff --git a/server/agentengine/controllers/method/stream_query.go b/server/agentengine/controllers/method/stream_query.go index c3edabc9d..f192c05f9 100644 --- a/server/agentengine/controllers/method/stream_query.go +++ b/server/agentengine/controllers/method/stream_query.go @@ -60,11 +60,27 @@ func (s *streamQueryHandler) Handle(ctx context.Context, rw http.ResponseWriter, func (s *streamQueryHandler) streamJSONL(ctx context.Context, rw http.ResponseWriter, payload []byte) error { var req models.StreamQueryRequest + // try to unmarshal models.StreamQueryRequest first err := json.Unmarshal(payload, &req) if err != nil { - err = fmt.Errorf("json.Unmarshal() failed: %v", err) - log.Print(err.Error()) - return err + // try to unmarshal models.StreamQueryTextRequest + var reqText models.StreamQueryTextRequest + errText := json.Unmarshal(payload, &reqText) + if errText != nil { + // cannot unmarshall to models.StreamQueryRequest and models.StreamQueryTextRequest + err = fmt.Errorf("json.Unmarshal() failed both for models.StreamQueryRequest (%v) and models.StreamQueryTextRequest (%v)", err, errText) + log.Print(err.Error()) + return err + } + // got text, create a full content based on that text + req = models.StreamQueryRequest{ + ClassMethod: reqText.ClassMethod, + Input: models.StreamQueryInput{ + UserID: reqText.Input.UserID, + SessionID: reqText.Input.SessionID, + Message: *genai.NewContentFromText(reqText.Input.Message, genai.RoleUser), + }, + } } events, err := s.run(ctx, &req, &req.Input.Message, s.config) @@ -135,8 +151,15 @@ func (s *streamQueryHandler) Metadata() (*structpb.Struct, error) { "type": "string", }, "message": map[string]any{ - "additionalProperties": true, - "type": "object", + "anyOf": []any{ + map[string]any{ + "type": "string", + }, + map[string]any{ + "additionalProperties": true, + "type": "object", + }, + }, }, }, "required": []any{ diff --git a/server/agentengine/controllers/method/stream_query_test.go b/server/agentengine/controllers/method/stream_query_test.go new file mode 100644 index 000000000..4a7f35e97 --- /dev/null +++ b/server/agentengine/controllers/method/stream_query_test.go @@ -0,0 +1,165 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package method + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "testing" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/cmd/launcher" + "google.golang.org/adk/session" +) + +type simpleEvent struct { + Content *genai.Content `json:"content"` +} + +// TestSimpleText checks whether a simple message as string gives the same result as genai.Content. +func TestSimpleText(t *testing.T) { + agentEngineId := 123 + appName := strconv.Itoa(agentEngineId) + userID := "u" + + // agent invokes BeforeAgent callback which returns the content as provided as an answer + a, err := llmagent.New(llmagent.Config{ + Name: "Echo", + BeforeAgentCallbacks: []agent.BeforeAgentCallback{ + func(cc agent.CallbackContext) (*genai.Content, error) { + return cc.UserContent(), nil + }, + }, + }) + if err != nil { + t.Fatalf("failed to create agent: %v", err) + } + + config := &launcher.Config{ + AgentLoader: agent.NewSingleLoader(a), + SessionService: session.InMemoryService(), + } + h := NewStreamQueryHandler(config, appName, "async_stream_query", "") + + ctx := t.Context() + sess, err := config.SessionService.Create(ctx, &session.CreateRequest{AppName: appName, UserID: userID}) + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + + wantContent := genai.NewContentFromText("Say hello", genai.RoleUser) + wantBytes, err := json.Marshal(wantContent) + if err != nil { + t.Fatalf("json.Marshal() failed: %v", err) + } + want := string(wantBytes) + + tests := []struct { + name string + payload string + }{ + { + name: "full content", + payload: `{ +"class_method":"async_stream_query", +"input":{ + "message":{ + "parts":[ + {"text":"Say hello"} + ], + "role":"user" + }, + "session_id":"` + sess.Session.ID() + `", + "user_id":"` + userID + `"}}`, + }, + { + name: "simplified content", + payload: `{ +"class_method":"async_stream_query", +"input":{ + "message":"Say hello", + "session_id":"` + sess.Session.ID() + `", + "user_id":"` + userID + `"}}`, + }, + } + + for _, tt := range tests { + w := newStringWriter() + b := []byte(tt.payload) + err := h.streamJSONL(t.Context(), w, b) + if err != nil { + t.Fatalf("streamJSONL() failed: %v", err) + } + + var ev simpleEvent + p := w.sb.String() + + err = json.Unmarshal([]byte(p), &ev) + if err != nil { + t.Fatalf("json.Unmarshal() failed: %v", err) + } + gotBytes, err := json.Marshal(ev.Content) + if err != nil { + t.Fatalf("json.Marshal() failed: %v", err) + } + got := string(gotBytes) + if got != want { + t.Errorf("streamJSONL() = %v, want %v", got, want) + } + } +} + +// mock writer for http +type stringWriter struct { + sb strings.Builder + h http.Header +} + +// Header implements [http.ResponseWriter]. +func (s *stringWriter) Header() http.Header { + return s.h +} + +// WriteHeader implements [http.ResponseWriter]. +func (s *stringWriter) WriteHeader(statusCode int) { + s.h = http.Header{"Status": []string{http.StatusText(statusCode)}} +} + +// Write implements [http.ResponseWriter]. +func (s *stringWriter) Write(p []byte) (n int, err error) { + return s.sb.Write(p) +} + +// Flush implements [http.Flusher] +func (s *stringWriter) Flush() { + // do nothing +} + +var ( + _ http.ResponseWriter = (*stringWriter)(nil) + _ http.Flusher = (*stringWriter)(nil) +) + +func newStringWriter() *stringWriter { + return &stringWriter{ + sb: strings.Builder{}, + h: http.Header{}, + } +} diff --git a/server/agentengine/internal/models/stream_query.go b/server/agentengine/internal/models/stream_query.go index dcf7fec07..628584f86 100644 --- a/server/agentengine/internal/models/stream_query.go +++ b/server/agentengine/internal/models/stream_query.go @@ -20,7 +20,7 @@ import ( "google.golang.org/adk/session" ) -// StreamQueryRequest is a struct representing JSON-encoded payload to async_stream_query method with dedicated Input. +// StreamQueryRequest is a struct representing JSON-encoded payload to async_stream_query method with dedicated Input with full genai.Content. type StreamQueryRequest struct { ClassMethod string `json:"class_method"` Input StreamQueryInput `json:"input"` @@ -33,6 +33,19 @@ type StreamQueryInput struct { Message genai.Content `json:"message"` } +// StreamQueryTextRequest is a struct representing JSON-encoded payload to async_stream_query method with dedicated Input with simple text as the content. +type StreamQueryTextRequest struct { + ClassMethod string `json:"class_method"` + Input StreamQueryTextInput `json:"input"` +} + +// StreamQueryTextInput is the actual Input for async_stream_query method. +type StreamQueryTextInput struct { + UserID string `json:"user_id"` + SessionID string `json:"session_id"` + Message string `json:"message"` +} + // StreamQueryResponse defines the content of event data for async_stream_query method. // It is returned as one line with JSON-encoded StreamQuerySSEResponse // Please mind that errors are also returned by this method From 415c921dcdb7c6eda8d03bc8b185691a6569cadb Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Wed, 29 Apr 2026 16:16:37 +0200 Subject: [PATCH 31/86] feat: make adk work with a2a-go/v2 (#701) * Copied the code to ./agent/remoteagent and ./server/adka2a to v2 directories. * Migrated the code in v2 directories to use a2a-go v2 types. * Changed the code in the original directories to delegate to the new code and serve as a type translation layer. * Deprecated original packages with a reference to v2 directories. What this gives is that ADK can be used with a2a-go/v2 which implements A2A protocol v1 (as well as v0). --- .golangci.yml | 1 + agent/remoteagent/a2a_agent.go | 343 ++++++------- agent/remoteagent/a2a_agent_compat_test.go | 478 ++++++++++++++++++ agent/remoteagent/v2/a2a_agent.go | 378 ++++++++++++++ .../{ => v2}/a2a_agent_run_processor.go | 13 +- .../{ => v2}/a2a_agent_run_processor_test.go | 43 +- agent/remoteagent/{ => v2}/a2a_agent_test.go | 364 +++++++------ agent/remoteagent/{ => v2}/a2a_e2e_test.go | 223 ++++---- agent/remoteagent/v2/client.go | 52 ++ agent/remoteagent/{ => v2}/doc.go | 2 +- ...tA2ARemoteAgentStreamingGeminiError.httprr | 0 ...2ARemoteAgentStreamingGeminiSuccess.httprr | 0 ...inalResponse_llm_mid-response_error.httprr | 0 ...nse_llm_mid-response_error_response.httprr | 0 agent/remoteagent/{ => v2}/utils.go | 39 +- agent/remoteagent/{ => v2}/utils_test.go | 34 +- go.mod | 15 +- go.sum | 33 +- internal/agent/remoteagent/a2a_config.go | 74 ++- server/adka2a/conversions.go | 155 ++++++ server/adka2a/executor.go | 454 +++++++---------- server/adka2a/{ => v2}/agent_card.go | 2 +- server/adka2a/{ => v2}/agent_card_test.go | 2 +- server/adka2a/{ => v2}/doc.go | 2 +- server/adka2a/{ => v2}/events.go | 90 ++-- server/adka2a/{ => v2}/events_test.go | 99 ++-- server/adka2a/v2/executor.go | 459 +++++++++++++++++ server/adka2a/{ => v2}/executor_context.go | 8 +- server/adka2a/{ => v2}/executor_plugin.go | 0 server/adka2a/{ => v2}/executor_test.go | 394 ++++++++------- server/adka2a/{ => v2}/input_required.go | 70 +-- server/adka2a/{ => v2}/metadata.go | 16 +- server/adka2a/{ => v2}/metadata_test.go | 2 +- server/adka2a/{ => v2}/parts.go | 187 +++---- server/adka2a/{ => v2}/parts_test.go | 100 ++-- server/adka2a/{ => v2}/processor.go | 23 +- server/adka2a/{ => v2}/processor_test.go | 177 +++---- server/adka2a/{ => v2}/task_artifact.go | 22 +- server/adka2a/{ => v2}/utils.go | 2 +- 39 files changed, 2953 insertions(+), 1403 deletions(-) create mode 100644 agent/remoteagent/a2a_agent_compat_test.go create mode 100644 agent/remoteagent/v2/a2a_agent.go rename agent/remoteagent/{ => v2}/a2a_agent_run_processor.go (96%) rename agent/remoteagent/{ => v2}/a2a_agent_run_processor_test.go (88%) rename agent/remoteagent/{ => v2}/a2a_agent_test.go (78%) rename agent/remoteagent/{ => v2}/a2a_e2e_test.go (86%) create mode 100644 agent/remoteagent/v2/client.go rename agent/remoteagent/{ => v2}/doc.go (91%) rename agent/remoteagent/{ => v2}/testdata/TestA2ARemoteAgentStreamingGeminiError.httprr (100%) rename agent/remoteagent/{ => v2}/testdata/TestA2ARemoteAgentStreamingGeminiSuccess.httprr (100%) rename agent/remoteagent/{ => v2}/testdata/TestA2ASingleHopFinalResponse_llm_mid-response_error.httprr (100%) rename agent/remoteagent/{ => v2}/testdata/TestA2ASingleHopFinalResponse_llm_mid-response_error_response.httprr (100%) rename agent/remoteagent/{ => v2}/utils.go (84%) rename agent/remoteagent/{ => v2}/utils_test.go (94%) create mode 100644 server/adka2a/conversions.go rename server/adka2a/{ => v2}/agent_card.go (99%) rename server/adka2a/{ => v2}/agent_card_test.go (99%) rename server/adka2a/{ => v2}/doc.go (91%) rename server/adka2a/{ => v2}/events.go (80%) rename server/adka2a/{ => v2}/events_test.go (85%) create mode 100644 server/adka2a/v2/executor.go rename server/adka2a/{ => v2}/executor_context.go (92%) rename server/adka2a/{ => v2}/executor_plugin.go (100%) rename server/adka2a/{ => v2}/executor_test.go (70%) rename server/adka2a/{ => v2}/input_required.go (78%) rename server/adka2a/{ => v2}/metadata.go (93%) rename server/adka2a/{ => v2}/metadata_test.go (99%) rename server/adka2a/{ => v2}/parts.go (65%) rename server/adka2a/{ => v2}/parts_test.go (68%) rename server/adka2a/{ => v2}/processor.go (88%) rename server/adka2a/{ => v2}/processor_test.go (78%) rename server/adka2a/{ => v2}/task_artifact.go (86%) rename server/adka2a/{ => v2}/utils.go (97%) diff --git a/.golangci.yml b/.golangci.yml index dd478fe62..4c597cef0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -53,6 +53,7 @@ linters: # Changes to the default config: - -QF1001 # Exclude "Apply De Morgan's law" - -QF1008 # Exclude "Omit embedded fields from selector expression" + - -SA1019 # Exclude to keep the old a2a compatibility, can remove for ADK v2 exclusions: rules: diff --git a/agent/remoteagent/a2a_agent.go b/agent/remoteagent/a2a_agent.go index 6e9950052..2d68fc54b 100644 --- a/agent/remoteagent/a2a_agent.go +++ b/agent/remoteagent/a2a_agent.go @@ -12,22 +12,28 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package remoteagent allows using a remote ADK agents. +// +// Deprecated: Use google.golang.org/adk/agent/remoteagent/v2 instead. package remoteagent import ( "context" + "errors" "fmt" "iter" - "log" - "time" "github.com/a2aproject/a2a-go/a2a" "github.com/a2aproject/a2a-go/a2aclient" "github.com/a2aproject/a2a-go/a2aclient/agentcard" + "github.com/a2aproject/a2a-go/log" + v2a2a "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" + + "google.golang.org/genai" "google.golang.org/adk/agent" - agentinternal "google.golang.org/adk/internal/agent" - iremoteagent "google.golang.org/adk/internal/agent/remoteagent" + v2 "google.golang.org/adk/agent/remoteagent/v2" "google.golang.org/adk/server/adka2a" "google.golang.org/adk/session" ) @@ -125,236 +131,201 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) { return nil, fmt.Errorf("either AgentCard or AgentCardSource must be provided") } - remoteAgent := &a2aAgent{ - serverConfig: &iremoteagent.A2AServerConfig{ - AgentCard: cfg.AgentCard, - AgentCardSource: cfg.AgentCardSource, - CardResolveOptions: cfg.CardResolveOptions, - ClientFactory: cfg.ClientFactory, - }, - } - agent, err := agent.New(agent.Config{ + v1Cfg := v2.A2AConfig{ Name: cfg.Name, Description: cfg.Description, BeforeAgentCallbacks: cfg.BeforeAgentCallbacks, AfterAgentCallbacks: cfg.AfterAgentCallbacks, - Run: func(ic agent.InvocationContext) iter.Seq2[*session.Event, error] { - return remoteAgent.run(ic, cfg) - }, - }) - if err != nil { - return nil, err } - internalAgent, ok := agent.(agentinternal.Agent) - if !ok { - return nil, fmt.Errorf("internal error: failed to convert to internal agent") + if cfg.AgentCard != nil { + v1Cfg.AgentCard = a2av0.ToV1AgentCard(cfg.AgentCard) + } else if cfg.AgentCardSource != "" { + source := cfg.AgentCardSource + resolveOpts := cfg.CardResolveOptions + v1Cfg.AgentCardProvider = func(ctx context.Context) (*v2a2a.AgentCard, error) { + v0Card, err := agentcard.DefaultResolver.Resolve(ctx, source, resolveOpts...) + if err != nil { + return nil, err + } + return a2av0.ToV1AgentCard(v0Card), nil + } } - state := agentinternal.Reveal(internalAgent) - state.AgentType = agentinternal.TypeRemoteAgent - state.Config = iremoteagent.RemoteAgentState{A2A: remoteAgent.serverConfig} - - return agent, nil -} - -type a2aAgent struct { - serverConfig *iremoteagent.A2AServerConfig -} -func (a *a2aAgent) run(ctx agent.InvocationContext, cfg A2AConfig) iter.Seq2[*session.Event, error] { - return func(yield func(*session.Event, error) bool) { - agentCard, client, err := iremoteagent.CreateA2AClient(ctx, a.serverConfig) + if cfg.MessageSendConfig != nil { + req, err := a2av0.ToV1SendMessageRequest(&a2a.MessageSendParams{Config: cfg.MessageSendConfig}) if err != nil { - yield(toErrorEvent(ctx, fmt.Errorf("client creation failed: %w", err)), nil) - return + return nil, fmt.Errorf("MessageSendConfig conversion failed: %w", err) } - defer destroy(client) + v1Cfg.MessageSendConfig = req.Config + } - msg, err := newMessage(ctx, cfg) - if err != nil { - yield(toErrorEvent(ctx, fmt.Errorf("message creation failed: %w", err)), nil) - return + if cfg.ClientFactory != nil { + v1Cfg.ClientProvider = func(ctx context.Context, card *v2a2a.AgentCard) (v2.A2AClient, error) { + legacyCard := a2av0.FromV1AgentCard(card) + client, err := cfg.ClientFactory.CreateFromCard(ctx, legacyCard) + if err != nil { + return nil, err + } + return &compatClient{client: client}, nil } + } - req := &a2a.MessageSendParams{Message: msg, Config: cfg.MessageSendConfig} - - processor := newRunProcessor(cfg, req) - - if bcbResp, bcbErr := processor.runBeforeA2ARequestCallbacks(ctx); bcbResp != nil || bcbErr != nil { - if acbResp, acbErr := processor.runAfterA2ARequestCallbacks(ctx, bcbResp, bcbErr); acbResp != nil || acbErr != nil { - yield(acbResp, acbErr) - } else { - yield(bcbResp, bcbErr) + if cfg.Converter != nil { + v1Cfg.Converter = func(ctx agent.InvocationContext, req *v2a2a.SendMessageRequest, event v2a2a.Event, err error) (*session.Event, error) { + legacyReq := a2av0.FromV1SendMessageRequest(req) + var legacyEvent a2a.Event + if event != nil { + var convErr error + legacyEvent, convErr = a2av0.FromV1Event(event) + if convErr != nil { + return nil, errors.Join(fmt.Errorf("a2a event conversion failed: %w", convErr), err) + } } - return + return cfg.Converter(ctx, legacyReq, legacyEvent, err) } + } - if len(msg.Parts) == 0 { - resp := adka2a.NewRemoteAgentEvent(ctx) - if cbResp, cbErr := processor.runAfterA2ARequestCallbacks(ctx, resp, err); cbResp != nil || cbErr != nil { - yield(cbResp, cbErr) - } else { - yield(resp, nil) - } - return + if cfg.BeforeRequestCallbacks != nil { + v1Cfg.BeforeRequestCallbacks = make([]v2.BeforeA2ARequestCallback, 0, len(cfg.BeforeRequestCallbacks)) + for _, cb := range cfg.BeforeRequestCallbacks { + v1Cfg.BeforeRequestCallbacks = append(v1Cfg.BeforeRequestCallbacks, func(ctx agent.CallbackContext, req *v2a2a.SendMessageRequest) (*session.Event, error) { + legacyReq := a2av0.FromV1SendMessageRequest(req) + resp, err := cb(ctx, legacyReq) + if resp != nil || err != nil { // short-circuit, no need to convert the request back + return resp, err + } + // callback pass-through request modifications + v1Req, convErr := a2av0.ToV1SendMessageRequest(legacyReq) + if convErr != nil { + return nil, convErr + } + *req = *v1Req + return nil, nil + }) } + } - var lastErr error - yieldErr := func(err error) bool { - lastErr = err - return yield(nil, err) + if cfg.AfterRequestCallbacks != nil { + v1Cfg.AfterRequestCallbacks = make([]v2.AfterA2ARequestCallback, 0, len(cfg.AfterRequestCallbacks)) + for _, cb := range cfg.AfterRequestCallbacks { + v1Cfg.AfterRequestCallbacks = append(v1Cfg.AfterRequestCallbacks, func(ctx agent.CallbackContext, req *v2a2a.SendMessageRequest, resp *session.Event, err error) (*session.Event, error) { + legacyReq := a2av0.FromV1SendMessageRequest(req) + newResp, newErr := cb(ctx, legacyReq, resp, err) + if newResp != nil || newErr != nil { // short-circuit, no need to convert the request back + return newResp, newErr + } + // callback pass-through request modifications + v1Req, convErr := a2av0.ToV1SendMessageRequest(legacyReq) + if convErr != nil { + return nil, convErr + } + *req = *v1Req + return nil, nil + }) } + } - var lastEvent a2a.Event - defer func() { - err := lastErr - if err == nil && ctx.Err() != nil { - err = context.Cause(ctx) + if cfg.A2APartConverter != nil { + v1Cfg.A2APartConverter = func(ctx context.Context, a2aEvent v2a2a.Event, part *v2a2a.Part) (*genai.Part, error) { + legacyEvent, convErr := a2av0.FromV1Event(a2aEvent) + if convErr != nil { + return nil, convErr } - cleanupRemoteTask(ctx, cfg, agentCard, client, lastEvent, err) - }() + return cfg.A2APartConverter(ctx, legacyEvent, a2av0.FromV1Part(part)) + } + } - processEvent := func(a2aEvent a2a.Event, a2aErr error) bool { - if a2aEvent != nil { - lastEvent = a2aEvent + if cfg.GenAIPartConverter != nil { + v1Cfg.GenAIPartConverter = func(ctx context.Context, adkEvent *session.Event, part *genai.Part) (*v2a2a.Part, error) { + legacyPart, err := cfg.GenAIPartConverter(ctx, adkEvent, part) + if err != nil { + return nil, err } + return a2av0.ToV1Part(legacyPart), nil + } + } - var err error - var event *session.Event - if cfg.Converter != nil { - event, err = cfg.Converter(ctx, req, a2aEvent, a2aErr) - } else { - event, err = processor.convertToSessionEvent(ctx, a2aEvent, a2aErr) - } + if cfg.RemoteTaskCleanupCallback != nil { + v1Cfg.RemoteTaskCleanupCallback = func(ctx context.Context, card *v2a2a.AgentCard, client v2.A2AClient, taskInfo v2a2a.TaskInfo, cause error) { + legacyCard := a2av0.FromV1AgentCard(card) + legacyTaskInfo := a2a.TaskInfo{TaskID: a2a.TaskID(taskInfo.TaskID), ContextID: taskInfo.ContextID} - if cbResp, cbErr := processor.runAfterA2ARequestCallbacks(ctx, event, err); cbResp != nil || cbErr != nil { - if cbErr != nil { - return yieldErr(cbErr) - } - event = cbResp - err = nil + if cc, ok := client.(*compatClient); ok { + cfg.RemoteTaskCleanupCallback(ctx, legacyCard, cc.client, legacyTaskInfo, cause) + return } - if err != nil { - return yieldErr(err) - } + log.Warn(ctx, "client is not an instance of compatClient, fallback to creating a new client", "type", fmt.Sprintf("%T", client)) - if event != nil { // an event might be skipped - for _, toEmit := range processor.aggregatePartial(ctx, a2aEvent, event) { - if !yield(toEmit, nil) { - return false - } - } + factory := cfg.ClientFactory + if factory == nil { + factory = a2aclient.NewFactory() } - return true - } - - if ctx.RunConfig().StreamingMode == agent.StreamingModeNone { - a2aEvent, a2aErr := client.SendMessage(ctx, req) - processEvent(a2aEvent, a2aErr) - return - } - - for a2aEvent, a2aErr := range client.SendStreamingMessage(ctx, req) { - if !processEvent(a2aEvent, a2aErr) { + legacyClient, err := factory.CreateFromCard(ctx, legacyCard) + if err != nil { + log.Warn(ctx, "RemoteTaskCleanupCallback: failed to create legacy client", "error", err) return } + defer func() { + if err := legacyClient.Destroy(); err != nil { + log.Warn(ctx, "RemoteTaskCleanupCallback: failed to destroy a legacy client", "error", err) + } + }() + cfg.RemoteTaskCleanupCallback(ctx, legacyCard, legacyClient, legacyTaskInfo, cause) } } -} - -func cleanupRemoteTask(ctx context.Context, cfg A2AConfig, card *a2a.AgentCard, client *a2aclient.Client, lastEvent a2a.Event, cause error) { - if lastEvent == nil { - return - } - taskID := lastEvent.TaskInfo().TaskID - if taskID == "" { - return - } - if _, ok := lastEvent.(*a2a.Message); ok { - return - } - var state a2a.TaskState - if tu, ok := lastEvent.(*a2a.TaskStatusUpdateEvent); ok { - state = tu.Status.State - } - if t, ok := lastEvent.(*a2a.Task); ok { - state = t.Status.State - } - if state.Terminal() { - return - } - ctx = context.WithoutCancel(ctx) + return v2.NewA2A(v1Cfg) +} - if cfg.RemoteTaskCleanupCallback != nil { - cfg.RemoteTaskCleanupCallback(ctx, card, client, lastEvent.TaskInfo(), cause) - return - } +type compatClient struct { + client *a2aclient.Client +} - if state == a2a.TaskStateInputRequired && cause == nil { - return +func (s *compatClient) SendMessage(ctx context.Context, req *v2a2a.SendMessageRequest) (v2a2a.SendMessageResult, error) { + legacyResp, err := s.client.SendMessage(ctx, a2av0.FromV1SendMessageRequest(req)) + if err != nil { + return nil, err } - cancelCtx, cancelTimeout := context.WithTimeout(ctx, 5*time.Second) - defer cancelTimeout() - _, err := client.CancelTask(cancelCtx, &a2a.TaskIDParams{ID: taskID}) + v1Event, err := a2av0.ToV1Event(legacyResp) if err != nil { - log.Printf("failed to cancel task %s: %v", taskID, err) + return nil, err } -} - -func newMessage(ctx agent.InvocationContext, cfg A2AConfig) (*a2a.Message, error) { - events := ctx.Session().Events() - if userFnCall := getUserFunctionCallAt(events, events.Len()-1); userFnCall != nil { - event := userFnCall.response - parts, err := convertParts(ctx, cfg, event) - if err != nil { - return nil, err - } - msg := a2a.NewMessage(a2a.MessageRoleUser, parts...) - msg.TaskID = userFnCall.taskID - msg.ContextID = userFnCall.contextID - return msg, nil + res, ok := v1Event.(v2a2a.SendMessageResult) + if !ok { + return nil, fmt.Errorf("converted event does not implement SendMessageResult: %T", v1Event) } - - parts, contextID := toMissingRemoteSessionParts(ctx, events, cfg) - msg := a2a.NewMessage(a2a.MessageRoleUser, parts...) - msg.ContextID = contextID - return msg, nil -} - -func toErrorEvent(ctx agent.InvocationContext, err error) *session.Event { - event := adka2a.NewRemoteAgentEvent(ctx) - event.ErrorMessage = err.Error() - event.CustomMetadata = map[string]any{adka2a.ToADKMetaKey("error"): err.Error()} - event.TurnComplete = true - return event + return res, nil } -func convertParts(ctx agent.InvocationContext, cfg A2AConfig, event *session.Event) ([]a2a.Part, error) { - parts := make([]a2a.Part, 0, len(event.Content.Parts)) - if cfg.GenAIPartConverter != nil { - for _, part := range event.Content.Parts { - cp, err := cfg.GenAIPartConverter(ctx, event, part) +func (s *compatClient) SendStreamingMessage(ctx context.Context, req *v2a2a.SendMessageRequest) iter.Seq2[v2a2a.Event, error] { + return func(yield func(v2a2a.Event, error) bool) { + for legacyEvent, err := range s.client.SendStreamingMessage(ctx, a2av0.FromV1SendMessageRequest(req)) { if err != nil { - return nil, err + yield(nil, err) + return } - if cp != nil { - parts = append(parts, cp) + v1Event, convErr := a2av0.ToV1Event(legacyEvent) + if convErr != nil { + yield(nil, convErr) + return + } + if !yield(v1Event, nil) { + return } - } - } else { - var err error - parts, err = adka2a.ToA2AParts(event.Content.Parts, event.LongRunningToolIDs) - if err != nil { - return nil, fmt.Errorf("event part conversion failed: %w", err) } } - return parts, nil } -func destroy(client *a2aclient.Client) { - if err := client.Destroy(); err != nil { - log.Printf("failed to destroy client: %v", err) +func (s *compatClient) CancelTask(ctx context.Context, req *v2a2a.CancelTaskRequest) (*v2a2a.Task, error) { + legacyResp, err := s.client.CancelTask(ctx, a2av0.FromV1CancelTaskRequest(req)) + if err != nil { + return nil, err } + return a2av0.ToV1Task(legacyResp) +} + +func (s *compatClient) Destroy() error { + return s.client.Destroy() } diff --git a/agent/remoteagent/a2a_agent_compat_test.go b/agent/remoteagent/a2a_agent_compat_test.go new file mode 100644 index 000000000..0b321a697 --- /dev/null +++ b/agent/remoteagent/a2a_agent_compat_test.go @@ -0,0 +1,478 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package remoteagent + +import ( + "context" + "iter" + "net/http/httptest" + "testing" + "time" + + legacyA2A "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2aclient" + legacyASrv "github.com/a2aproject/a2a-go/a2asrv" + v2a2a "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" + v2asrv "github.com/a2aproject/a2a-go/v2/a2asrv" + "google.golang.org/genai" + + "google.golang.org/adk/agent" + icontext "google.golang.org/adk/internal/context" + "google.golang.org/adk/internal/utils" + "google.golang.org/adk/model" + "google.golang.org/adk/runner" + "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/session" +) + +func TestCompat_OldExecutor_Direct(t *testing.T) { + agentName := "test-agent" + agentObj := utils.Must(agent.New(agent.Config{ + Name: agentName, + Run: func(ic agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + yield(&session.Event{ + LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("hello", genai.RoleModel)}, + Author: agentName, + }, nil) + } + }, + })) + + executor := adka2a.NewExecutor(adka2a.ExecutorConfig{ + OutputMode: adka2a.OutputArtifactPerEvent, + RunnerConfig: runner.Config{ + AppName: "TestApp", + Agent: agentObj, + SessionService: session.InMemoryService(), + }, + RunConfig: agent.RunConfig{ + StreamingMode: agent.StreamingModeSSE, + }, + GenAIPartConverter: func(ctx context.Context, adkEvent *session.Event, part *genai.Part) (legacyA2A.Part, error) { + return a2av0.FromV1Part(v2a2a.NewTextPart(part.Text)), nil + }, + AfterEventCallback: func(ctx adka2a.ExecutorContext, event *session.Event, processed *legacyA2A.TaskArtifactUpdateEvent) error { + if processed.Artifact != nil && len(processed.Artifact.Parts) > 0 { + processed.Artifact.Parts[0] = a2av0.FromV1Part(v2a2a.NewTextPart("modified-by-executor")) + } + return nil + }, + }) + + reqCtx := &legacyASrv.RequestContext{ + ContextID: "test-context", + TaskID: legacyA2A.NewTaskID(), + Message: legacyA2A.NewMessage(legacyA2A.MessageRoleUser, a2av0.FromV1Part(v2a2a.NewTextPart("hi"))), + } + queue := &mockQueue{} + if err := executor.Execute(t.Context(), reqCtx, queue); err != nil { + t.Fatalf("executor.Execute() error = %v", err) + } + + found := false + for _, ev := range queue.events { + if ae, ok := ev.(*legacyA2A.TaskArtifactUpdateEvent); ok { + for _, p := range ae.Artifact.Parts { + gp, _ := adka2a.ToGenAIPart(p) + if gp != nil && gp.Text == "modified-by-executor" { + found = true + } + } + } + } + if !found { + t.Error("Did not find modified part in executor output events") + } +} + +func TestCompat_RemoteAgent(t *testing.T) { + tests := []struct { + name string + executor *mockV2Executor + updateConfig func(config *A2AConfig) + wantEventWithText string + }{ + { + name: "after request callback modifies response", + executor: &mockV2Executor{ + events: []v2a2a.Event{ + v2a2a.NewMessage(v2a2a.MessageRoleAgent, v2a2a.NewTextPart("hello")), + }, + }, + updateConfig: func(config *A2AConfig) { + config.AfterRequestCallbacks = []AfterA2ARequestCallback{ + func(ctx agent.CallbackContext, req *legacyA2A.MessageSendParams, resp *session.Event, err error) (*session.Event, error) { + if resp != nil && resp.Content != nil && len(resp.Content.Parts) > 0 { + resp.Content.Parts[0].Text = "modified-by-agent-callback" + } + return nil, nil + }, + } + }, + wantEventWithText: "modified-by-agent-callback", + }, + { + name: "before request callback modifies request", + executor: &mockV2Executor{ + executeFn: func(ctx context.Context, execCtx *v2asrv.ExecutorContext) iter.Seq2[v2a2a.Event, error] { + return func(yield func(v2a2a.Event, error) bool) { + text := "" + if execCtx.Message != nil && len(execCtx.Message.Parts) > 0 { + text = execCtx.Message.Parts[0].Text() + } + yield(v2a2a.NewMessage(v2a2a.MessageRoleAgent, v2a2a.NewTextPart("echo:"+text)), nil) + } + }, + }, + updateConfig: func(config *A2AConfig) { + config.BeforeRequestCallbacks = []BeforeA2ARequestCallback{ + func(ctx agent.CallbackContext, req *legacyA2A.MessageSendParams) (*session.Event, error) { + req.Message = legacyA2A.NewMessage(legacyA2A.MessageRoleUser, legacyA2A.TextPart{Text: "42"}) + return nil, nil + }, + } + }, + wantEventWithText: "echo:42", + }, + { + name: "before request callback short-circuits", + executor: &mockV2Executor{ + executeFn: func(ctx context.Context, execCtx *v2asrv.ExecutorContext) iter.Seq2[v2a2a.Event, error] { + return func(yield func(v2a2a.Event, error) bool) { + t.Fatal("server should not be called when before callback short-circuits") + } + }, + }, + updateConfig: func(config *A2AConfig) { + config.BeforeRequestCallbacks = []BeforeA2ARequestCallback{ + func(ctx agent.CallbackContext, req *legacyA2A.MessageSendParams) (*session.Event, error) { + return &session.Event{ + LLMResponse: model.LLMResponse{ + Content: genai.NewContentFromText("cached-response", genai.RoleModel), + }, + }, nil + }, + } + }, + wantEventWithText: "cached-response", + }, + { + name: "custom converter", + executor: &mockV2Executor{ + events: []v2a2a.Event{ + v2a2a.NewMessage(v2a2a.MessageRoleAgent, v2a2a.NewTextPart("original")), + }, + }, + updateConfig: func(config *A2AConfig) { + config.Converter = func(ctx agent.InvocationContext, req *legacyA2A.MessageSendParams, event legacyA2A.Event, err error) (*session.Event, error) { + ev := session.NewEvent("custom") + ev.Author = ctx.Agent().Name() + ev.LLMResponse = model.LLMResponse{ + Content: genai.NewContentFromText("converted", genai.RoleModel), + TurnComplete: true, + } + return ev, nil + } + }, + wantEventWithText: "converted", + }, + { + name: "GenAI part converter", + executor: &mockV2Executor{ + executeFn: func(ctx context.Context, execCtx *v2asrv.ExecutorContext) iter.Seq2[v2a2a.Event, error] { + return func(yield func(v2a2a.Event, error) bool) { + if execCtx.Message != nil { + for _, p := range execCtx.Message.Parts { + if p.Text() == "custom:hello" { + yield(v2a2a.NewMessage(v2a2a.MessageRoleAgent, v2a2a.NewTextPart("converter-verified")), nil) + return + } + } + } + yield(v2a2a.NewMessage(v2a2a.MessageRoleAgent, v2a2a.NewTextPart("converter-not-applied")), nil) + } + }, + }, + updateConfig: func(config *A2AConfig) { + config.GenAIPartConverter = func(ctx context.Context, adkEvent *session.Event, part *genai.Part) (legacyA2A.Part, error) { + if part.Text != "" { + return a2av0.FromV1Part(v2a2a.NewTextPart("custom:" + part.Text)), nil + } + return adka2a.ToA2APart(part, nil) + } + }, + wantEventWithText: "converter-verified", + }, + { + name: "A2A part converter", + executor: &mockV2Executor{ + events: []v2a2a.Event{ + v2a2a.NewMessage(v2a2a.MessageRoleAgent, v2a2a.NewTextPart("raw-response")), + }, + }, + updateConfig: func(config *A2AConfig) { + config.A2APartConverter = func(ctx context.Context, a2aEvent legacyA2A.Event, part legacyA2A.Part) (*genai.Part, error) { + tp, ok := part.(legacyA2A.TextPart) + if ok { + return genai.NewPartFromText("custom:" + tp.Text), nil + } + return adka2a.ToGenAIPart(part) + } + }, + wantEventWithText: "custom:raw-response", + }, + { + name: "multiple after request callbacks execute in order", + executor: &mockV2Executor{ + events: []v2a2a.Event{ + v2a2a.NewMessage(v2a2a.MessageRoleAgent, v2a2a.NewTextPart("hello")), + }, + }, + updateConfig: func(config *A2AConfig) { + config.AfterRequestCallbacks = []AfterA2ARequestCallback{ + func(ctx agent.CallbackContext, req *legacyA2A.MessageSendParams, resp *session.Event, err error) (*session.Event, error) { + if resp != nil && resp.Content != nil && len(resp.Content.Parts) > 0 { + resp.Content.Parts[0].Text += "-first" + } + return nil, nil + }, + func(ctx agent.CallbackContext, req *legacyA2A.MessageSendParams, resp *session.Event, err error) (*session.Event, error) { + if resp != nil && resp.Content != nil && len(resp.Content.Parts) > 0 { + resp.Content.Parts[0].Text += "-second" + } + return nil, nil + }, + } + }, + wantEventWithText: "hello-first-second", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := startA2AServer(t, tc.executor) + card := newLegacyCard(server.URL) + config := A2AConfig{Name: "remote-agent", AgentCard: card} + tc.updateConfig(&config) + agnt, err := NewA2A(config) + if err != nil { + t.Fatalf("NewA2A() error = %v", err) + } + ic := newInvocationContext(t, []*session.Event{newUserHello()}) + events, err := runAndCollect(ic, agnt) + if err != nil { + t.Fatalf("agent.Run() error = %v", err) + } + foundText := false + var texts []string + for _, ev := range events { + if foundText { + break + } + if ev.Content == nil { + continue + } + for _, p := range ev.Content.Parts { + if p.Text == tc.wantEventWithText { + foundText = true + break + } + if p.Text != "" { + texts = append(texts, p.Text) + } + } + } + if !foundText { + t.Errorf("expected text %q in events, got texts: %v", tc.wantEventWithText, texts) + } + }) + } +} + +func TestCompat_RemoteTaskCleanupCallback(t *testing.T) { + mockExec := &mockV2Executor{ + executeFn: func(ctx context.Context, execCtx *v2asrv.ExecutorContext) iter.Seq2[v2a2a.Event, error] { + return func(yield func(v2a2a.Event, error) bool) { + if !yield(v2a2a.NewSubmittedTask(execCtx, execCtx.Message), nil) { + return + } + for ctx.Err() == nil { + data := v2a2a.NewDataPart(map[string]any{"tick": true}) + if !yield(v2a2a.NewArtifactEvent(execCtx, data), nil) { + return + } + time.Sleep(1 * time.Millisecond) + } + yield(v2a2a.NewStatusUpdateEvent(execCtx, v2a2a.TaskStateCompleted, nil), nil) + } + }, + } + + server := startA2AServer(t, mockExec) + card := newLegacyCard(server.URL) + + cleanupCalled := false + var cleanupTaskInfo legacyA2A.TaskInfo + oldAgent := utils.Must(NewA2A(A2AConfig{ + Name: "remote-agent", + AgentCard: card, + RemoteTaskCleanupCallback: func(ctx context.Context, card *legacyA2A.AgentCard, client *a2aclient.Client, taskInfo legacyA2A.TaskInfo, cause error) { + cleanupCalled = true + cleanupTaskInfo = taskInfo + }, + })) + + // Use a cancelable context so we can trigger cleanup by canceling mid-stream. + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + svc := session.InMemoryService() + resp, err := svc.Create(ctx, &session.CreateRequest{AppName: t.Name(), UserID: "test"}) + if err != nil { + t.Fatalf("session.Create() error = %v", err) + } + hello := newUserHello() + if err := svc.AppendEvent(ctx, resp.Session, hello); err != nil { + t.Fatalf("AppendEvent() error = %v", err) + } + ic := icontext.NewInvocationContext(ctx, icontext.InvocationContextParams{ + Session: resp.Session, + RunConfig: &agent.RunConfig{StreamingMode: agent.StreamingModeSSE}, + }) + + // Break out of the run after receiving a couple events to trigger cleanup. + count := 0 + for _, err := range oldAgent.Run(ic) { + if err != nil { + break + } + count++ + if count >= 2 { + cancel() + } + } + + if !cleanupCalled { + t.Error("RemoteTaskCleanupCallback was not called") + } + if cleanupTaskInfo.TaskID == "" { + t.Error("RemoteTaskCleanupCallback received empty TaskID") + } +} + +type mockQueue struct { + events []legacyA2A.Event +} + +func (q *mockQueue) Write(ctx context.Context, event legacyA2A.Event) error { + q.events = append(q.events, event) + return nil +} + +func (q *mockQueue) WriteVersioned(ctx context.Context, event legacyA2A.Event, version legacyA2A.TaskVersion) error { + return q.Write(ctx, event) +} + +func (q *mockQueue) Read(ctx context.Context) (legacyA2A.Event, legacyA2A.TaskVersion, error) { + var v legacyA2A.TaskVersion + return nil, v, nil +} + +func (q *mockQueue) Close() error { return nil } + +type mockV2Executor struct { + events []v2a2a.Event + executeFn func(ctx context.Context, execCtx *v2asrv.ExecutorContext) iter.Seq2[v2a2a.Event, error] +} + +func (e *mockV2Executor) Execute(ctx context.Context, execCtx *v2asrv.ExecutorContext) iter.Seq2[v2a2a.Event, error] { + if e.executeFn != nil { + return e.executeFn(ctx, execCtx) + } + return func(yield func(v2a2a.Event, error) bool) { + for _, ev := range e.events { + if !yield(ev, nil) { + return + } + } + } +} + +func (e *mockV2Executor) Cancel(ctx context.Context, execCtx *v2asrv.ExecutorContext) iter.Seq2[v2a2a.Event, error] { + return func(yield func(v2a2a.Event, error) bool) { + yield(v2a2a.NewStatusUpdateEvent(execCtx, v2a2a.TaskStateCanceled, nil), nil) + } +} + +func startA2AServer(t *testing.T, executor *mockV2Executor) *httptest.Server { + t.Helper() + handler := v2asrv.NewHandler(executor) + server := httptest.NewServer(v2asrv.NewJSONRPCHandler(handler)) + t.Cleanup(server.Close) + return server +} + +func newLegacyCard(serverURL string) *legacyA2A.AgentCard { + return a2av0.FromV1AgentCard(&v2a2a.AgentCard{ + SupportedInterfaces: []*v2a2a.AgentInterface{ + v2a2a.NewAgentInterface(serverURL, v2a2a.TransportProtocolJSONRPC), + }, + Capabilities: v2a2a.AgentCapabilities{Streaming: true}, + }) +} + +func newInvocationContext(t *testing.T, events []*session.Event) agent.InvocationContext { + t.Helper() + ctx := t.Context() + service := session.InMemoryService() + resp, err := service.Create(ctx, &session.CreateRequest{AppName: t.Name(), UserID: "test"}) + if err != nil { + t.Fatalf("sessionService.Create() error = %v", err) + } + for _, event := range events { + if err := service.AppendEvent(ctx, resp.Session, event); err != nil { + t.Fatalf("sessionService.AppendEvent() error = %v", err) + } + } + + ic := icontext.NewInvocationContext(ctx, icontext.InvocationContextParams{ + Session: resp.Session, + RunConfig: &agent.RunConfig{ + StreamingMode: agent.StreamingModeSSE, + }, + }) + return ic +} + +func newUserHello() *session.Event { + event := session.NewEvent("invocation") + event.Author = "user" + event.LLMResponse = model.LLMResponse{ + Content: genai.NewContentFromText("hello", genai.RoleUser), + } + return event +} + +func runAndCollect(ic agent.InvocationContext, agnt agent.Agent) ([]*session.Event, error) { + var collected []*session.Event + for ev, err := range agnt.Run(ic) { + if err != nil { + return collected, err + } + collected = append(collected, ev) + } + return collected, nil +} diff --git a/agent/remoteagent/v2/a2a_agent.go b/agent/remoteagent/v2/a2a_agent.go new file mode 100644 index 000000000..73c2fa5b3 --- /dev/null +++ b/agent/remoteagent/v2/a2a_agent.go @@ -0,0 +1,378 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package remoteagent + +import ( + "context" + "encoding/json" + "fmt" + "iter" + "os" + "strings" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" + "github.com/a2aproject/a2a-go/v2/log" + + "google.golang.org/adk/agent" + agentinternal "google.golang.org/adk/internal/agent" + iremoteagent "google.golang.org/adk/internal/agent/remoteagent" + "google.golang.org/adk/server/adka2a/v2" + "google.golang.org/adk/session" +) + +// BeforeA2ARequestCallback is called before sending a request to the remote agent. +// +// If it returns non-nil result or error, the actual call is skipped and the returned value is used +// as the agent invocation result. +type BeforeA2ARequestCallback func(ctx agent.CallbackContext, req *a2a.SendMessageRequest) (*session.Event, error) + +// A2AEventConverter can be used to provide a custom implementation of A2A event transformation logic. +type A2AEventConverter func(ctx agent.InvocationContext, req *a2a.SendMessageRequest, event a2a.Event, err error) (*session.Event, error) + +// AfterA2ARequestCallback is called after receiving a response from the remote agent and converting it to a session.Event. +// In streaming responses the callback is invoked for every request. Session event parameter might be nil if conversion logic +// decides to not emit an A2A event. +// +// If it returns non-nil result or error, it gets emitted instead of the original result. +type AfterA2ARequestCallback func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, resp *session.Event, err error) (*session.Event, error) + +// A2ARemoteTaskCleanupCallback is called if Run exited before a terminal event was received from the remote A2A server. +type A2ARemoteTaskCleanupCallback func(ctx context.Context, card *a2a.AgentCard, client A2AClient, taskInfo a2a.TaskInfo, cause error) + +// AgentCardProvider resolves an agent card on each agent invocation. +// Use [NewAgentCardProvider] to create a provider from a URL or file path. +// Callers that want lazy/cached resolution should implement caching within the provider function. +type AgentCardProvider func(ctx context.Context) (*a2a.AgentCard, error) + +// NewAgentCardProvider creates an [AgentCardProvider] that resolves an agent card from the given source. +// The source can be an http(s) URL or a local file path. +func NewAgentCardProvider(source string, opts ...agentcard.ResolveOption) AgentCardProvider { + return func(ctx context.Context) (*a2a.AgentCard, error) { + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + card, err := agentcard.DefaultResolver.Resolve(ctx, source, opts...) + if err != nil { + return nil, fmt.Errorf("failed to fetch an agent card: %w", err) + } + return card, nil + } + + fileBytes, err := os.ReadFile(source) + if err != nil { + return nil, fmt.Errorf("failed to read agent card from %q: %w", source, err) + } + + var card a2a.AgentCard + if err := json.Unmarshal(fileBytes, &card); err != nil { + return nil, fmt.Errorf("failed to unmarshal an agent card: %w", err) + } + return &card, nil + } +} + +// A2AConfig is used to describe and configure a remote agent. +type A2AConfig struct { + Name string + Description string + + // AgentCard is a static agent card. Either AgentCard or AgentCardProvider must be set. + AgentCard *a2a.AgentCard + // AgentCardProvider resolves an agent card on each agent invocation. + // Use [NewAgentCardProvider] to create a provider from a URL or file path. + // Either AgentCard or AgentCardProvider must be set. + AgentCardProvider AgentCardProvider + + // BeforeAgentCallbacks is a list of callbacks that are called sequentially + // before the agent starts its run. + // + // If any callback returns non-nil content or error, then the agent run and + // the remaining callbacks will be skipped, and a new event will be created + // from the content or error of that callback. + BeforeAgentCallbacks []agent.BeforeAgentCallback + // BeforeRequestCallbacks will be called in the order they are provided until + // there's a callback that returns a non-nil result or error. Then the + // actual request is skipped, and the returned response/error is used. + // + // This provides an opportunity to inspect, log, or modify the request object. + // It can also be used to implement caching by returning a cached + // response, which would skip the actual remote agent call. + BeforeRequestCallbacks []BeforeA2ARequestCallback + // Converter is used to convert a2a.Event to session.Event. If not provided, adka2a.ToSessionEvent + // is used as the default implementation and errors are converted to events with error payload. + Converter A2AEventConverter + // AfterRequestCallbacks will be called in the order they are provided until + // there's a callback that returns a non-nil result or error. Then + // the actual remote agent event is replaced with the returned result/error. + // + // This is the ideal place to log agent responses, collect metrics on token or perform + // pre-processing of events before a mapper is invoked. + AfterRequestCallbacks []AfterA2ARequestCallback + // AfterAgentCallbacks is a list of callbacks that are called sequentially + // after the agent has completed its run. + // + // If any callback returns non-nil content or error, then a new event will be + // created from the content or error of that callback and the remaining + // callbacks will be skipped. + AfterAgentCallbacks []agent.AfterAgentCallback + + // A2APartConverter is a custom converter for converting A2A parts to GenAI parts. + // Implementations should generally remember to leverage adka2a.ToGenAiPart for default conversions + // nil returns are considered intentionally dropped parts. + A2APartConverter adka2a.A2APartConverter + + // GenAIPartConverter is a custom converter for converting GenAI parts to A2A parts. + // Implementations should generally remember to leverage adka2a.ToA2APart for default conversions + // nil returns are considered intentionally dropped parts. + GenAIPartConverter adka2a.GenAIPartConverter + + // ClientProvider can be used to provide a custom implementation of A2A message sending. + ClientProvider A2AClientProvider + // MessageSendConfig is attached to a2a.SendMessageRequest sent on every agent invocation. + MessageSendConfig *a2a.SendMessageConfig + + // RemoteTaskCleanupCallback is called if Run exited before a terminal event was received from the remote A2A server. + // If Run exited due to an error including context cancellation it will be passed as cause. + // The context passed to this callback is the original context, but with Err() removed by context.WithoutCancel. + // If no callback is provided the default behavior is to make a cancel RPC request with 5 second timeout. + RemoteTaskCleanupCallback A2ARemoteTaskCleanupCallback +} + +// NewA2A creates a remote A2A agent. A2A (Agent-To-Agent) protocol is used for communication with an +// agent which can run in a different process or on a different host. +func NewA2A(cfg A2AConfig) (agent.Agent, error) { + if cfg.AgentCard == nil && cfg.AgentCardProvider == nil { + return nil, fmt.Errorf("either AgentCard or AgentCardProvider must be provided") + } + if cfg.ClientProvider == nil { + cfg.ClientProvider = NewA2AClientProvider(a2aclient.NewFactory()) + } + + remoteAgent := &a2aAgent{ + serverConfig: &iremoteagent.A2AServerConfig{ + AgentCard: cfg.AgentCard, + AgentCardProvider: cfg.AgentCardProvider, + ClientProvider: cfg.ClientProvider, + }, + } + agent, err := agent.New(agent.Config{ + Name: cfg.Name, + Description: cfg.Description, + BeforeAgentCallbacks: cfg.BeforeAgentCallbacks, + AfterAgentCallbacks: cfg.AfterAgentCallbacks, + Run: func(ic agent.InvocationContext) iter.Seq2[*session.Event, error] { + return remoteAgent.run(ic, cfg) + }, + }) + if err != nil { + return nil, err + } + + internalAgent, ok := agent.(agentinternal.Agent) + if !ok { + return nil, fmt.Errorf("internal error: failed to convert to internal agent") + } + state := agentinternal.Reveal(internalAgent) + state.AgentType = agentinternal.TypeRemoteAgent + state.Config = iremoteagent.RemoteAgentState{A2A: remoteAgent.serverConfig} + + return agent, nil +} + +type a2aAgent struct { + serverConfig *iremoteagent.A2AServerConfig +} + +func (a *a2aAgent) run(ctx agent.InvocationContext, cfg A2AConfig) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + card, err := iremoteagent.ResolveAgentCard(ctx, a.serverConfig) + if err != nil { + yield(toErrorEvent(ctx, fmt.Errorf("agent card resolution failed: %w", err)), nil) + return + } + + sender, err := cfg.ClientProvider(ctx, card) + if err != nil { + yield(toErrorEvent(ctx, fmt.Errorf("sender creation failed: %w", err)), nil) + return + } + defer destroy(ctx, sender) + + msg, err := newMessage(ctx, cfg) + if err != nil { + yield(toErrorEvent(ctx, fmt.Errorf("message creation failed: %w", err)), nil) + return + } + + req := &a2a.SendMessageRequest{Message: msg, Config: cfg.MessageSendConfig} + processor := newRunProcessor(cfg, req) + + if bcbResp, bcbErr := processor.runBeforeA2ARequestCallbacks(ctx); bcbResp != nil || bcbErr != nil { + if acbResp, acbErr := processor.runAfterA2ARequestCallbacks(ctx, bcbResp, bcbErr); acbResp != nil || acbErr != nil { + yield(acbResp, acbErr) + } else { + yield(bcbResp, bcbErr) + } + return + } + + if len(msg.Parts) == 0 { + resp := adka2a.NewRemoteAgentEvent(ctx) + if cbResp, cbErr := processor.runAfterA2ARequestCallbacks(ctx, resp, err); cbResp != nil || cbErr != nil { + yield(cbResp, cbErr) + } else { + yield(resp, nil) + } + return + } + + var lastErr error + yieldErr := func(err error) bool { + lastErr = err + return yield(nil, err) + } + + var lastEvent a2a.Event + defer func() { + err := lastErr + if err == nil && ctx.Err() != nil { + err = context.Cause(ctx) + } + cleanupRemoteTask(ctx, cfg, card, sender, lastEvent, err) + }() + + processEvent := func(a2aEvent a2a.Event, a2aErr error) bool { + if a2aEvent != nil { + lastEvent = a2aEvent + } + + var err error + var event *session.Event + if cfg.Converter != nil { + event, err = cfg.Converter(ctx, req, a2aEvent, a2aErr) + } else { + event, err = processor.convertToSessionEvent(ctx, a2aEvent, a2aErr) + } + + if cbResp, cbErr := processor.runAfterA2ARequestCallbacks(ctx, event, err); cbResp != nil || cbErr != nil { + if cbErr != nil { + return yieldErr(cbErr) + } + event = cbResp + err = nil + } + + if err != nil { + return yieldErr(err) + } + + if event != nil { // an event might be skipped + for _, toEmit := range processor.aggregatePartial(ctx, a2aEvent, event) { + if !yield(toEmit, nil) { + return false + } + } + } + return true + } + + if ctx.RunConfig().StreamingMode == agent.StreamingModeNone { + a2aEvent, a2aErr := sender.SendMessage(ctx, req) + processEvent(a2aEvent, a2aErr) + return + } + + for a2aEvent, a2aErr := range sender.SendStreamingMessage(ctx, req) { + if !processEvent(a2aEvent, a2aErr) { + return + } + } + } +} + +func cleanupRemoteTask(ctx context.Context, cfg A2AConfig, card *a2a.AgentCard, client A2AClient, lastEvent a2a.Event, cause error) { + if lastEvent == nil { + return + } + taskID := lastEvent.TaskInfo().TaskID + if taskID == "" { + return + } + if _, ok := lastEvent.(*a2a.Message); ok { + return + } + var state a2a.TaskState + if tu, ok := lastEvent.(*a2a.TaskStatusUpdateEvent); ok { + state = tu.Status.State + } + if t, ok := lastEvent.(*a2a.Task); ok { + state = t.Status.State + } + if state.Terminal() { + return + } + + ctx = context.WithoutCancel(ctx) + + if cfg.RemoteTaskCleanupCallback != nil { + cfg.RemoteTaskCleanupCallback(ctx, card, client, lastEvent.TaskInfo(), cause) + return + } + + if state == a2a.TaskStateInputRequired && cause == nil { + return + } + cancelCtx, cancelTimeout := context.WithTimeout(ctx, 5*time.Second) + defer cancelTimeout() + _, err := client.CancelTask(cancelCtx, &a2a.CancelTaskRequest{ID: taskID}) + if err != nil { + log.Warn(ctx, "failed to cancel task", "task_id", taskID, "error", err) + } +} + +func newMessage(ctx agent.InvocationContext, cfg A2AConfig) (*a2a.Message, error) { + events := ctx.Session().Events() + if userFnCall := getUserFunctionCallAt(events, events.Len()-1); userFnCall != nil { + event := userFnCall.response + parts, err := convertParts(ctx, cfg, event) + if err != nil { + return nil, fmt.Errorf("event part conversion failed: %w", err) + } + msg := a2a.NewMessage(a2a.MessageRoleUser, parts...) + msg.TaskID = a2a.TaskID(userFnCall.taskID) + msg.ContextID = userFnCall.contextID + return msg, nil + } + + parts, contextID := toMissingRemoteSessionParts(ctx, events, cfg) + msg := a2a.NewMessage(a2a.MessageRoleUser, parts...) + msg.ContextID = contextID + return msg, nil +} + +func toErrorEvent(ctx agent.InvocationContext, err error) *session.Event { + event := adka2a.NewRemoteAgentEvent(ctx) + event.ErrorMessage = err.Error() + event.CustomMetadata = map[string]any{adka2a.ToADKMetaKey("error"): err.Error()} + event.TurnComplete = true + return event +} + +func destroy(ctx context.Context, client A2AClient) { + if err := client.Destroy(); err != nil { + log.Warn(ctx, "failed to destroy client", "error", err) + } +} diff --git a/agent/remoteagent/a2a_agent_run_processor.go b/agent/remoteagent/v2/a2a_agent_run_processor.go similarity index 96% rename from agent/remoteagent/a2a_agent_run_processor.go rename to agent/remoteagent/v2/a2a_agent_run_processor.go index 615a3d1ea..dc3b58faa 100644 --- a/agent/remoteagent/a2a_agent_run_processor.go +++ b/agent/remoteagent/v2/a2a_agent_run_processor.go @@ -19,13 +19,13 @@ import ( "maps" "slices" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "google.golang.org/genai" "google.golang.org/adk/agent" icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/internal/converters" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" "google.golang.org/adk/session" ) @@ -40,7 +40,7 @@ type artifactAggregation struct { type a2aAgentRunProcessor struct { config A2AConfig partConverter adka2a.A2APartConverter - request *a2a.MessageSendParams + request *a2a.SendMessageRequest // partial event contents emitted before the terminal event aggregations map[a2a.ArtifactID]*artifactAggregation @@ -48,7 +48,7 @@ type a2aAgentRunProcessor struct { aggregationOrder []a2a.ArtifactID } -func newRunProcessor(config A2AConfig, request *a2a.MessageSendParams) *a2aAgentRunProcessor { +func newRunProcessor(config A2AConfig, request *a2a.SendMessageRequest) *a2aAgentRunProcessor { return &a2aAgentRunProcessor{ config: config, request: request, @@ -68,7 +68,7 @@ func (p *a2aAgentRunProcessor) aggregatePartial(ctx agent.InvocationContext, a2a } // RemoteAgent event stream finished, emit any aggregated events data we have before the final event - if statusUpdate, ok := a2aEvent.(*a2a.TaskStatusUpdateEvent); ok && statusUpdate.Final { + if statusUpdate, ok := a2aEvent.(*a2a.TaskStatusUpdateEvent); ok && statusUpdate.Status.State.Terminal() { var events []*session.Event for _, aid := range p.aggregationOrder { if agg, ok := p.aggregations[aid]; ok { @@ -216,6 +216,9 @@ func (p *a2aAgentRunProcessor) runBeforeA2ARequestCallbacks(ctx agent.Invocation } func (p *a2aAgentRunProcessor) runAfterA2ARequestCallbacks(ctx agent.InvocationContext, resp *session.Event, err error) (*session.Event, error) { + if resp == nil && err == nil { + return nil, nil + } cctx := icontext.NewCallbackContext(ctx) for _, callback := range p.config.AfterRequestCallbacks { if cbEvent, cbErr := callback(cctx, p.request, resp, err); cbEvent != nil || cbErr != nil { diff --git a/agent/remoteagent/a2a_agent_run_processor_test.go b/agent/remoteagent/v2/a2a_agent_run_processor_test.go similarity index 88% rename from agent/remoteagent/a2a_agent_run_processor_test.go rename to agent/remoteagent/v2/a2a_agent_run_processor_test.go index 03f598d97..2e6351c78 100644 --- a/agent/remoteagent/a2a_agent_run_processor_test.go +++ b/agent/remoteagent/v2/a2a_agent_run_processor_test.go @@ -17,7 +17,7 @@ package remoteagent import ( "testing" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genai" @@ -26,7 +26,7 @@ import ( icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" "google.golang.org/adk/session" ) @@ -40,7 +40,7 @@ func TestA2AAgentRunProcessor_aggregatePartial(t *testing.T) { return &a2a.TaskArtifactUpdateEvent{ TaskID: task.ID, ContextID: task.ContextID, - Artifact: &a2a.Artifact{ID: aid, Parts: a2a.ContentParts{a2a.TextPart{Text: text}}}, + Artifact: &a2a.Artifact{ID: aid, Parts: a2a.ContentParts{a2a.NewTextPart(text)}}, LastChunk: flags.lastChunk, Append: flags.append, } @@ -80,9 +80,9 @@ func TestA2AAgentRunProcessor_aggregatePartial(t *testing.T) { { name: "do not aggregate when ADK events", events: []a2a.Event{ - withADKPartial(a2a.NewArtifactUpdateEvent(task, aid1, a2a.TextPart{Text: "Hel"}), true), - withADKPartial(a2a.NewArtifactUpdateEvent(task, aid1, a2a.TextPart{Text: "lo"}), true), - withADKPartial(a2a.NewArtifactUpdateEvent(task, aid1, a2a.TextPart{Text: "Hello"}), false), + withADKPartial(a2a.NewArtifactUpdateEvent(task, aid1, a2a.NewTextPart("Hel")), true), + withADKPartial(a2a.NewArtifactUpdateEvent(task, aid1, a2a.NewTextPart("lo")), true), + withADKPartial(a2a.NewArtifactUpdateEvent(task, aid1, a2a.NewTextPart("Hello")), false), newFinalStatusUpdate(task, a2a.TaskStateCompleted), }, wantEvents: []*session.Event{ @@ -95,10 +95,10 @@ func TestA2AAgentRunProcessor_aggregatePartial(t *testing.T) { { name: "aggregation reset by final snapshot", events: []a2a.Event{ - a2a.NewArtifactUpdateEvent(task, "a1", a2a.TextPart{Text: "ignore me"}), + a2a.NewArtifactUpdateEvent(task, "a1", a2a.NewTextPart("ignore me")), &a2a.Task{ ID: task.ID, - Artifacts: []*a2a.Artifact{{Parts: a2a.ContentParts{a2a.TextPart{Text: "done"}}}}, + Artifacts: []*a2a.Artifact{{Parts: a2a.ContentParts{a2a.NewTextPart("done")}}}, Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, }, }, @@ -110,9 +110,9 @@ func TestA2AAgentRunProcessor_aggregatePartial(t *testing.T) { { name: "aggregation reset by non-final snapshot", events: []a2a.Event{ - a2a.NewArtifactUpdateEvent(task, "a1", a2a.TextPart{Text: "foo"}), + a2a.NewArtifactUpdateEvent(task, "a1", a2a.NewTextPart("foo")), &a2a.Task{ID: task.ID}, - a2a.NewArtifactUpdateEvent(task, "a1", a2a.TextPart{Text: "bar"}), + a2a.NewArtifactUpdateEvent(task, "a1", a2a.NewTextPart("bar")), newFinalStatusUpdate(task, a2a.TaskStateCompleted), }, wantEvents: []*session.Event{ @@ -232,11 +232,12 @@ func TestA2AAgentRunProcessor_aggregatePartial(t *testing.T) { { name: "thoughts aggregation", events: []a2a.Event{ - a2a.NewArtifactUpdateEvent(task, "a1", a2a.TextPart{ - Text: "thinking...", - Metadata: map[string]any{adka2a.ToA2AMetaKey("thought"): true}, - }), - a2a.NewArtifactUpdateEvent(task, "a1", a2a.TextPart{Text: "done"}), + func() *a2a.TaskArtifactUpdateEvent { + p := a2a.NewTextPart("thinking...") + p.SetMeta(adka2a.ToA2AMetaKey("thought"), true) + return a2a.NewArtifactUpdateEvent(task, "a1", p) + }(), + a2a.NewArtifactUpdateEvent(task, "a1", a2a.NewTextPart("done")), newFinalStatusUpdate(task, a2a.TaskStateCompleted), }, wantEvents: []*session.Event{ @@ -255,16 +256,16 @@ func TestA2AAgentRunProcessor_aggregatePartial(t *testing.T) { { name: "interleaved thought and text", events: []a2a.Event{ - a2a.NewArtifactUpdateEvent(task, "a1", a2a.TextPart{ - Text: "thinking1", + a2a.NewArtifactUpdateEvent(task, "a1", &a2a.Part{ + Content: a2a.Text("thinking1"), Metadata: map[string]any{adka2a.ToA2AMetaKey("thought"): true}, }), - a2a.NewArtifactUpdateEvent(task, "a1", a2a.TextPart{Text: "text1"}), - a2a.NewArtifactUpdateEvent(task, "a1", a2a.TextPart{ - Text: "thinking2", + a2a.NewArtifactUpdateEvent(task, "a1", a2a.NewTextPart("text1")), + a2a.NewArtifactUpdateEvent(task, "a1", &a2a.Part{ + Content: a2a.Text("thinking2"), Metadata: map[string]any{adka2a.ToA2AMetaKey("thought"): true}, }), - a2a.NewArtifactUpdateEvent(task, "a1", a2a.TextPart{Text: "text2"}), + a2a.NewArtifactUpdateEvent(task, "a1", a2a.NewTextPart("text2")), newFinalStatusUpdate(task, a2a.TaskStateCompleted), }, wantEvents: []*session.Event{ diff --git a/agent/remoteagent/a2a_agent_test.go b/agent/remoteagent/v2/a2a_agent_test.go similarity index 78% rename from agent/remoteagent/a2a_agent_test.go rename to agent/remoteagent/v2/a2a_agent_test.go index a3f484538..2e5607235 100644 --- a/agent/remoteagent/a2a_agent_test.go +++ b/agent/remoteagent/v2/a2a_agent_test.go @@ -26,10 +26,9 @@ import ( "testing" "time" - "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2aclient" - "github.com/a2aproject/a2a-go/a2asrv" - "github.com/a2aproject/a2a-go/a2asrv/eventqueue" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genai" @@ -39,35 +38,37 @@ import ( "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" "google.golang.org/adk/runner" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" "google.golang.org/adk/session" ) type mockA2AExecutor struct { - executeFn func(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error - cancelFn func(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error - cleanupFn func(ctx context.Context, reqCtx *a2asrv.RequestContext, result a2a.SendMessageResult, cause error) + executeFn func(ctx context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] + cancelFn func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] + cleanupFn func(ctx context.Context, reqCtx *a2asrv.ExecutorContext, result a2a.SendMessageResult, cause error) } var _ a2asrv.AgentExecutor = (*mockA2AExecutor)(nil) -func (e *mockA2AExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { +func (e *mockA2AExecutor) Execute(ctx context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { if e.executeFn != nil { - return e.executeFn(ctx, reqCtx, queue) + return e.executeFn(ctx, execCtx) + } + return func(yield func(a2a.Event, error) bool) { + yield(nil, fmt.Errorf("not implemented")) } - return fmt.Errorf("not implemented") } -func (e *mockA2AExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { +func (e *mockA2AExecutor) Cancel(ctx context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { if e.cancelFn != nil { - return e.cancelFn(ctx, reqCtx, queue) + return e.cancelFn(ctx, execCtx) + } + return func(yield func(a2a.Event, error) bool) { + yield(a2a.NewStatusUpdateEvent(execCtx, a2a.TaskStateCanceled, nil), fmt.Errorf("not implemented")) } - ev := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCanceled, nil) - ev.Final = true - return queue.Write(ctx, ev) } -func (e *mockA2AExecutor) Cleanup(ctx context.Context, reqCtx *a2asrv.RequestContext, result a2a.SendMessageResult, cause error) { +func (e *mockA2AExecutor) Cleanup(ctx context.Context, reqCtx *a2asrv.ExecutorContext, result a2a.SendMessageResult, cause error) { if e.cleanupFn != nil { e.cleanupFn(ctx, reqCtx, result, cause) } @@ -88,7 +89,7 @@ func startA2AServer(agentExecutor a2asrv.AgentExecutor) *testA2AServer { func newA2ARemoteAgent(t *testing.T, name string, server *testA2AServer) agent.Agent { t.Helper() - card := &a2a.AgentCard{PreferredTransport: a2a.TransportProtocolJSONRPC, URL: server.URL, Capabilities: a2a.AgentCapabilities{Streaming: true}} + card := &a2a.AgentCard{SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface(server.URL, a2a.TransportProtocolJSONRPC)}, Capabilities: a2a.AgentCapabilities{Streaming: true}} return utils.Must(NewA2A(A2AConfig{AgentCard: card, Name: name})) } @@ -168,28 +169,38 @@ func newADKEventReplay(t *testing.T, name string, events []*session.Event) agent func newA2AEventReplay(t *testing.T, events []a2a.Event) a2asrv.AgentExecutor { return &mockA2AExecutor{ - executeFn: func(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - for _, ev := range events { - // A2A stack is going to fail the request if events don't have correct taskID and contextID - switch v := ev.(type) { - case *a2a.Message: - v.TaskID = reqCtx.TaskID - v.ContextID = reqCtx.ContextID - case *a2a.Task: - v.ID = reqCtx.TaskID - v.ContextID = reqCtx.ContextID - case *a2a.TaskStatusUpdateEvent: - v.TaskID = reqCtx.TaskID - v.ContextID = reqCtx.ContextID - case *a2a.TaskArtifactUpdateEvent: - v.TaskID = reqCtx.TaskID - v.ContextID = reqCtx.ContextID + executeFn: func(ctx context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + if len(events) > 0 { + if _, ok := events[0].(*a2a.Task); !ok { + if _, ok := events[0].(*a2a.Message); !ok { + if !yield(a2a.NewSubmittedTask(execCtx, execCtx.Message), nil) { + return + } + } + } } - if err := queue.Write(ctx, ev); err != nil { - t.Errorf("queue.Write() error = %v", err) + for _, ev := range events { + // A2A stack is going to fail the request if events don't have correct taskID and contextID + switch v := ev.(type) { + case *a2a.Message: + v.TaskID = execCtx.TaskID + v.ContextID = execCtx.ContextID + case *a2a.Task: + v.ID = execCtx.TaskID + v.ContextID = execCtx.ContextID + case *a2a.TaskStatusUpdateEvent: + v.TaskID = execCtx.TaskID + v.ContextID = execCtx.ContextID + case *a2a.TaskArtifactUpdateEvent: + v.TaskID = execCtx.TaskID + v.ContextID = execCtx.ContextID + } + if !yield(ev, nil) { + return + } } } - return nil }, } } @@ -201,12 +212,11 @@ func newUserHello() *session.Event { return event } -func newFinalStatusUpdate(task *a2a.Task, state a2a.TaskState, msgParts ...a2a.Part) *a2a.TaskStatusUpdateEvent { +func newFinalStatusUpdate(task *a2a.Task, state a2a.TaskState, msgParts ...*a2a.Part) *a2a.TaskStatusUpdateEvent { event := a2a.NewStatusUpdateEvent(task, state, nil) if len(msgParts) > 0 { event.Status.Message = a2a.NewMessageForTask(a2a.MessageRoleAgent, task, msgParts...) } - event.Final = true return event } @@ -430,6 +440,11 @@ func TestRemoteAgent_ADK2ADK(t *testing.T) { func TestRemoteAgent_ADK2A2A(t *testing.T) { task := &a2a.Task{ID: a2a.NewTaskID(), ContextID: a2a.NewContextID()} artifactEvent := a2a.NewArtifactEvent(task) + newArtifactEvent := func(parts ...*a2a.Part) *a2a.TaskArtifactUpdateEvent { + event := a2a.NewArtifactUpdateEvent(task, artifactEvent.Artifact.ID, parts...) + event.Append = false + return event + } testCases := []struct { name string @@ -444,7 +459,7 @@ func TestRemoteAgent_ADK2A2A(t *testing.T) { { name: "message", remoteEvents: []a2a.Event{ - a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "hello"}, a2a.TextPart{Text: "world"}), + a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("hello"), a2a.NewTextPart("world")), }, wantResponses: []model.LLMResponse{ { @@ -468,7 +483,7 @@ func TestRemoteAgent_ADK2A2A(t *testing.T) { remoteEvents: []a2a.Event{ &a2a.Task{Status: a2a.TaskStatus{ State: a2a.TaskStateCompleted, - Message: a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "hello"}), + Message: a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("hello")), }}, }, wantResponses: []model.LLMResponse{{ @@ -482,7 +497,7 @@ func TestRemoteAgent_ADK2A2A(t *testing.T) { &a2a.Task{ Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, Artifacts: []*a2a.Artifact{ - {Parts: a2a.ContentParts{a2a.TextPart{Text: "hello"}, a2a.TextPart{Text: "world"}}}, + {Parts: a2a.ContentParts{a2a.NewTextPart("hello"), a2a.NewTextPart("world")}}, }, }, }, @@ -501,12 +516,12 @@ func TestRemoteAgent_ADK2A2A(t *testing.T) { remoteEvents: []a2a.Event{ &a2a.Task{Status: a2a.TaskStatus{ State: a2a.TaskStateWorking, - Message: a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "hello"}), + Message: a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("hello")), }}, &a2a.Task{ Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, Artifacts: []*a2a.Artifact{ - {Parts: a2a.ContentParts{a2a.TextPart{Text: "world"}}}, + {Parts: a2a.ContentParts{a2a.NewTextPart("world")}}, }, }, }, @@ -521,8 +536,8 @@ func TestRemoteAgent_ADK2A2A(t *testing.T) { &a2a.Task{ Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, Artifacts: []*a2a.Artifact{ - {Parts: a2a.ContentParts{a2a.TextPart{Text: "hello"}}}, - {Parts: a2a.ContentParts{a2a.TextPart{Text: "world"}}}, + {Parts: a2a.ContentParts{a2a.NewTextPart("hello")}}, + {Parts: a2a.ContentParts{a2a.NewTextPart("world")}}, }, }, }, @@ -539,9 +554,8 @@ func TestRemoteAgent_ADK2A2A(t *testing.T) { { name: "artifact parts translation", remoteEvents: []a2a.Event{ - artifactEvent, - a2a.NewArtifactUpdateEvent(task, artifactEvent.Artifact.ID, a2a.TextPart{Text: "hello"}), - a2a.NewArtifactUpdateEvent(task, artifactEvent.Artifact.ID, a2a.TextPart{Text: "world"}), + newArtifactEvent(a2a.NewTextPart("hello")), + a2a.NewArtifactUpdateEvent(task, artifactEvent.Artifact.ID, a2a.NewTextPart("world")), newFinalStatusUpdate(task, a2a.TaskStateCompleted), }, wantResponses: []model.LLMResponse{ @@ -554,9 +568,9 @@ func TestRemoteAgent_ADK2A2A(t *testing.T) { { name: "non-final status update messages as thoughts", remoteEvents: []a2a.Event{ - a2a.NewStatusUpdateEvent(task, a2a.TaskStateSubmitted, a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "submitted...\n"})), - a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "working...\n"})), - newFinalStatusUpdate(task, a2a.TaskStateCompleted, a2a.TextPart{Text: "completed!"}), + a2a.NewStatusUpdateEvent(task, a2a.TaskStateSubmitted, a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("submitted...\n"))), + a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("working...\n"))), + newFinalStatusUpdate(task, a2a.TaskStateCompleted, a2a.NewTextPart("completed!")), }, wantResponses: []model.LLMResponse{ {Content: &genai.Content{Parts: []*genai.Part{{Text: "submitted...\n", Thought: true}}, Role: genai.RoleModel}, Partial: true}, @@ -578,32 +592,27 @@ func TestRemoteAgent_ADK2A2A(t *testing.T) { { name: "partial and non-partial event aggregation", remoteEvents: []a2a.Event{ - artifactEvent, - &a2a.TaskArtifactUpdateEvent{ - TaskID: task.ID, - ContextID: task.ContextID, - Artifact: &a2a.Artifact{ID: artifactEvent.Artifact.ID, Parts: a2a.ContentParts{a2a.TextPart{Text: "1"}}}, - Append: true, - }, + newArtifactEvent(a2a.NewTextPart("1")), &a2a.TaskArtifactUpdateEvent{ TaskID: task.ID, ContextID: task.ContextID, - Artifact: &a2a.Artifact{ID: artifactEvent.Artifact.ID, Parts: a2a.ContentParts{a2a.TextPart{Text: "2"}}}, + Artifact: &a2a.Artifact{ID: artifactEvent.Artifact.ID, Parts: a2a.ContentParts{a2a.NewTextPart("2")}}, Append: true, }, + &a2a.TaskArtifactUpdateEvent{ TaskID: task.ID, ContextID: task.ContextID, - Artifact: &a2a.Artifact{ID: artifactEvent.Artifact.ID, Parts: a2a.ContentParts{a2a.TextPart{Text: "3"}}}, + Artifact: &a2a.Artifact{ID: artifactEvent.Artifact.ID, Parts: a2a.ContentParts{a2a.NewTextPart("3")}}, Append: false, }, &a2a.TaskArtifactUpdateEvent{ TaskID: task.ID, ContextID: task.ContextID, - Artifact: &a2a.Artifact{ID: artifactEvent.Artifact.ID, Parts: a2a.ContentParts{a2a.TextPart{Text: "4"}}}, + Artifact: &a2a.Artifact{ID: artifactEvent.Artifact.ID, Parts: a2a.ContentParts{a2a.NewTextPart("4")}}, Append: true, }, - newFinalStatusUpdate(task, a2a.TaskStateCompleted, a2a.TextPart{Text: "5"}), + newFinalStatusUpdate(task, a2a.TaskStateCompleted, a2a.NewTextPart("5")), }, wantResponses: []model.LLMResponse{ {Content: genai.NewContentFromText("1", genai.RoleModel), Partial: true}, @@ -654,7 +663,7 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { testCases := []struct { name string sessionEvents []*session.Event - events func(*a2asrv.RequestContext) []a2a.Event + events func(*a2asrv.ExecutorContext) []a2a.Event before []BeforeA2ARequestCallback after []AfterA2ARequestCallback converter A2AEventConverter @@ -663,17 +672,17 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { }{ { name: "request and response modification", - events: func(rc *a2asrv.RequestContext) []a2a.Event { - return []a2a.Event{a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "foo"})} + events: func(rc *a2asrv.ExecutorContext) []a2a.Event { + return []a2a.Event{a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("foo"))} }, before: []BeforeA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest) (*session.Event, error) { req.Metadata = map[string]any{"counter": 1} return nil, nil }, }, after: []AfterA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams, result *session.Event, err error) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, result *session.Event, err error) (*session.Event, error) { result.Content = genai.NewContentFromText(result.Content.Parts[0].Text+"bar", genai.RoleModel) result.CustomMetadata = req.Metadata return nil, nil @@ -689,18 +698,17 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { }, { name: "after invoked for every event", - events: func(rc *a2asrv.RequestContext) []a2a.Event { - artifactEvent := a2a.NewArtifactEvent(rc, a2a.TextPart{Text: "Hello"}) - finalEvent := a2a.NewStatusUpdateEvent(rc, a2a.TaskStateCompleted, nil) - finalEvent.Final = true + events: func(rc *a2asrv.ExecutorContext) []a2a.Event { + artifactEvent := a2a.NewArtifactEvent(rc, a2a.NewTextPart("Hello")) return []a2a.Event{ + a2a.NewSubmittedTask(rc, a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("..."))), artifactEvent, - a2a.NewArtifactUpdateEvent(rc, artifactEvent.Artifact.ID, a2a.TextPart{Text: ", world!"}), - finalEvent, + a2a.NewArtifactUpdateEvent(rc, artifactEvent.Artifact.ID, a2a.NewTextPart(", world!")), + a2a.NewStatusUpdateEvent(rc, a2a.TaskStateCompleted, nil), } }, after: []AfterA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams, result *session.Event, err error) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, result *session.Event, err error) (*session.Event, error) { result.CustomMetadata = map[string]any{"foo": "bar"} return nil, nil }, @@ -728,16 +736,15 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { }, { name: "after error stops the run", - events: func(rc *a2asrv.RequestContext) []a2a.Event { + events: func(rc *a2asrv.ExecutorContext) []a2a.Event { finalEvent := a2a.NewStatusUpdateEvent(rc, a2a.TaskStateCompleted, nil) - finalEvent.Final = true return []a2a.Event{ - a2a.NewArtifactEvent(rc, a2a.TextPart{Text: "Hello"}), + a2a.NewArtifactEvent(rc, a2a.NewTextPart("Hello")), finalEvent, } }, after: []AfterA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams, result *session.Event, err error) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, result *session.Event, err error) (*session.Event, error) { return nil, fmt.Errorf("rejected") }, }, @@ -746,7 +753,7 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { { name: "request overwrite with response", before: []BeforeA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest) (*session.Event, error) { return &session.Event{LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("hello", genai.RoleModel)}}, nil }, }, @@ -755,7 +762,7 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { { name: "request overwrite with error", before: []BeforeA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest) (*session.Event, error) { return nil, fmt.Errorf("failed") }, }, @@ -764,7 +771,7 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { { name: "response overwrite", after: []AfterA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams, result *session.Event, err error) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, result *session.Event, err error) (*session.Event, error) { return &session.Event{LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("hello", genai.RoleModel)}}, nil }, }, @@ -773,7 +780,7 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { { name: "response overwrite with error", after: []AfterA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams, result *session.Event, err error) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, result *session.Event, err error) (*session.Event, error) { return nil, fmt.Errorf("failed") }, }, @@ -782,10 +789,10 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { { name: "before interceptor short-circuit", before: []BeforeA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest) (*session.Event, error) { return nil, fmt.Errorf("failed") }, - func(ctx agent.CallbackContext, req *a2a.MessageSendParams) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest) (*session.Event, error) { t.Fatalf("not called") return nil, nil }, @@ -795,10 +802,10 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { { name: "after interceptor short-circuit", after: []AfterA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams, result *session.Event, err error) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, result *session.Event, err error) (*session.Event, error) { return nil, fmt.Errorf("failed") }, - func(ctx agent.CallbackContext, req *a2a.MessageSendParams, result *session.Event, err error) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, result *session.Event, err error) (*session.Event, error) { t.Fatalf("not called") return nil, nil }, @@ -809,7 +816,7 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { name: "after interceptor for empty session", sessionEvents: []*session.Event{}, after: []AfterA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams, result *session.Event, err error) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, result *session.Event, err error) (*session.Event, error) { if len(req.Message.Parts) != 0 { t.Fatalf("got %d parts, expected empty message", len(req.Message.Parts)) } @@ -820,14 +827,14 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { }, { name: "converter error", - converter: func(ctx agent.InvocationContext, req *a2a.MessageSendParams, event a2a.Event, err error) (*session.Event, error) { + converter: func(ctx agent.InvocationContext, req *a2a.SendMessageRequest, event a2a.Event, err error) (*session.Event, error) { return nil, fmt.Errorf("failed") }, wantErr: fmt.Errorf("failed"), }, { name: "converter custom response", - converter: func(ctx agent.InvocationContext, req *a2a.MessageSendParams, event a2a.Event, err error) (*session.Event, error) { + converter: func(ctx agent.InvocationContext, req *a2a.SendMessageRequest, event a2a.Event, err error) (*session.Event, error) { return &session.Event{LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("hello", genai.RoleModel)}}, nil }, wantResponses: []model.LLMResponse{{Content: genai.NewContentFromText("hello", genai.RoleModel)}}, @@ -835,12 +842,12 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { { name: "after interceptor invoked with before result", before: []BeforeA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest) (*session.Event, error) { return nil, fmt.Errorf("before error") }, }, after: []AfterA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams, result *session.Event, err error) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, result *session.Event, err error) (*session.Event, error) { return nil, fmt.Errorf("after error") }, }, @@ -851,20 +858,27 @@ func TestRemoteAgent_RequestCallbacks(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { executor := &mockA2AExecutor{ - executeFn: func(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - if tc.events != nil { - for _, event := range tc.events(reqCtx) { - if err := queue.Write(ctx, event); err != nil { - return err + executeFn: func(ctx context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + if tc.events != nil { + for _, event := range tc.events(execCtx) { + if !yield(event, nil) { + return + } } + return } - return nil + yield(a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("Hi!")), nil) } - return queue.Write(ctx, a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "Hi!"})) }, } server := startA2AServer(executor) - card := &a2a.AgentCard{PreferredTransport: a2a.TransportProtocolJSONRPC, URL: server.URL, Capabilities: a2a.AgentCapabilities{Streaming: true}} + card := &a2a.AgentCard{ + SupportedInterfaces: []*a2a.AgentInterface{ + a2a.NewAgentInterface(server.URL, a2a.TransportProtocolJSONRPC), + }, + Capabilities: a2a.AgentCapabilities{Streaming: true}, + } remoteAgent, err := NewA2A(A2AConfig{ Name: "a2a", AgentCard: card, @@ -901,15 +915,15 @@ func TestRemoteAgent_RequestPayload(t *testing.T) { testCases := []struct { name string sessionEvents []*session.Event - wantRequest *a2a.MessageSendParams + wantRequest *a2a.SendMessageRequest }{ { name: "only user message", sessionEvents: []*session.Event{newUserHello()}, - wantRequest: &a2a.MessageSendParams{ + wantRequest: &a2a.SendMessageRequest{ Message: &a2a.Message{ Role: a2a.MessageRoleUser, - Parts: []a2a.Part{a2a.TextPart{Text: "hello"}}, + Parts: a2a.ContentParts{a2a.NewTextPart("hello")}, }, }, }, @@ -930,14 +944,14 @@ func TestRemoteAgent_RequestPayload(t *testing.T) { }, }, }, - wantRequest: &a2a.MessageSendParams{ + wantRequest: &a2a.SendMessageRequest{ Message: &a2a.Message{ Role: a2a.MessageRoleUser, - Parts: []a2a.Part{ - a2a.TextPart{Text: "hello"}, - a2a.TextPart{Text: "For context:"}, - a2a.TextPart{Text: fmt.Sprintf("[%s] said: hi", notRemoteAgentName)}, - a2a.TextPart{Text: "how are you?"}, + Parts: a2a.ContentParts{ + a2a.NewTextPart("hello"), + a2a.NewTextPart("For context:"), + a2a.NewTextPart(fmt.Sprintf("[%s] said: hi", notRemoteAgentName)), + a2a.NewTextPart("how are you?"), }, }, }, @@ -959,14 +973,14 @@ func TestRemoteAgent_RequestPayload(t *testing.T) { {Author: "user", LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("msg3", genai.RoleUser)}}, {Author: notRemoteAgentName, LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("resp3", genai.RoleModel)}}, }, - wantRequest: &a2a.MessageSendParams{ + wantRequest: &a2a.SendMessageRequest{ Message: &a2a.Message{ Role: a2a.MessageRoleUser, ContextID: "ctx-123", - Parts: []a2a.Part{ - a2a.TextPart{Text: "msg3"}, - a2a.TextPart{Text: "For context:"}, - a2a.TextPart{Text: fmt.Sprintf("[%s] said: resp3", notRemoteAgentName)}, + Parts: a2a.ContentParts{ + a2a.NewTextPart("msg3"), + a2a.NewTextPart("For context:"), + a2a.NewTextPart(fmt.Sprintf("[%s] said: resp3", notRemoteAgentName)), }, }, }, @@ -1004,21 +1018,22 @@ func TestRemoteAgent_RequestPayload(t *testing.T) { }, }, }, - wantRequest: &a2a.MessageSendParams{ + wantRequest: &a2a.SendMessageRequest{ Message: &a2a.Message{ Role: a2a.MessageRoleUser, TaskID: "task-1", ContextID: "ctx-1", - Parts: []a2a.Part{ - a2a.TextPart{Text: "lgtm:"}, - a2a.DataPart{ - Data: map[string]any{ + Parts: a2a.ContentParts{ + a2a.NewTextPart("lgtm:"), + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{ "id": "call-1", "name": "fn", "response": map[string]any{"status": "approved"}, - }, - Metadata: map[string]any{"adk_type": "function_response"}, - }, + }) + p.SetMeta(adka2a.ToA2AMetaKey("type"), "function_response") + return p + }(), }, }, }, @@ -1026,19 +1041,19 @@ func TestRemoteAgent_RequestPayload(t *testing.T) { } server := startA2AServer(newA2AEventReplay(t, []a2a.Event{})) - card := &a2a.AgentCard{PreferredTransport: a2a.TransportProtocolJSONRPC, URL: server.URL, Capabilities: a2a.AgentCapabilities{Streaming: true}} + card := &a2a.AgentCard{SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface(server.URL, a2a.TransportProtocolJSONRPC)}, Capabilities: a2a.AgentCapabilities{Streaming: true}} for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() errRejected := errors.New("rejected") - var gotRequest *a2a.MessageSendParams + var gotRequest *a2a.SendMessageRequest remoteAgent, err := NewA2A(A2AConfig{ Name: remoteAgentName, AgentCard: card, BeforeRequestCallbacks: []BeforeA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest) (*session.Event, error) { gotRequest = req return nil, errRejected }, @@ -1067,7 +1082,7 @@ func TestRemoteAgent_EmptyResultForEmptySession(t *testing.T) { ictx := newInvocationContext(t, []*session.Event{}) executor := newA2AEventReplay(t, []a2a.Event{ - a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "will not be invoked, because input is empty"}), + a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("will not be invoked, because input is empty")), }) agentName := "a2a agent" @@ -1094,7 +1109,7 @@ func TestRemoteAgent_EmptyResultForEmptySession(t *testing.T) { } func TestRemoteAgent_ResolvesAgentCard(t *testing.T) { - remoteEvents := []a2a.Event{a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "Hello!"})} + remoteEvents := []a2a.Event{a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("Hello!"))} wantResponses := []model.LLMResponse{{Content: genai.NewContentFromText("Hello!", genai.RoleModel), TurnComplete: true}} executor := newA2AEventReplay(t, remoteEvents) @@ -1105,14 +1120,14 @@ func TestRemoteAgent_ResolvesAgentCard(t *testing.T) { mux.Handle("/invoke", a2asrv.NewJSONRPCHandler(handler)) mux.HandleFunc("/.well-known/agent-card.json", func(w http.ResponseWriter, r *http.Request) { url := fmt.Sprintf("%s/invoke", cardServer.URL) - card := &a2a.AgentCard{PreferredTransport: a2a.TransportProtocolJSONRPC, URL: url, Capabilities: a2a.AgentCapabilities{Streaming: true}} + card := &a2a.AgentCard{SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface(url, a2a.TransportProtocolJSONRPC)}, Capabilities: a2a.AgentCapabilities{Streaming: true}} if err := json.NewEncoder(w).Encode(card); err != nil { t.Errorf("json.Encode(agentCard) error = %v", err) } }) cardServer = httptest.NewServer(mux) - remoteAgent, err := NewA2A(A2AConfig{Name: "a2a", AgentCardSource: cardServer.URL}) + remoteAgent, err := NewA2A(A2AConfig{Name: "a2a", AgentCardProvider: NewAgentCardProvider(cardServer.URL)}) if err != nil { t.Fatalf("remoteagent.NewA2A() error = %v", err) } @@ -1133,17 +1148,23 @@ func TestRemoteAgent_ResolvesAgentCard(t *testing.T) { } func TestRemoteAgent_ErrorEventIfNoCompatibleTransport(t *testing.T) { - remoteEvents := []a2a.Event{a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "will not be invoked!"})} + remoteEvents := []a2a.Event{a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("will not be invoked!"))} executor := newA2AEventReplay(t, remoteEvents) server := startA2AServer(executor) - remoteAgent, err := NewA2A(A2AConfig{ - Name: "a2a", - ClientFactory: a2aclient.NewFactory(a2aclient.WithDefaultsDisabled()), - AgentCard: &a2a.AgentCard{ - PreferredTransport: a2a.TransportProtocolJSONRPC, - URL: server.URL, + name := "a2a" + cardResource := &a2a.AgentCard{ + SupportedInterfaces: []*a2a.AgentInterface{ + a2a.NewAgentInterface(server.URL, a2a.TransportProtocolJSONRPC), }, + Name: name, + } + remoteAgent, err := NewA2A(A2AConfig{ + Name: name, + AgentCard: cardResource, + ClientProvider: NewA2AClientProvider( + a2aclient.NewFactory(a2aclient.WithDefaultsDisabled()), + ), }) if err != nil { t.Fatalf("remoteagent.NewA2A() error = %v", err) @@ -1166,11 +1187,12 @@ func TestRemoteAgent_ErrorEventIfNoCompatibleTransport(t *testing.T) { func TestRemoteAgent_ErrorEventOnServerError(t *testing.T) { executorErr := fmt.Errorf("mockExecutor failed") executor := &mockA2AExecutor{ - executeFn: func(ctx context.Context, reqCtx *a2asrv.RequestContext, q eventqueue.Queue) error { - return executorErr + executeFn: func(ctx context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + yield(nil, executorErr) + } }, } - remoteAgent := newA2ARemoteAgent(t, "a2a agent", startA2AServer(executor)) ictx := newInvocationContext(t, []*session.Event{newUserHello()}) @@ -1188,16 +1210,16 @@ func TestRemoteAgent_ErrorEventOnServerError(t *testing.T) { } func TestRemoteAgent_CustomConverters(t *testing.T) { - originalA2APart := a2a.TextPart{Text: "hello"} - customA2APart := a2a.TextPart{Text: "modified"} - mockGenAIPartConverter := func(ctx context.Context, event *session.Event, part *genai.Part) (a2a.Part, error) { + originalA2APart := a2a.NewTextPart("hello") + customA2APart := a2a.NewTextPart("modified") + mockGenAIPartConverter := func(ctx context.Context, event *session.Event, part *genai.Part) (*a2a.Part, error) { return customA2APart, nil } tests := []struct { name string cfg A2AConfig - want a2a.Part + want *a2a.Part }{ { name: "custom converter", @@ -1219,7 +1241,7 @@ func TestRemoteAgent_CustomConverters(t *testing.T) { if len(msg.Parts) != 1 { t.Fatalf("len(msg.Parts) = %d, want 1", len(msg.Parts)) } - if textPart, ok := msg.Parts[0].(a2a.TextPart); !ok || textPart.Text != tc.want.(a2a.TextPart).Text { + if msg.Parts[0].Text() != tc.want.Text() { t.Fatalf("msg.Parts[0] = %+v, want %+v", msg.Parts[0], tc.want) } } @@ -1228,7 +1250,7 @@ func TestRemoteAgent_CustomConverters(t *testing.T) { func TestRemoteAgent_CleanupCallback(t *testing.T) { testCases := []struct { name string - events func(*a2asrv.RequestContext) []a2a.Event + events func(*a2asrv.ExecutorContext) []a2a.Event afterRequestCallbacks []AfterA2ARequestCallback eventConverter A2AEventConverter breakAfter int @@ -1238,7 +1260,7 @@ func TestRemoteAgent_CleanupCallback(t *testing.T) { { name: "after request callback error", afterRequestCallbacks: []AfterA2ARequestCallback{ - func(ctx agent.CallbackContext, req *a2a.MessageSendParams, resp *session.Event, err error) (*session.Event, error) { + func(ctx agent.CallbackContext, req *a2a.SendMessageRequest, resp *session.Event, err error) (*session.Event, error) { return nil, fmt.Errorf("callback error") }, }, @@ -1246,7 +1268,7 @@ func TestRemoteAgent_CleanupCallback(t *testing.T) { }, { name: "part converter error", - eventConverter: func(ctx agent.InvocationContext, req *a2a.MessageSendParams, event a2a.Event, err error) (*session.Event, error) { + eventConverter: func(ctx agent.InvocationContext, req *a2a.SendMessageRequest, event a2a.Event, err error) (*session.Event, error) { if _, ok := event.(*a2a.TaskArtifactUpdateEvent); ok { return nil, fmt.Errorf("converter error") } @@ -1273,38 +1295,46 @@ func TestRemoteAgent_CleanupCallback(t *testing.T) { cleanupTaskID a2a.TaskID cleanupCause error ) - cleanupCallback := func(ctx context.Context, card *a2a.AgentCard, client *a2aclient.Client, task a2a.TaskInfo, cause error) { + cleanupCallback := func(ctx context.Context, card *a2a.AgentCard, client A2AClient, task a2a.TaskInfo, cause error) { cleanupCalled = true cleanupTaskID = task.TaskID cleanupCause = cause - if _, err := client.CancelTask(ctx, &a2a.TaskIDParams{ID: task.TaskID}); err != nil { + if _, err := client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: task.TaskID}); err != nil { t.Errorf("client.CancelTask() error = %v", err) } } remoteTaskIDChan := make(chan a2a.TaskID, 1) executor := &mockA2AExecutor{ - executeFn: func(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - remoteTaskIDChan <- reqCtx.TaskID - if err := queue.Write(ctx, a2a.NewSubmittedTask(reqCtx, reqCtx.Message)); err != nil { - return err + cancelFn: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + yield(a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCanceled, nil), nil) } - for ctx.Err() == nil { - data := a2a.DataPart{Data: map[string]any{"foo": "bar"}} - if err := queue.Write(ctx, a2a.NewArtifactEvent(reqCtx, data)); err != nil { - return err + }, + executeFn: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + remoteTaskIDChan <- reqCtx.TaskID + if !yield(a2a.NewSubmittedTask(reqCtx, reqCtx.Message), nil) { + return + } + for ctx.Err() == nil { + data := a2a.NewDataPart(map[string]any{"foo": "bar"}) + if !yield(a2a.NewArtifactEvent(reqCtx, data), nil) { + return + } + time.Sleep(1 * time.Millisecond) } - time.Sleep(1 * time.Millisecond) + yield(a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCompleted, nil), nil) } - finalUpdate := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCompleted, nil) - finalUpdate.Final = true - return queue.Write(ctx, finalUpdate) }, } server := startA2AServer(executor) defer server.Close() - card := &a2a.AgentCard{PreferredTransport: a2a.TransportProtocolJSONRPC, URL: server.URL, Capabilities: a2a.AgentCapabilities{Streaming: true}} + card := &a2a.AgentCard{ + SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface(server.URL, a2a.TransportProtocolJSONRPC)}, + Capabilities: a2a.AgentCapabilities{Streaming: true}, + } remoteAgent, err := NewA2A(A2AConfig{ Name: "a2a", AgentCard: card, @@ -1356,7 +1386,7 @@ func TestRemoteAgent_CleanupCallback(t *testing.T) { } client := newA2AClient(t, server) - task, err := client.GetTask(t.Context(), &a2a.TaskQueryParams{ID: expectedTaskID}) + task, err := client.GetTask(t.Context(), &a2a.GetTaskRequest{ID: expectedTaskID}) if err != nil { t.Fatalf("client.CancelTask() error = %v", err) } @@ -1376,11 +1406,11 @@ func TestRemoteAgent_PartConverter(t *testing.T) { } cfg := A2AConfig{ - GenAIPartConverter: func(ctx context.Context, event *session.Event, p *genai.Part) (a2a.Part, error) { + GenAIPartConverter: func(ctx context.Context, event *session.Event, p *genai.Part) (*a2a.Part, error) { if p.Text == "DROP" { return nil, nil } - return a2a.TextPart{Text: p.Text}, nil + return a2a.NewTextPart(p.Text), nil }, } @@ -1400,8 +1430,8 @@ func TestRemoteAgent_PartConverter(t *testing.T) { t.Fatalf("got nil part, want it filtered out.") } - if tp, ok := p.(a2a.TextPart); ok && tp.Text != "KEEP" { - t.Errorf("got %s, want 'KEEP'", tp.Text) + if p.Text() != "KEEP" { + t.Errorf("got %s, want 'KEEP'", p.Text()) } } } diff --git a/agent/remoteagent/a2a_e2e_test.go b/agent/remoteagent/v2/a2a_e2e_test.go similarity index 86% rename from agent/remoteagent/a2a_e2e_test.go rename to agent/remoteagent/v2/a2a_e2e_test.go index 4799bd60d..32754a190 100644 --- a/agent/remoteagent/a2a_e2e_test.go +++ b/agent/remoteagent/v2/a2a_e2e_test.go @@ -29,10 +29,9 @@ import ( "testing" "time" - "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2aclient" - "github.com/a2aproject/a2a-go/a2asrv" - "github.com/a2aproject/a2a-go/a2asrv/eventqueue" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genai" @@ -47,7 +46,7 @@ import ( "google.golang.org/adk/model" "google.golang.org/adk/model/gemini" "google.golang.org/adk/runner" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" "google.golang.org/adk/session" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" @@ -94,10 +93,10 @@ func TestA2AInputRequired(t *testing.T) { return createLongRunningToolApproval(t, pendingResponse) }, wantFirstArtifactParts: a2a.ContentParts{ - a2a.TextPart{Text: modelTextRequiresApproval}, - a2a.TextPart{Text: modelTextWaitingForApproval}, + a2a.NewTextPart(modelTextRequiresApproval), + a2a.NewTextPart(modelTextWaitingForApproval), }, - wantSecondArtifactParts: a2a.ContentParts{a2a.TextPart{Text: modelTextTaskComplete}}, + wantSecondArtifactParts: a2a.ContentParts{a2a.NewTextPart(modelTextTaskComplete)}, }, { name: "tool confirmation", @@ -106,28 +105,32 @@ func TestA2AInputRequired(t *testing.T) { return createToolConfirmationApproval(t, toolCall) }, wantFirstArtifactParts: a2a.ContentParts{ - a2a.TextPart{Text: modelTextRequiresApproval}, - a2a.DataPart{ - Data: map[string]any{"name": approvalToolName}, - Metadata: map[string]any{"adk_is_long_running": false, "adk_type": "function_call"}, - }, - a2a.DataPart{ - Data: map[string]any{ + a2a.NewTextPart(modelTextRequiresApproval), + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"name": approvalToolName}) + p.SetMeta(adka2a.ToA2AMetaKey("is_long_running"), false) + p.SetMeta(adka2a.ToA2AMetaKey("type"), "function_call") + return p + }(), + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{ "name": approvalToolName, "response": map[string]any{"status": string(approvalStatusPending)}, - }, - Metadata: map[string]any{"adk_type": "function_response"}, - }, + }) + p.SetMeta(adka2a.ToA2AMetaKey("type"), "function_response") + return p + }(), }, wantSecondArtifactParts: a2a.ContentParts{ - a2a.DataPart{ - Data: map[string]any{ + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{ "name": approvalToolName, "response": map[string]any{"status": string(approvalStatusVerified)}, - }, - Metadata: map[string]any{"adk_type": "function_response"}, - }, - a2a.TextPart{Text: modelTextTaskComplete}, + }) + p.SetMeta(adka2a.ToA2AMetaKey("type"), "function_response") + return p + }(), + a2a.NewTextPart(modelTextTaskComplete), }, }, } @@ -147,7 +150,7 @@ func TestA2AInputRequired(t *testing.T) { // Initial message triggers input required taskContent := "Perform important task!" - msg1 := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: taskContent}) + msg1 := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart(taskContent)) task1 := mustSendMessage(t, client, msg1) if task1.Status.State != a2a.TaskStateInputRequired { t.Fatalf("client.SendMessage(Initial) result state = %q, want %q", task1.Status.State, a2a.TaskStateInputRequired) @@ -158,7 +161,7 @@ func TestA2AInputRequired(t *testing.T) { // Incomplete followup keeps the task in input-required incompleteFollowupText := "Is it really necessary?" - msg2 := a2a.NewMessageForTask(a2a.MessageRoleUser, task1, a2a.TextPart{Text: incompleteFollowupText}) + msg2 := a2a.NewMessageForTask(a2a.MessageRoleUser, task1, a2a.NewTextPart(incompleteFollowupText)) task2 := mustSendMessage(t, client, msg2) if task2.Status.State != a2a.TaskStateInputRequired { t.Fatalf("client.SendMessage(IncompleteInput) result state = %q, want %q", task2.Status.State, a2a.TaskStateInputRequired) @@ -175,16 +178,16 @@ func TestA2AInputRequired(t *testing.T) { } // The last part should be the error message lastPart := task2.Status.Message.Parts[len(task2.Status.Message.Parts)-1] - tp, ok := lastPart.(a2a.TextPart) - if !ok { + text := lastPart.Text() + if text == "" { t.Fatalf("last part is not TextPart") } - if !strings.Contains(tp.Text, "no input provided") { - t.Errorf("last part text = %q; want it to contain 'no input provided'", tp.Text) + if !strings.Contains(text, "no input provided") { + t.Errorf("last part text = %q; want it to contain 'no input provided'", text) } // Another incomplete followup should not accumulate error messages - msg2a := a2a.NewMessageForTask(a2a.MessageRoleUser, task1, a2a.TextPart{Text: "Still debating?"}) + msg2a := a2a.NewMessageForTask(a2a.MessageRoleUser, task1, a2a.NewTextPart("Still debating?")) task2a := mustSendMessage(t, client, msg2a) if task2a.Status.State != a2a.TaskStateInputRequired { t.Fatalf("client.SendMessage(IncompleteInput 2) result state = %q, want %q", task2a.Status.State, a2a.TaskStateInputRequired) @@ -193,7 +196,7 @@ func TestA2AInputRequired(t *testing.T) { // Count validation errors in parts validationErrors := 0 for _, p := range task2a.Status.Message.Parts { - if tp, ok := p.(a2a.TextPart); ok && strings.Contains(tp.Text, "no input provided") { + if strings.Contains(p.Text(), "no input provided") { validationErrors++ } } @@ -206,7 +209,7 @@ func TestA2AInputRequired(t *testing.T) { approvedResponse := tc.createApproval(t, toolCall, pendingResponse) msg3 := a2a.NewMessageForTask(a2a.MessageRoleUser, task2, - a2a.TextPart{Text: "LGTM"}, + a2a.NewTextPart("LGTM"), toA2AParts(t, []*genai.Part{approvedResponse}, []string{toolCall.ID})[0], ) task3 := mustSendMessage(t, client, msg3) @@ -270,7 +273,7 @@ func TestA2AMultiHopInputRequired(t *testing.T) { genai.NewPartFromText(modelTextWaitingForApproval), }, []string{}), wantSecondArtifactParts: a2a.ContentParts{ - a2a.TextPart{Text: modelTextTaskComplete}, + a2a.NewTextPart(modelTextTaskComplete), }, }, { @@ -288,14 +291,15 @@ func TestA2AMultiHopInputRequired(t *testing.T) { genai.NewPartFromFunctionResponse(approvalToolName, map[string]any{"status": string(approvalStatusPending)}), }, []string{}), wantSecondArtifactParts: a2a.ContentParts{ - a2a.DataPart{ - Data: map[string]any{ + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{ "name": approvalToolName, "response": map[string]any{"status": string(approvalStatusVerified)}, - }, - Metadata: map[string]any{"adk_type": "function_response"}, - }, - a2a.TextPart{Text: modelTextTaskComplete}, + }) + p.SetMeta(adka2a.ToA2AMetaKey("type"), "function_response") + return p + }(), + a2a.NewTextPart(modelTextTaskComplete), }, }, } @@ -321,14 +325,14 @@ func TestA2AMultiHopInputRequired(t *testing.T) { client := newA2AClient(t, serverA) // Initial message triggers input required - msg1 := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: "Hello, perform important task!"}) + msg1 := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("Hello, perform important task!")) task1 := mustSendMessage(t, client, msg1) if task1.Status.State != a2a.TaskStateInputRequired { t.Fatalf("client.SendMessage(Initial) result state = %q, want %q", task1.Status.State, a2a.TaskStateInputRequired) } // Incomplete followup keeps the task in input-required - msg2 := a2a.NewMessageForTask(a2a.MessageRoleUser, task1, a2a.TextPart{Text: "Is it really necessary?"}) + msg2 := a2a.NewMessageForTask(a2a.MessageRoleUser, task1, a2a.NewTextPart("Is it really necessary?")) task2 := mustSendMessage(t, client, msg2) if task2.Status.State != a2a.TaskStateInputRequired { t.Fatalf("client.SendMessage(IncompleteInput) result state = %q, want %q", task2.Status.State, a2a.TaskStateInputRequired) @@ -338,7 +342,7 @@ func TestA2AMultiHopInputRequired(t *testing.T) { toolCall, pendingResponse := findLongRunningCall(t, toGenaiParts(t, filterPartial(task2.Status.Message.Parts))) approvedResponse := tc.createApproval(t, toolCall, pendingResponse) msg3 := a2a.NewMessageForTask(a2a.MessageRoleUser, task2, - a2a.TextPart{Text: "LGTM"}, + a2a.NewTextPart("LGTM"), toA2AParts(t, []*genai.Part{approvedResponse}, nil)[0], ) task3 := mustSendMessage(t, client, msg3) @@ -377,22 +381,27 @@ func TestA2ACleanupPropagation(t *testing.T) { // until it detects a context cancelation remoteTaskIDChan, remoteCleanupCalledChan := make(chan a2a.TaskID, 1), make(chan struct{}, 2) serverB := startA2AServer(&mockA2AExecutor{ - executeFn: func(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - remoteTaskIDChan <- reqCtx.TaskID - if err := queue.Write(ctx, a2a.NewSubmittedTask(reqCtx, reqCtx.Message)); err != nil { - return err + cancelFn: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + yield(a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCanceled, nil), nil) } - for ctx.Err() == nil { - if err := queue.Write(ctx, a2a.NewArtifactEvent(reqCtx, a2a.TextPart{Text: "foo"})); err != nil { - return err + }, + executeFn: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + remoteTaskIDChan <- reqCtx.TaskID + if !yield(a2a.NewSubmittedTask(reqCtx, reqCtx.Message), nil) { + return } - time.Sleep(1 * time.Millisecond) + for ctx.Err() == nil { + if !yield(a2a.NewArtifactEvent(reqCtx, a2a.NewTextPart("foo")), nil) { + return + } + time.Sleep(1 * time.Millisecond) + } + yield(a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCompleted, nil), nil) } - finalUpdate := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCompleted, nil) - finalUpdate.Final = true - return queue.Write(ctx, finalUpdate) }, - cleanupFn: func(ctx context.Context, reqCtx *a2asrv.RequestContext, result a2a.SendMessageResult, cause error) { + cleanupFn: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext, result a2a.SendMessageResult, cause error) { remoteCleanupCalledChan <- struct{}{} }, }) @@ -411,8 +420,8 @@ func TestA2ACleanupPropagation(t *testing.T) { statusUpdateEventChan := make(chan a2a.Event, 10) go func() { defer close(statusUpdateEventChan) - msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: "work"}) - for event, err := range client.SendStreamingMessage(t.Context(), &a2a.MessageSendParams{Message: msg}) { + msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("work")) + for event, err := range client.SendStreamingMessage(t.Context(), &a2a.SendMessageRequest{Message: msg}) { if err != nil { t.Errorf("client.SendStreamingMessage() error = %v", err) return @@ -429,7 +438,7 @@ func TestA2ACleanupPropagation(t *testing.T) { cancelResultChan := make(chan *a2a.Task, 1) go func() { defer close(cancelResultChan) - task, err := client.CancelTask(t.Context(), &a2a.TaskIDParams{ID: taskID}) + task, err := client.CancelTask(t.Context(), &a2a.CancelTaskRequest{ID: taskID}) if err != nil { t.Errorf("client.CancelTask() error = %v", err) return @@ -456,7 +465,7 @@ func TestA2ACleanupPropagation(t *testing.T) { <-remoteCleanupCalledChan remoteTaskID := <-remoteTaskIDChan remoteClient := newA2AClient(t, serverB) - remoteTask, err := remoteClient.GetTask(t.Context(), &a2a.TaskQueryParams{ID: remoteTaskID}) + remoteTask, err := remoteClient.GetTask(t.Context(), &a2a.GetTaskRequest{ID: remoteTaskID}) if err != nil { t.Fatalf("remoteClient.GetTask() error = %v", err) } @@ -494,8 +503,8 @@ func TestA2ASingleHopFinalResponse(t *testing.T) { }, wantState: a2a.TaskStateCompleted, wantArtifactParts: a2a.ContentParts{ - a2a.TextPart{Text: "Hello, I am beep!"}, - a2a.TextPart{Text: "I am boop. We are here to help!"}, + a2a.NewTextPart("Hello, I am beep!"), + a2a.NewTextPart("I am boop. We are here to help!"), }, wantPartial: true, }, @@ -514,8 +523,8 @@ func TestA2ASingleHopFinalResponse(t *testing.T) { }, wantState: a2a.TaskStateCompleted, wantArtifactParts: a2a.ContentParts{ - a2a.TextPart{Text: "Hello, I am beep!"}, - a2a.TextPart{Text: "I am boop. We are here to help!"}, + a2a.NewTextPart("Hello, I am beep!"), + a2a.NewTextPart("I am boop. We are here to help!"), }, }, { @@ -589,7 +598,7 @@ func TestA2ASingleHopFinalResponse(t *testing.T) { defer server.Close() client := newA2AClient(t, server) - msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: "Tell me about the current weather"}) + msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("Tell me about the current weather")) task := mustSendMessage(t, client, msg) if task.Status.State != tc.wantState { t.Fatalf("client.SendMessage(Initial) result state = %q, want %q", task.Status.State, tc.wantState) @@ -610,7 +619,7 @@ func TestA2ASingleHopFinalResponse(t *testing.T) { if task.Status.Message == nil || len(task.Status.Message.Parts) != 1 { t.Fatalf("got status message = %v, want message with one part", task.Status.Message) } - if tp, ok := task.Status.Message.Parts[0].(a2a.TextPart); !ok || !strings.Contains(tp.Text, tc.wantStatusContain) { + if !strings.Contains(task.Status.Message.Parts[0].Text(), tc.wantStatusContain) { t.Fatalf("got status message = %v, want text containing %q", task.Status.Message.Parts[0], tc.wantStatusContain) } } @@ -635,7 +644,13 @@ func TestA2ASingleHopFinalResponse(t *testing.T) { } else { partialArtifact = task.Artifacts[1] } - wantPartialParts := a2a.ContentParts{a2a.DataPart{Data: map[string]any{}, Metadata: map[string]any{"adk_partial": true}}} + wantPartialParts := a2a.ContentParts{ + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{}) + p.SetMeta(adka2a.ToA2AMetaKey("partial"), true) + return p + }(), + } if diff := cmp.Diff(wantPartialParts, partialArtifact.Parts); diff != "" { t.Fatalf("task wrong artifact parts (+got,-want) diff = %s", diff) } @@ -664,13 +679,13 @@ func TestA2ARemoteAgentStreamingGeminiSuccess(t *testing.T) { ctx := t.Context() client := newA2AClient(t, serverA) - msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: "tell me about the capital of Poland"}) + msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("tell me about the capital of Poland")) msg.ContextID = a2a.NewContextID() // Make streaming request and aggregate results var taskID a2a.TaskID partialText, finalText := "", "" - for event, err := range client.SendStreamingMessage(t.Context(), &a2a.MessageSendParams{Message: msg}) { + for event, err := range client.SendStreamingMessage(t.Context(), &a2a.SendMessageRequest{Message: msg}) { if err != nil { t.Fatalf("client.SendStreamingMessage() error = %v", err) } @@ -679,7 +694,7 @@ func TestA2ARemoteAgentStreamingGeminiSuccess(t *testing.T) { if len(tau.Artifact.Parts) != 1 { t.Fatalf("got %d parts in final partial artifact update, want 1", len(tau.Artifact.Parts)) } - if dp, ok := tau.Artifact.Parts[0].(a2a.DataPart); !ok || len(dp.Data) > 0 { + if dp := tau.Artifact.Parts[0]; dp.Data() == nil || len(dp.Data().(map[string]any)) > 0 { t.Fatalf("got %v part in final partial artifact update, want empty data part", tau.Artifact.Parts[0]) } continue @@ -687,7 +702,7 @@ func TestA2ARemoteAgentStreamingGeminiSuccess(t *testing.T) { if adka2a.IsPartial(tau.Metadata) { for _, p := range tau.Artifact.Parts { - partialText += p.(a2a.TextPart).Text + partialText += p.Text() } continue } @@ -695,7 +710,7 @@ func TestA2ARemoteAgentStreamingGeminiSuccess(t *testing.T) { if len(finalText) > 0 { t.Fatal("got multiple non-partial updates, want 1") } - finalText = tau.Artifact.Parts[0].(a2a.TextPart).Text + finalText = tau.Artifact.Parts[0].Text() } taskID = event.TaskInfo().TaskID } @@ -709,7 +724,7 @@ func TestA2ARemoteAgentStreamingGeminiSuccess(t *testing.T) { } // Check A2A Task state - task, err := client.GetTask(ctx, &a2a.TaskQueryParams{ID: taskID}) + task, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: taskID}) if err != nil { t.Fatalf("client.GetTask() error = %v", err) } @@ -776,12 +791,12 @@ func TestA2ARemoteAgentStreamingGeminiError(t *testing.T) { ctx := t.Context() client := newA2AClient(t, serverA) - msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: "tell me about the capital of Poland"}) + msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("tell me about the capital of Poland")) msg.ContextID = a2a.NewContextID() // Make streaming request and aggregate results var taskID a2a.TaskID - for event, err := range client.SendStreamingMessage(t.Context(), &a2a.MessageSendParams{Message: msg}) { + for event, err := range client.SendStreamingMessage(t.Context(), &a2a.SendMessageRequest{Message: msg}) { if err != nil { t.Fatalf("client.SendStreamingMessage() error = %v", err) } @@ -789,7 +804,7 @@ func TestA2ARemoteAgentStreamingGeminiError(t *testing.T) { } // Check A2A Task state - task, err := client.GetTask(ctx, &a2a.TaskQueryParams{ID: taskID}) + task, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: taskID}) if err != nil { t.Fatalf("client.GetTask() error = %v", err) } @@ -799,13 +814,13 @@ func TestA2ARemoteAgentStreamingGeminiError(t *testing.T) { if task.Status.Message == nil || len(task.Status.Message.Parts) != 1 { t.Fatalf("task status message = %v, want 1 part", task.Status.Message) } - if tp, ok := task.Status.Message.Parts[0].(a2a.TextPart); !ok || !strings.Contains(tp.Text, errorMessage) { + if !strings.Contains(task.Status.Message.Parts[0].Text(), errorMessage) { t.Fatalf("task status message = %v, want containing %q", task.Status.Message.Parts[0], errorMessage) } if len(task.Artifacts) != 1 || len(adka2a.WithoutPartialArtifacts(task.Artifacts)) != 0 { t.Fatalf("task artifacts = %v, want single partial artifact", task.Artifacts) } - if dp, ok := task.Artifacts[0].Parts[0].(a2a.DataPart); !ok || len(dp.Data) != 0 { + if dp := task.Artifacts[0].Parts[0]; dp.Data() == nil { t.Fatalf("task artifact = %v, want reset partial artifact", task.Artifacts[0]) } @@ -955,7 +970,7 @@ func newAgentExecutor(agnt agent.Agent, service session.Service, mode adka2a.Out func mustSendMessage(t *testing.T, client *a2aclient.Client, msg *a2a.Message) *a2a.Task { t.Helper() - sendParams := &a2a.MessageSendParams{Message: msg} + sendParams := &a2a.SendMessageRequest{Message: msg} result, err := client.SendMessage(t.Context(), sendParams) if err != nil { t.Fatalf("client.SendMessage() error = %v", err) @@ -967,8 +982,8 @@ func mustSendMessage(t *testing.T, client *a2aclient.Client, msg *a2a.Message) * return task } -func filterPartial(parts []a2a.Part) []a2a.Part { - var result []a2a.Part +func filterPartial(parts []*a2a.Part) []*a2a.Part { + var result []*a2a.Part for _, p := range parts { if b, _ := p.Meta()[adka2a.ToA2AMetaKey("partial")].(bool); b { continue @@ -1000,16 +1015,16 @@ func findLongRunningCall(t *testing.T, parts []*genai.Part) (*genai.FunctionCall return call, response } -func toA2AParts(t *testing.T, parts []*genai.Part, callIDs []string) []a2a.Part { +func toA2AParts(t *testing.T, parts []*genai.Part, callIDs []string) []*a2a.Part { t.Helper() - a2aParts, err := adka2a.ToA2AParts(parts, callIDs) + result, err := adka2a.ToA2AParts(parts, callIDs) if err != nil { t.Fatalf("adka2a.ToA2AParts() error = %v", err) } - return a2aParts + return result } -func toGenaiParts(t *testing.T, a2aParts []a2a.Part) []*genai.Part { +func toGenaiParts(t *testing.T, a2aParts []*a2a.Part) []*genai.Part { t.Helper() parts, err := adka2a.ToGenAIParts(a2aParts) if err != nil { @@ -1040,8 +1055,10 @@ func newA2AClient(t *testing.T, server *testA2AServer) *a2aclient.Client { t.Helper() result, err := a2aclient.NewFromCard(t.Context(), &a2a.AgentCard{ - PreferredTransport: a2a.TransportProtocolJSONRPC, - URL: server.URL, Capabilities: a2a.AgentCapabilities{Streaming: true}, + SupportedInterfaces: []*a2a.AgentInterface{ + a2a.NewAgentInterface(server.URL, a2a.TransportProtocolJSONRPC), + }, + Capabilities: a2a.AgentCapabilities{Streaming: true}, }) if err != nil { t.Fatalf("a2aclient.NewFromEndpoints() error = %v", err) @@ -1129,19 +1146,23 @@ func TestA2AMultiHopInputRequiredCancellation(t *testing.T) { remoteAgentName := "remote-agent-B" remoteTaskIDChan := make(chan a2a.TaskID, 1) serverB := startA2AServer(&mockA2AExecutor{ - executeFn: func(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - remoteTaskIDChan <- reqCtx.TaskID - ev := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateInputRequired, a2a.NewMessage(a2a.MessageRoleAgent, a2a.DataPart{ - Data: map[string]any{"id": "call-1", "name": "foo"}, - Metadata: map[string]any{"adk_is_long_running": true, "adk_type": "function_call"}, - })) - ev.Final = true - return queue.Write(ctx, ev) + executeFn: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + remoteTaskIDChan <- reqCtx.TaskID + if !yield(a2a.NewSubmittedTask(reqCtx, reqCtx.Message), nil) { + return + } + ev := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateInputRequired, a2a.NewMessage(a2a.MessageRoleAgent, &a2a.Part{ + Content: a2a.Data{Value: map[string]any{"id": "call-1", "name": "foo"}}, + Metadata: map[string]any{"adk_is_long_running": true, "adk_type": "function_call"}, + })) + yield(ev, nil) + } }, - cancelFn: func(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - ev := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCanceled, nil) - ev.Final = true - return queue.Write(ctx, ev) + cancelFn: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + yield(a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCanceled, nil), nil) + } }, }) defer serverB.Close() @@ -1155,14 +1176,14 @@ func TestA2AMultiHopInputRequiredCancellation(t *testing.T) { // Send message clientA := newA2AClient(t, serverA) - msg1 := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: "Hello"}) + msg1 := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("Hello")) task1 := mustSendMessage(t, clientA, msg1) if task1.Status.State != a2a.TaskStateInputRequired { t.Fatalf("task1.Status.State = %q, want %q", task1.Status.State, a2a.TaskStateInputRequired) } // Cancel the task on Server A - _, err := clientA.CancelTask(t.Context(), &a2a.TaskIDParams{ID: task1.ID}) + _, err := clientA.CancelTask(t.Context(), &a2a.CancelTaskRequest{ID: task1.ID}) if err != nil { t.Fatalf("client.CancelTask() error = %v", err) } @@ -1170,7 +1191,7 @@ func TestA2AMultiHopInputRequiredCancellation(t *testing.T) { // Verify that Server B's task was cancelled remoteTaskID := <-remoteTaskIDChan clientB := newA2AClient(t, serverB) - remoteTask, err := clientB.GetTask(t.Context(), &a2a.TaskQueryParams{ID: remoteTaskID}) + remoteTask, err := clientB.GetTask(t.Context(), &a2a.GetTaskRequest{ID: remoteTaskID}) if err != nil { t.Fatalf("client.CancelTask() error = %v", err) } diff --git a/agent/remoteagent/v2/client.go b/agent/remoteagent/v2/client.go new file mode 100644 index 000000000..bd445cca0 --- /dev/null +++ b/agent/remoteagent/v2/client.go @@ -0,0 +1,52 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package remoteagent + +import ( + "context" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + + iremoteagent "google.golang.org/adk/internal/agent/remoteagent" +) + +// A2AClient is used to send messages to a remote agent. +type A2AClient iremoteagent.A2AClient + +// A2AClientProvider is a function that creates an A2AMessageSender. +type A2AClientProvider func(context.Context, *a2a.AgentCard) (A2AClient, error) + +// CreateClient implements iremoteagent.A2AClientProvider. +func (fn A2AClientProvider) CreateClient(ctx context.Context, card *a2a.AgentCard) (iremoteagent.A2AClient, error) { + return fn(ctx, card) +} + +// NewA2AClientProvider creates a default A2AClientProvider from the configured factory. +func NewA2AClientProvider(factory *a2aclient.Factory) A2AClientProvider { + return func(ctx context.Context, card *a2a.AgentCard) (A2AClient, error) { + var client *a2aclient.Client + var err error + if factory != nil { + client, err = factory.CreateFromCard(ctx, card) + } else { + client, err = a2aclient.NewFromCard(ctx, card) + } + if err != nil { + return nil, err + } + return client, nil + } +} diff --git a/agent/remoteagent/doc.go b/agent/remoteagent/v2/doc.go similarity index 91% rename from agent/remoteagent/doc.go rename to agent/remoteagent/v2/doc.go index c26fba2a4..98e2ce114 100644 --- a/agent/remoteagent/doc.go +++ b/agent/remoteagent/v2/doc.go @@ -12,5 +12,5 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package remoteagent allows to use a remote ADK agents. +// Package remoteagent allows using a remote ADK agents. package remoteagent diff --git a/agent/remoteagent/testdata/TestA2ARemoteAgentStreamingGeminiError.httprr b/agent/remoteagent/v2/testdata/TestA2ARemoteAgentStreamingGeminiError.httprr similarity index 100% rename from agent/remoteagent/testdata/TestA2ARemoteAgentStreamingGeminiError.httprr rename to agent/remoteagent/v2/testdata/TestA2ARemoteAgentStreamingGeminiError.httprr diff --git a/agent/remoteagent/testdata/TestA2ARemoteAgentStreamingGeminiSuccess.httprr b/agent/remoteagent/v2/testdata/TestA2ARemoteAgentStreamingGeminiSuccess.httprr similarity index 100% rename from agent/remoteagent/testdata/TestA2ARemoteAgentStreamingGeminiSuccess.httprr rename to agent/remoteagent/v2/testdata/TestA2ARemoteAgentStreamingGeminiSuccess.httprr diff --git a/agent/remoteagent/testdata/TestA2ASingleHopFinalResponse_llm_mid-response_error.httprr b/agent/remoteagent/v2/testdata/TestA2ASingleHopFinalResponse_llm_mid-response_error.httprr similarity index 100% rename from agent/remoteagent/testdata/TestA2ASingleHopFinalResponse_llm_mid-response_error.httprr rename to agent/remoteagent/v2/testdata/TestA2ASingleHopFinalResponse_llm_mid-response_error.httprr diff --git a/agent/remoteagent/testdata/TestA2ASingleHopFinalResponse_llm_mid-response_error_response.httprr b/agent/remoteagent/v2/testdata/TestA2ASingleHopFinalResponse_llm_mid-response_error_response.httprr similarity index 100% rename from agent/remoteagent/testdata/TestA2ASingleHopFinalResponse_llm_mid-response_error_response.httprr rename to agent/remoteagent/v2/testdata/TestA2ASingleHopFinalResponse_llm_mid-response_error_response.httprr diff --git a/agent/remoteagent/utils.go b/agent/remoteagent/v2/utils.go similarity index 84% rename from agent/remoteagent/utils.go rename to agent/remoteagent/v2/utils.go index bca2e4c8c..f9b75cd1c 100644 --- a/agent/remoteagent/utils.go +++ b/agent/remoteagent/v2/utils.go @@ -18,11 +18,12 @@ import ( "fmt" "slices" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/log" "google.golang.org/genai" "google.golang.org/adk/agent" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" "google.golang.org/adk/session" ) @@ -89,23 +90,23 @@ func getFunctionResponseCallID(event *session.Event) (string, bool) { // Parts from all events we processed are returned as a single list. // The returned contextID might be an empty string. This means the current remote agent invocation is not associates with // any of the previous one. In this case a new contextID will be generated on the remote server. -func toMissingRemoteSessionParts(ctx agent.InvocationContext, events session.Events, cfg A2AConfig) ([]a2a.Part, string) { +func toMissingRemoteSessionParts(ctx agent.InvocationContext, events session.Events, cfg A2AConfig) ([]*a2a.Part, string) { partCount, contextID := 0, "" // only events after this index are not in the remote session lastRemoteResponseIndex := -1 for i := events.Len() - 1; i >= 0; i-- { event := events.At(i) - if event.LLMResponse.Content != nil { - partCount += len(event.Content.Parts) - } if event.Author == ctx.Agent().Name() { lastRemoteResponseIndex = i _, contextID = adka2a.GetA2ATaskInfo(event) break } + if event.LLMResponse.Content != nil { + partCount += len(event.Content.Parts) + } } - result := make([]a2a.Part, 0, partCount) + result := make([]*a2a.Part, 0, partCount) for i := lastRemoteResponseIndex + 1; i < events.Len(); i++ { event := events.At(i) if event.Author != "user" && event.Author != ctx.Agent().Name() { @@ -116,7 +117,7 @@ func toMissingRemoteSessionParts(ctx agent.InvocationContext, events session.Eve } parts, err := convertParts(ctx, cfg, event) if err != nil { - // TODO(yarolegovich): log error + log.Warn(ctx, "failed to convert parts for session event", "index", i, "error", err) continue } result = append(result, parts...) @@ -158,3 +159,25 @@ func presentAsUserMessage(ctx agent.InvocationContext, agentEvent *session.Event } return event } + +func convertParts(ctx agent.InvocationContext, cfg A2AConfig, event *session.Event) ([]*a2a.Part, error) { + parts := make([]*a2a.Part, 0, len(event.Content.Parts)) + if cfg.GenAIPartConverter != nil { + for _, part := range event.Content.Parts { + cp, err := cfg.GenAIPartConverter(ctx, event, part) + if err != nil { + return nil, err + } + if cp != nil { + parts = append(parts, cp) + } + } + } else { + var err error + parts, err = adka2a.ToA2AParts(event.Content.Parts, event.LongRunningToolIDs) + if err != nil { + return nil, fmt.Errorf("event part conversion failed: %w", err) + } + } + return parts, nil +} diff --git a/agent/remoteagent/utils_test.go b/agent/remoteagent/v2/utils_test.go similarity index 94% rename from agent/remoteagent/utils_test.go rename to agent/remoteagent/v2/utils_test.go index d189c4e29..1690f7432 100644 --- a/agent/remoteagent/utils_test.go +++ b/agent/remoteagent/v2/utils_test.go @@ -18,7 +18,7 @@ import ( "fmt" "testing" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genai" @@ -26,7 +26,7 @@ import ( "google.golang.org/adk/agent" icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/model" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" "google.golang.org/adk/session" ) @@ -157,7 +157,7 @@ func TestToMissingRemoteSessionParts(t *testing.T) { testCases := []struct { name string events []*session.Event - wantParts []a2a.Part + wantParts []*a2a.Part wantContextID string }{ { @@ -166,10 +166,10 @@ func TestToMissingRemoteSessionParts(t *testing.T) { newEventFromParts("user", &genai.Part{Text: "hello"}), newEventFromParts("user", &genai.Part{Text: "foo"}, &genai.Part{Text: "bar"}), }, - wantParts: []a2a.Part{ - a2a.TextPart{Text: "hello"}, - a2a.TextPart{Text: "foo"}, - a2a.TextPart{Text: "bar"}, + wantParts: []*a2a.Part{ + a2a.NewTextPart("hello"), + a2a.NewTextPart("foo"), + a2a.NewTextPart("bar"), }, }, { @@ -178,10 +178,10 @@ func TestToMissingRemoteSessionParts(t *testing.T) { newEventFromParts("another-agent", &genai.Part{Text: "foo"}), newEventFromParts("user", &genai.Part{Text: "bar"}), }, - wantParts: []a2a.Part{ - a2a.TextPart{Text: "For context:"}, - a2a.TextPart{Text: "[another-agent] said: foo"}, - a2a.TextPart{Text: "bar"}, + wantParts: []*a2a.Part{ + a2a.NewTextPart("For context:"), + a2a.NewTextPart("[another-agent] said: foo"), + a2a.NewTextPart("bar"), }, }, { @@ -190,8 +190,8 @@ func TestToMissingRemoteSessionParts(t *testing.T) { newEventFromParts("another-agent", &genai.Part{Text: "foo", Thought: true}), newEventFromParts("user", &genai.Part{Text: "bar"}), }, - wantParts: []a2a.Part{ - a2a.TextPart{Text: "bar"}, + wantParts: []*a2a.Part{ + a2a.NewTextPart("bar"), }, }, { @@ -202,9 +202,9 @@ func TestToMissingRemoteSessionParts(t *testing.T) { newEventFromParts("user", &genai.Part{Text: "foo"}), newEventFromParts("user", &genai.Part{Text: "bar"}), }, - wantParts: []a2a.Part{ - a2a.TextPart{Text: "foo"}, - a2a.TextPart{Text: "bar"}, + wantParts: []*a2a.Part{ + a2a.NewTextPart("foo"), + a2a.NewTextPart("bar"), }, }, { @@ -218,7 +218,7 @@ func TestToMissingRemoteSessionParts(t *testing.T) { }, }, }, - wantParts: []a2a.Part{}, + wantParts: []*a2a.Part{}, wantContextID: "ctxID-123", }, } diff --git a/go.mod b/go.mod index 68e5e1b26..dc1e73095 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( cloud.google.com/go v0.123.0 cloud.google.com/go/aiplatform v1.121.0 cloud.google.com/go/storage v1.56.1 - github.com/a2aproject/a2a-go v0.3.13 + github.com/a2aproject/a2a-go v0.3.15 github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/glebarez/sqlite v1.8.0 github.com/google/go-cmp v0.7.0 @@ -16,7 +16,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/mitchellh/mapstructure v1.5.0 github.com/modelcontextprotocol/go-sdk v1.4.1 - github.com/spf13/cobra v1.8.1 + github.com/spf13/cobra v1.10.2 go.opentelemetry.io/contrib/detectors/gcp v1.40.0 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 @@ -28,7 +28,7 @@ require ( golang.org/x/sync v0.20.0 google.golang.org/api v0.272.0 google.golang.org/genai v1.40.0 - google.golang.org/grpc v1.79.3 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 gorm.io/gorm v1.31.0 @@ -38,6 +38,11 @@ require ( require github.com/hashicorp/golang-lru/v2 v2.0.7 +require ( + github.com/a2aproject/a2a-go/v2 v2.2.1 + golang.org/x/mod v0.33.0 // indirect +) + require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go/auth v0.18.2 // indirect @@ -90,8 +95,8 @@ require ( golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect modernc.org/libc v1.22.3 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 2ac6ef276..a46f5354c 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= -github.com/a2aproject/a2a-go v0.3.13 h1:WpIcSHgCySIxD7OQEdV7U7WJc/HL/G2QQj0RJ0YhPi0= -github.com/a2aproject/a2a-go v0.3.13/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= +github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI= +github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= +github.com/a2aproject/a2a-go/v2 v2.2.1 h1:NAfUoceWAStJ7FnF8TTfWuHejf9mzKgc9QmaKV1hyXw= +github.com/a2aproject/a2a-go/v2 v2.2.1/go.mod h1:mkZr8y2bUgAVQsjs/5fHK7xrRlAHDybMEyxWh2tKRC8= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -40,7 +42,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -126,9 +128,9 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= @@ -173,8 +175,11 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -192,20 +197,20 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= google.golang.org/genai v1.40.0 h1:kYxyQSH+vsib8dvsgyLJzsVEIv5k3ZmHJyVqdvGncmc= google.golang.org/genai v1.40.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= -google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= -google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/agent/remoteagent/a2a_config.go b/internal/agent/remoteagent/a2a_config.go index ef5dd92ec..84b8e55c1 100644 --- a/internal/agent/remoteagent/a2a_config.go +++ b/internal/agent/remoteagent/a2a_config.go @@ -16,16 +16,31 @@ package remoteagent import ( "context" - "encoding/json" "fmt" - "os" - "strings" + "iter" - "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2aclient" - "github.com/a2aproject/a2a-go/a2aclient/agentcard" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" ) +// A2AClient abstracts a2a-go client so that we can use different SDK versions. +type A2AClient interface { + // SendMessage sends a message to the remote agent and returns the result. + SendMessage(ctx context.Context, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) + // SendStreamingMessage sends a message to the remote agent and returns a stream of events. + SendStreamingMessage(ctx context.Context, req *a2a.SendMessageRequest) iter.Seq2[a2a.Event, error] + // CancelTask cancels a task on the remote agent. + CancelTask(ctx context.Context, req *a2a.CancelTaskRequest) (*a2a.Task, error) + // Destroy is called in the end of agent invocation. + Destroy() error +} + +// A2AClientProvider creates an [A2AClient]. +type A2AClientProvider interface { + // CreateClient creates an [A2AClient]. + CreateClient(context.Context, *a2a.AgentCard) (A2AClient, error) +} + // RemoteAgentState holds the internal state of a remote agent. type RemoteAgentState struct { // A2A holds the A2A configuration if remote agent is an A2A agent. @@ -34,25 +49,23 @@ type RemoteAgentState struct { // A2AServerConfig is used to describe and configure a remote agent. type A2AServerConfig struct { - // AgentCardSource can be either an http(s) URL or a local file path. If a2a.AgentCard - // is not provided, the source is used to resolve the card during the first agent invocation. - AgentCard *a2a.AgentCard - AgentCardSource string - // CardResolveOptions can be used to provide a set of agencard.Resolver configurations. - CardResolveOptions []agentcard.ResolveOption - // ClientFactory can be used to provide a set of a2aclient.Client configurations. - ClientFactory *a2aclient.Factory + // AgentCard is a static agent card. + AgentCard *a2a.AgentCard + // AgentCardProvider resolves an agent card lazily. + AgentCardProvider func(ctx context.Context) (*a2a.AgentCard, error) + // ClientProvider is used to create an [A2AClient] implementation. + ClientProvider A2AClientProvider } -func CreateA2AClient(ctx context.Context, cfg *A2AServerConfig) (*a2a.AgentCard, *a2aclient.Client, error) { - card, err := resolveAgentCard(ctx, cfg) +func CreateA2AClient(ctx context.Context, cfg *A2AServerConfig) (*a2a.AgentCard, A2AClient, error) { + card, err := ResolveAgentCard(ctx, cfg) if err != nil { return nil, nil, fmt.Errorf("agent card resolution failed: %w", err) } - var client *a2aclient.Client - if cfg.ClientFactory != nil { - client, err = cfg.ClientFactory.CreateFromCard(ctx, card) + var client A2AClient + if cfg.ClientProvider != nil { + client, err = cfg.ClientProvider.CreateClient(ctx, card) } else { client, err = a2aclient.NewFromCard(ctx, card) } @@ -62,27 +75,12 @@ func CreateA2AClient(ctx context.Context, cfg *A2AServerConfig) (*a2a.AgentCard, return card, client, nil } -func resolveAgentCard(ctx context.Context, cfg *A2AServerConfig) (*a2a.AgentCard, error) { +func ResolveAgentCard(ctx context.Context, cfg *A2AServerConfig) (*a2a.AgentCard, error) { if cfg.AgentCard != nil { return cfg.AgentCard, nil } - - if strings.HasPrefix(cfg.AgentCardSource, "http://") || strings.HasPrefix(cfg.AgentCardSource, "https://") { - card, err := agentcard.DefaultResolver.Resolve(ctx, cfg.AgentCardSource, cfg.CardResolveOptions...) - if err != nil { - return nil, fmt.Errorf("failed to fetch an agent card: %w", err) - } - return card, nil - } - - fileBytes, err := os.ReadFile(cfg.AgentCardSource) - if err != nil { - return nil, fmt.Errorf("failed to read agent card from %q: %w", cfg.AgentCardSource, err) - } - - var card a2a.AgentCard - if err := json.Unmarshal(fileBytes, &card); err != nil { - return nil, fmt.Errorf("failed to unmarshal an agent card: %w", err) + if cfg.AgentCardProvider != nil { + return cfg.AgentCardProvider(ctx) } - return &card, nil + return nil, fmt.Errorf("either AgentCard or AgentCardProvider must be set") } diff --git a/server/adka2a/conversions.go b/server/adka2a/conversions.go new file mode 100644 index 000000000..b35e5ed97 --- /dev/null +++ b/server/adka2a/conversions.go @@ -0,0 +1,155 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package adka2a + +import ( + "context" + "fmt" + + "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/log" + v2a2a "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + v2 "google.golang.org/adk/server/adka2a/v2" + "google.golang.org/adk/session" +) + +// BuildAgentSkills attempts to create a list of [a2a.AgentSkill]s based on agent descriptions and types. +// This information can be used in [a2a.AgentCard] to help clients understand agent capabilities. +func BuildAgentSkills(agent agent.Agent) []a2a.AgentSkill { + v1Skills := v2.BuildAgentSkills(agent) + card := a2av0.FromV1AgentCard(&v2a2a.AgentCard{Skills: v1Skills}) + return card.Skills +} + +// ToA2AMetaKey adds a prefix used to differentiate ADK-related values stored in Metadata an A2A event. +func ToA2AMetaKey(key string) string { + return v2.ToA2AMetaKey(key) +} + +// ToADKMetaKey adds a prefix used to differentiate A2A-related values stored in custom metadata of an ADK session event. +func ToADKMetaKey(key string) string { + return v2.ToADKMetaKey(key) +} + +// ToCustomMetadata creates ADK custom metadata from A2A task and context IDs. +func ToCustomMetadata(taskID a2a.TaskID, contextID string) map[string]any { + return v2.ToCustomMetadata(v2a2a.TaskID(taskID), contextID) +} + +// GetA2ATaskInfo returns A2A task and context IDs if they are present in session event custom metadata. +func GetA2ATaskInfo(event *session.Event) (a2a.TaskID, string) { + taskID, contextID := v2.GetA2ATaskInfo(event) + return a2a.TaskID(taskID), contextID +} + +// NewRemoteAgentEvent create a new Event authored by the agent running in the provided invocation context. +func NewRemoteAgentEvent(ctx agent.InvocationContext) *session.Event { + return v2.NewRemoteAgentEvent(ctx) +} + +// EventToMessage converts the provided session event to A2A message. +func EventToMessage(event *session.Event) (*a2a.Message, error) { + v1Msg, err := v2.EventToMessage(event) + if err != nil { + return nil, err + } + return a2av0.FromV1Message(v1Msg), nil +} + +// ToSessionEvent converts the provided a2a event to session event authored by the agent running in the provided invocation context. +func ToSessionEvent(ctx agent.InvocationContext, event a2a.Event) (*session.Event, error) { + v1Event, err := a2av0.ToV1Event(event) + if err != nil { + return nil, fmt.Errorf("a2a event conversion failed: %w", err) + } + return v2.ToSessionEvent(ctx, v1Event) +} + +// IsPartial takes metadata of an A2A object (eg. a2a.Part, a2a.Artifact) and returns true if +// it was marked as partial based on the ADK partial flag set on the original ADK object. +func IsPartial(meta map[string]any) bool { + return v2.IsPartial(meta) +} + +// IsPartialFlagSet takes metadata of an A2A object (eg. a2a.Part, a2a.Artifact) and returns true if +// the ADK partial flag was set on it. +func IsPartialFlagSet(meta map[string]any) bool { + return v2.IsPartialFlagSet(meta) +} + +// ToA2APart converts the provided genai part to A2A equivalent. Long running tool IDs are used for attaching metadata to +// the relevant data parts. +func ToA2APart(part *genai.Part, longRunningToolIDs []string) (a2a.Part, error) { + v1p, err := v2.ToA2APart(part, longRunningToolIDs) + if err != nil { + return nil, err + } + return a2av0.FromV1Part(v1p), nil +} + +// ToA2AParts converts the provided genai parts to A2A equivalents. Long running tool IDs are used for attaching metadata to +// the relevant data parts. +func ToA2AParts(parts []*genai.Part, longRunningToolIDs []string) ([]a2a.Part, error) { + v1ps, err := v2.ToA2AParts(parts, longRunningToolIDs) + if err != nil { + return nil, err + } + result := make([]a2a.Part, len(v1ps)) + for i, v1p := range v1ps { + result[i] = a2av0.FromV1Part(v1p) + } + return result, nil +} + +// ToGenAIPart converts the provided A2A part to a genai equivalent. +func ToGenAIPart(part a2a.Part) (*genai.Part, error) { + return v2.ToGenAIPart(a2av0.ToV1Part(part)) +} + +// ToGenAIParts converts the provided A2A parts to genai equivalents. +func ToGenAIParts(parts []a2a.Part) ([]*genai.Part, error) { + v1ps := make([]*v2a2a.Part, len(parts)) + for i, p := range parts { + v1ps[i] = a2av0.ToV1Part(p) + } + return v2.ToGenAIParts(v1ps) +} + +// WithoutPartialArtifacts returns a slice of artifacts without partial artifacts. +// Partial artifacts are usually discarded (contain no parts) after agent invocation is finished. +func WithoutPartialArtifacts(artifacts []*a2a.Artifact) []*a2a.Artifact { + v1Artifacts := make([]*v2a2a.Artifact, 0, len(artifacts)) + for _, a := range artifacts { + v1a, err := a2av0.ToV1Artifact(a) + if err != nil { + log.Warn(context.Background(), "artifact skipped, because conversion failed", "cause", err) + continue + } + v1Artifacts = append(v1Artifacts, v1a) + } + + filtered := v2.WithoutPartialArtifacts(v1Artifacts) + + result := make([]*a2a.Artifact, 0, len(filtered)) + for _, f := range filtered { + result = append(result, a2av0.FromV1Artifact(f)) + } + return result +} diff --git a/server/adka2a/executor.go b/server/adka2a/executor.go index f17e344f0..f87ce19be 100644 --- a/server/adka2a/executor.go +++ b/server/adka2a/executor.go @@ -12,27 +12,29 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package adka2a allows exposing ADK agents via A2A. +// +// Deprecated: Use google.golang.org/adk/server/adka2a/v2 instead. package adka2a import ( "context" "errors" "fmt" - "iter" - "slices" + "maps" "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2aclient" "github.com/a2aproject/a2a-go/a2asrv" "github.com/a2aproject/a2a-go/a2asrv/eventqueue" "github.com/a2aproject/a2a-go/log" - + a2av2 "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" + a2asrvv2 "github.com/a2aproject/a2a-go/v2/a2asrv" "google.golang.org/genai" "google.golang.org/adk/agent" - iremoteagent "google.golang.org/adk/internal/agent/remoteagent" - "google.golang.org/adk/plugin" "google.golang.org/adk/runner" + v2 "google.golang.org/adk/server/adka2a/v2" "google.golang.org/adk/session" ) @@ -55,31 +57,28 @@ type A2APartConverter func(ctx context.Context, a2aEvent a2a.Event, part a2a.Par // nil returns are considered intentionally dropped parts. type GenAIPartConverter func(ctx context.Context, adkEvent *session.Event, part *genai.Part) (a2a.Part, error) -// A2AExecutionCleanupCallback is a callback which will be called after an execution or cancellatio has completed or failed. +// A2AExecutionCleanupCallback is a callback which will be called after an execution or cancellation has completed or failed. type A2AExecutionCleanupCallback func(ctx context.Context, reqCtx *a2asrv.RequestContext, subAgentCards []*a2a.AgentCard, result a2a.SendMessageResult, cause error) // OutputMode controls how artifacts are produced. -type OutputMode string +type OutputMode = v2.OutputMode // Runner is an interface matching [runner.Runner] API. // It exists to let users use custom runner implementations with A2A agent executor. -type Runner interface { - // Run runs the agent for the given user input, yielding events from agents. - Run(ctx context.Context, userID, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] -} +type Runner = v2.Runner // RunnerProvider is a [Runner] factory function. The provided plugin must be installed in the returned [Runner] for // callbacks taking [ExecutorContext] to work correctly. -type RunnerProvider func(ctx context.Context, reqCtx *a2asrv.RequestContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) +type RunnerProvider = v2.RunnerProvider const ( // OutputArtifactPerRun produces a single artifact per [runner.Runner.Run]. - OutputArtifactPerRun OutputMode = "artifact-per-run" + OutputArtifactPerRun OutputMode = v2.OutputArtifactPerRun // OutputArtifactPerEvent produces an artifact per non-partial [session.Event]. // While agent is emitting events an artifact is build incrementally (parts are append to it). // The next partial event replaces accumulated contents and seals the artifact, meaning // the next event from this agent will create a new artifact. - OutputArtifactPerEvent OutputMode = "artifact-per-event" + OutputArtifactPerEvent OutputMode = v2.OutputArtifactPerEvent ) // RunnerConfig is part of the runner configuration executor code depends on. @@ -87,7 +86,7 @@ const ( type RunnerConfig struct { // AppName is the name of the application used in [session.Service] keys and A2A event metadata. AppName string - // Agent is the root agent. It isued + // Agent is the root agent. Agent agent.Agent // SessionService is the session service to use. SessionService session.Service @@ -139,311 +138,242 @@ type ExecutorConfig struct { var _ a2asrv.AgentExecutor = (*Executor)(nil) -// Executor invokes an ADK agent and translates [session.Event]-s to [a2a.Event]-s according to the following rules: -// - If the input doesn't reference any a2a.Task, produce a Task with TaskStateSubmitted state. -// - Right before runner.Runner invocation, produce TaskStatusUpdateEvent with TaskStateWorking. -// - For every session.Event produce a TaskArtifactUpdateEvent{Append=true} with transformed parts. -// - After the last session.Event is processed produce an empty TaskArtifactUpdateEvent{Append=true} with LastChunk=true, -// if at least one artifact update was produced during the run. -// - If there was an LLMResponse with non-zero error code, produce a TaskStatusUpdateEvent with TaskStateFailed. -// Else if there was an LLMResponse with long-running tool invocation, produce a TaskStatusUpdateEvent with TaskStateInputRequired. -// Else produce a TaskStatusUpdateEvent with TaskStateCompleted. +// Executor is the legacy AgentExecutor implementation which delegates to [v2.Executor]. type Executor struct { - config ExecutorConfig + impl *v2.Executor } // NewExecutor creates an initialized [Executor] instance. func NewExecutor(config ExecutorConfig) *Executor { - if config.RunnerProvider == nil { - config.RunnerProvider = newDefaultRunnerProvider(config.RunnerConfig) - } - return &Executor{config: config} -} - -func (e *Executor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - msg := reqCtx.Message - if msg == nil { - return fmt.Errorf("message not provided") - } - content, err := toGenAIContent(ctx, msg, e.config.A2APartConverter) - if err != nil { - return fmt.Errorf("a2a message conversion failed: %w", err) - } - - executorPlugin, err := newExecutorPlugin() - if err != nil { - return fmt.Errorf("failed to create a2a-executor plugin: %w", err) - } - - cfg, r, err := e.config.RunnerProvider(ctx, reqCtx, executorPlugin.plugin) - if err != nil { - return fmt.Errorf("failed to create a runner: %w", err) + v1Config := v2.ExecutorConfig{ + RunnerConfig: config.RunnerConfig, + RunnerProvider: config.RunnerProvider, + RunConfig: config.RunConfig, + OutputMode: v2.OutputMode(config.OutputMode), } - if e.config.BeforeExecuteCallback != nil { - ctx, err = e.config.BeforeExecuteCallback(ctx, reqCtx) - if err != nil { - return fmt.Errorf("before execute: %w", err) + if config.BeforeExecuteCallback != nil { + v1Config.BeforeExecuteCallback = func(ctx context.Context, execCtx *a2asrvv2.ExecutorContext) (context.Context, error) { + legacyReqCtx := toRequestContext(execCtx) + newCtx, err := config.BeforeExecuteCallback(ctx, legacyReqCtx) + if err != nil { + return nil, err + } + v1ExecCtx, err := toExecutorContext(newCtx, legacyReqCtx) + if err != nil { + return nil, err + } + *execCtx = *v1ExecCtx + return newCtx, nil } } - if event, err := handleInputRequired(reqCtx, content); event != nil || err != nil { - if err != nil { - return err + if config.AfterEventCallback != nil { + v1Config.AfterEventCallback = func(ctx v2.ExecutorContext, adkEvent *session.Event, a2aEvent *a2av2.TaskArtifactUpdateEvent) error { + legacyEvent := a2av0.FromV1TaskArtifactUpdateEvent(a2aEvent) + if err := config.AfterEventCallback(executorContextWrapper{ctx}, adkEvent, legacyEvent); err != nil { + return err + } + newV1Event, err := a2av0.ToV1Event(legacyEvent) + if err != nil { + return err + } + if converted, ok := newV1Event.(*a2av2.TaskArtifactUpdateEvent); ok { + *a2aEvent = *converted + } + return nil } - return queue.Write(ctx, event) } - if reqCtx.StoredTask == nil { - event := a2a.NewSubmittedTask(reqCtx, msg) - if err := queue.Write(ctx, event); err != nil { - return fmt.Errorf("failed to submit a task: %w", err) + if config.AfterExecuteCallback != nil { + v1Config.AfterExecuteCallback = func(ctx v2.ExecutorContext, finalEvent *a2av2.TaskStatusUpdateEvent, err error) error { + legacyEvent := a2av0.FromV1TaskStatusUpdateEvent(finalEvent) + if cbErr := config.AfterExecuteCallback(executorContextWrapper{ctx}, legacyEvent, err); cbErr != nil { + return cbErr + } + newV1Event, convErr := a2av0.ToV1Event(legacyEvent) + if convErr != nil { + return convErr + } + if converted, ok := newV1Event.(*a2av2.TaskStatusUpdateEvent); ok { + *finalEvent = *converted + } + return nil } } - invocationMeta := toInvocationMeta(ctx, cfg, reqCtx) - - err = e.prepareSession(ctx, cfg, invocationMeta) - if err != nil { - event := toTaskFailedUpdateEvent(reqCtx, err, invocationMeta.eventMeta) - execCtx := newExecutorContext(ctx, invocationMeta, executorPlugin, content) - return e.writeFinalTaskStatus(execCtx, queue, nil, event, err) + if config.A2APartConverter != nil { + v1Config.A2APartConverter = func(ctx context.Context, a2aEvent a2av2.Event, part *a2av2.Part) (*genai.Part, error) { + legacyEvent, err := a2av0.FromV1Event(a2aEvent) + if err != nil { + return nil, fmt.Errorf("a2a event conversion failed: %w", err) + } + return config.A2APartConverter(ctx, legacyEvent, a2av0.FromV1Part(part)) + } } - event := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateWorking, nil) - event.Metadata = invocationMeta.eventMeta - if err := queue.Write(ctx, event); err != nil { - return err + if config.GenAIPartConverter != nil { + v1Config.GenAIPartConverter = func(ctx context.Context, adkEvent *session.Event, part *genai.Part) (*a2av2.Part, error) { + legacyPart, err := config.GenAIPartConverter(ctx, adkEvent, part) + if err != nil { + return nil, err + } + return a2av0.ToV1Part(legacyPart), nil + } } - var artifactTransform eventToArtifactTransform - if e.config.OutputMode == OutputArtifactPerEvent { - artifactTransform = newArtifactMaker(reqCtx) - } else { - artifactTransform = newLegacyArtifactMaker(reqCtx) + if config.A2AExecutionCleanupCallback != nil { + v1Config.A2AExecutionCleanupCallback = func(ctx context.Context, execCtx *a2asrvv2.ExecutorContext, subAgentCards []*a2av2.AgentCard, result a2av2.SendMessageResult, cause error) { + legacyReqCtx := toRequestContext(execCtx) + legacySubAgentCards := make([]*a2a.AgentCard, len(subAgentCards)) + for i, card := range subAgentCards { + legacySubAgentCards[i] = a2av0.FromV1AgentCard(card) + } + legacyEvent, err := a2av0.FromV1Event(result) + if err != nil { + log.Warn(ctx, "failed to convert SendMessageResult to legacy format", "error", err) + config.A2AExecutionCleanupCallback(ctx, legacyReqCtx, legacySubAgentCards, nil, errors.Join(cause, fmt.Errorf("SendMessageResult conversion failed: %w", err))) + return + } + legacyResult, ok := legacyEvent.(a2a.SendMessageResult) + if !ok { + log.Warn(ctx, "conversion result is not a2a.SendMessageResult", "type", fmt.Sprintf("%T", legacyResult)) + config.A2AExecutionCleanupCallback(ctx, legacyReqCtx, legacySubAgentCards, nil, errors.Join(cause, fmt.Errorf("conversion result is not a2a.SendMessageResult: %T", legacyEvent))) + return + } + config.A2AExecutionCleanupCallback(ctx, legacyReqCtx, legacySubAgentCards, legacyResult, cause) + } } - processor := newEventProcessor(reqCtx, invocationMeta, e.config.GenAIPartConverter, artifactTransform) - executorContext := newExecutorContext(ctx, invocationMeta, executorPlugin, content) - return e.process(executorContext, r, processor, queue) -} - -func (e *Executor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - event := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCanceled, nil) - event.Final = true - return queue.Write(ctx, event) + return &Executor{impl: v2.NewExecutor(v1Config)} } -func (e *Executor) Cleanup(ctx context.Context, reqCtx *a2asrv.RequestContext, result a2a.SendMessageResult, cause error) { - cfg, err := e.createRunnerConfig(ctx, reqCtx) +func (e *Executor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { + execCtx, err := toExecutorContext(ctx, reqCtx) if err != nil { - log.Error(ctx, "failed to create runner config", err) - return + return err } - remoteSubagents := findRemoteSubagents(cfg.Agent) - - // If task was in input-required and got successfully cancelled - run the cleanup logic - if reqCtx.StoredTask != nil && reqCtx.StoredTask.Status.State == a2a.TaskStateInputRequired { - if task, ok := result.(*a2a.Task); ok && task.Status.State == a2a.TaskStateCanceled && reqCtx.Message == nil { - if err := e.cancelChildInputRequiredTasks(ctx, reqCtx, reqCtx.StoredTask.Status, remoteSubagents); err != nil { - log.Warn(ctx, "failed to cancel subagent tasks waiting for input", "cause", err) - } + for event, err := range e.impl.Execute(ctx, execCtx) { + if err != nil { + return err } - } - - if e.config.A2AExecutionCleanupCallback != nil { - subAgentCards := make([]*a2a.AgentCard, len(remoteSubagents)) - for i, subagent := range remoteSubagents { - subAgentCards[i] = subagent.config.AgentCard + legacyEvent, lErr := a2av0.FromV1Event(event) + if lErr != nil { + return lErr } - e.config.A2AExecutionCleanupCallback(ctx, reqCtx, subAgentCards, result, cause) - } else if cause != nil { - if reqCtx.Message != nil { - log.Warn(ctx, "execution failed", "error", cause) - } else { - log.Warn(ctx, "cancellation failed", "error", cause) + if err := queue.Write(ctx, legacyEvent); err != nil { + return err } } -} - -func (e *Executor) cancelChildInputRequiredTasks(ctx context.Context, reqCtx *a2asrv.RequestContext, status a2a.TaskStatus, subagents []remoteAgent) error { - if len(subagents) == 0 { - return nil - } - cfg, err := e.createRunnerConfig(ctx, reqCtx) - if err != nil { - return fmt.Errorf("failed to create runner config: %w", err) - } - - meta := toInvocationMeta(ctx, cfg, reqCtx) - getSessionResponse, err := cfg.SessionService.Get(ctx, &session.GetRequest{ - AppName: cfg.AppName, - UserID: meta.userID, - SessionID: meta.sessionID, - }) - if err != nil { - return fmt.Errorf("failed to get a session: %w", err) - } + return nil +} - tasksToCancel, err := getSubagentTasksToCancel(ctx, status, getSessionResponse.Session) +func (e *Executor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { + v2ReqCtx, err := toExecutorContext(ctx, reqCtx) if err != nil { - return fmt.Errorf("subtask search failed: %w", err) - } - if len(tasksToCancel) == 0 { - return nil + return err } - var failures []error - clientCache := map[string]*a2aclient.Client{} - for _, task := range tasksToCancel { // TODO(yarolegovich): run in parallel (how to limit?) - remoteSubagentIdx := slices.IndexFunc(subagents, func(a remoteAgent) bool { return a.agent.Name() == task.agentName }) - if remoteSubagentIdx < 0 { - continue - } - remoteSubagent := subagents[remoteSubagentIdx] - client, ok := clientCache[task.agentName] - if !ok { - _, newClient, err := iremoteagent.CreateA2AClient(ctx, remoteSubagent.config) - if err != nil { - failures = append(failures, fmt.Errorf("failed to create A2A client: %w", err)) - continue - } - clientCache[task.agentName] = newClient - client = newClient - } - _, err = client.CancelTask(ctx, &a2a.TaskIDParams{ID: task.taskID}) + for event, err := range e.impl.Cancel(ctx, v2ReqCtx) { if err != nil { - failures = append(failures, fmt.Errorf("failed to cancel task: %w", err)) - continue - } - } - for _, client := range clientCache { - if err := client.Destroy(); err != nil { - failures = append(failures, fmt.Errorf("client destroy failed: %w", err)) - } - } - return errors.Join(failures...) -} - -// Processing failures should be delivered as Task failed events. An error is returned from this method if an event write fails. -func (e *Executor) process(ctx ExecutorContext, r Runner, processor *eventProcessor, q eventqueue.Queue) error { - meta := processor.meta - for adkEvent, adkErr := range r.Run(ctx, meta.userID, meta.sessionID, ctx.UserContent(), e.config.RunConfig) { - if adkErr != nil { - event := processor.makeTaskFailedEvent(fmt.Errorf("agent run failed: %w", adkErr), nil) - return e.writeFinalTaskStatus(ctx, q, processor.makeFinalArtifactUpdate(), event, adkErr) - } - - a2aEvent, pErr := processor.process(ctx, adkEvent) - if pErr == nil && a2aEvent != nil && e.config.AfterEventCallback != nil { - pErr = e.config.AfterEventCallback(ctx, adkEvent, a2aEvent) + return err } - - if pErr != nil { - event := processor.makeTaskFailedEvent(fmt.Errorf("processor failed: %w", pErr), adkEvent) - return e.writeFinalTaskStatus(ctx, q, processor.makeFinalArtifactUpdate(), event, pErr) + legacyEvent, lErr := a2av0.FromV1Event(event) + if lErr != nil { + return lErr } - - if a2aEvent != nil { - if err := q.Write(ctx, a2aEvent); err != nil { - return fmt.Errorf("event write failed: %w", err) - } + if err := queue.Write(ctx, legacyEvent); err != nil { + return err } } - finalStatus := processor.makeFinalStatusUpdate() - return e.writeFinalTaskStatus(ctx, q, processor.makeFinalArtifactUpdate(), finalStatus, nil) + return nil } -func (e *Executor) writeFinalTaskStatus( - ctx ExecutorContext, - queue eventqueue.Queue, - partialReset *a2a.TaskArtifactUpdateEvent, - status *a2a.TaskStatusUpdateEvent, - err error, -) error { - if e.config.AfterExecuteCallback != nil { - if err = e.config.AfterExecuteCallback(ctx, status, err); err != nil { - return fmt.Errorf("after execute: %w", err) - } - } - if partialReset != nil { - if err := queue.Write(ctx, partialReset); err != nil { - return fmt.Errorf("partial artifact update write failed: %w", err) - } - } - if err := queue.Write(ctx, status); err != nil { - return fmt.Errorf("%q state update event write failed: %w", status.Status.State, err) - } - return nil +// ExecutorContext provides read-only information about the context of an A2A agent execution. +// An execution starts with a user message and ends with a task in a terminal or input-required state. +type ExecutorContext interface { + context.Context + + // SessionID is ID of the session. It is passed as contextID in A2A request. + SessionID() string + // UserID is ID of the user who made the request. The information is either extracted from [a2asrv.CallContext] + // or derived from session ID for unauthenticated requests. + UserID() string + // AgentName is the name of the root agent. + AgentName() string + // ReadonlyState provides a view of the current session state. + ReadonlyState() session.ReadonlyState + // Events provides a readonly view of the current session events. + Events() session.Events + // UserContent is a converted A2A message which is passed to runner.Run. + UserContent() *genai.Content + // RequestContext contains information about the original A2A Request, the current task and related tasks. + RequestContext() *a2asrv.RequestContext } -func (e *Executor) prepareSession(ctx context.Context, cfg RunnerConfig, meta invocationMeta) error { - service := cfg.SessionService +type executorContextWrapper struct { + v2.ExecutorContext +} - _, err := service.Get(ctx, &session.GetRequest{ - AppName: cfg.AppName, - UserID: meta.userID, - SessionID: meta.sessionID, - }) - if err == nil { - return nil - } +func (w executorContextWrapper) RequestContext() *a2asrv.RequestContext { + v1Ctx := w.ExecutorContext.RequestContext() + return toRequestContext(v1Ctx) +} - _, err = service.Create(ctx, &session.CreateRequest{ - AppName: cfg.AppName, - UserID: meta.userID, - SessionID: meta.sessionID, - State: make(map[string]any), - }) - if err != nil { - return fmt.Errorf("failed to create a session: %w", err) +func toRequestContext(ctx *a2asrvv2.ExecutorContext) *a2asrv.RequestContext { + var relatedTasks []*a2a.Task + for _, t := range ctx.RelatedTasks { + relatedTasks = append(relatedTasks, a2av0.FromV1Task(t)) } - return nil + return &a2asrv.RequestContext{ + ContextID: ctx.ContextID, + Message: a2av0.FromV1Message(ctx.Message), + StoredTask: a2av0.FromV1Task(ctx.StoredTask), + TaskID: a2a.TaskID(ctx.TaskID), + Metadata: ctx.Metadata, + RelatedTasks: relatedTasks, + } } -func (e *Executor) createRunnerConfig(ctx context.Context, reqCtx *a2asrv.RequestContext) (RunnerConfig, error) { - executorPlugin, err := newExecutorPlugin() - if err != nil { - return RunnerConfig{}, fmt.Errorf("failed to create a2a-plugin: %w", err) +func toExecutorContext(ctx context.Context, reqCtx *a2asrv.RequestContext) (*a2asrvv2.ExecutorContext, error) { + var user *a2asrvv2.User + reqMeta := make(map[string][]string) + if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { + user = &a2asrvv2.User{Name: callCtx.User.Name(), Authenticated: callCtx.User.Authenticated()} + maps.Insert(reqMeta, callCtx.RequestMeta().List()) } - cfg, _, err := e.config.RunnerProvider(ctx, reqCtx, executorPlugin.plugin) + + storedTask, err := a2av0.ToV1Task(reqCtx.StoredTask) if err != nil { - return RunnerConfig{}, fmt.Errorf("runner provider failed: %w", err) + return nil, err } - return cfg, nil -} - -func newDefaultRunnerProvider(baseConfig runner.Config) RunnerProvider { - return func(ctx context.Context, reqCtx *a2asrv.RequestContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) { - if baseConfig.Agent == nil { - return RunnerConfig{}, nil, fmt.Errorf("runner.Config.Agent is not provided") - } - if baseConfig.Agent == nil { - return RunnerConfig{}, nil, fmt.Errorf("runner.Config.SessionService is not provided") - } - cfg := baseConfig - cfg.PluginConfig.Plugins = append(slices.Clone(cfg.PluginConfig.Plugins), plugin) - r, err := runner.New(cfg) + var relatedTasks []*a2av2.Task + for _, t := range reqCtx.RelatedTasks { + v1Task, err := a2av0.ToV1Task(t) if err != nil { - return RunnerConfig{}, nil, err + return nil, err } - return toInternalRunnerConfig(cfg), &defaultRunner{runner: r}, nil + relatedTasks = append(relatedTasks, v1Task) } -} - -type defaultRunner struct { - runner *runner.Runner -} -func (r *defaultRunner) Run(ctx context.Context, userID, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] { - return r.runner.Run(ctx, userID, sessionID, msg, cfg) -} + v1Msg, err := a2av0.ToV1Message(reqCtx.Message) + if err != nil { + return nil, err + } -func toInternalRunnerConfig(cfg runner.Config) RunnerConfig { - return RunnerConfig{Agent: cfg.Agent, AppName: cfg.AppName, SessionService: cfg.SessionService} + return &a2asrvv2.ExecutorContext{ + ContextID: reqCtx.ContextID, + Message: v1Msg, + TaskID: a2av2.TaskID(reqCtx.TaskID), + StoredTask: storedTask, + RelatedTasks: relatedTasks, + Metadata: reqCtx.Metadata, + User: user, + ServiceParams: a2asrvv2.NewServiceParams(reqMeta), + }, nil } diff --git a/server/adka2a/agent_card.go b/server/adka2a/v2/agent_card.go similarity index 99% rename from server/adka2a/agent_card.go rename to server/adka2a/v2/agent_card.go index 27a13a6b9..f9d82133f 100644 --- a/server/adka2a/agent_card.go +++ b/server/adka2a/v2/agent_card.go @@ -20,7 +20,7 @@ import ( "slices" "strings" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "google.golang.org/adk/agent" "google.golang.org/adk/agent/workflowagents/loopagent" diff --git a/server/adka2a/agent_card_test.go b/server/adka2a/v2/agent_card_test.go similarity index 99% rename from server/adka2a/agent_card_test.go rename to server/adka2a/v2/agent_card_test.go index 628f1e927..d6990615b 100644 --- a/server/adka2a/agent_card_test.go +++ b/server/adka2a/v2/agent_card_test.go @@ -17,7 +17,7 @@ package adka2a import ( "testing" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "github.com/google/go-cmp/cmp" "google.golang.org/adk/agent" diff --git a/server/adka2a/doc.go b/server/adka2a/v2/doc.go similarity index 91% rename from server/adka2a/doc.go rename to server/adka2a/v2/doc.go index eb0ce7cad..07495145e 100644 --- a/server/adka2a/doc.go +++ b/server/adka2a/v2/doc.go @@ -12,5 +12,5 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package adka2a allows to expose ADK agents via A2A. +// Package adka2a allows exposing ADK agents via A2A. package adka2a diff --git a/server/adka2a/events.go b/server/adka2a/v2/events.go similarity index 80% rename from server/adka2a/events.go rename to server/adka2a/v2/events.go index 828e38404..7a47cdd67 100644 --- a/server/adka2a/events.go +++ b/server/adka2a/v2/events.go @@ -19,7 +19,7 @@ import ( "fmt" "maps" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "google.golang.org/genai" "google.golang.org/adk/agent" @@ -39,8 +39,11 @@ func EventToMessage(event *session.Event) (*a2a.Message, error) { if event == nil { return nil, nil } - - parts, err := ToA2AParts(event.Content.Parts, event.LongRunningToolIDs) + var eventParts []*genai.Part + if event.Content != nil { + eventParts = event.Content.Parts + } + parts, err := ToA2AParts(eventParts, event.LongRunningToolIDs) if err != nil { return nil, fmt.Errorf("part conversion failed: %w", err) } @@ -58,7 +61,13 @@ func EventToMessage(event *session.Event) (*a2a.Message, error) { msg := a2a.NewMessage(role, parts...) msg.Metadata = setActionsMeta(msg.Metadata, event.Actions) - maps.Copy(msg.Metadata, eventMeta) + if len(eventMeta) > 0 { + if msg.Metadata == nil { + msg.Metadata = maps.Clone(eventMeta) + } else { + maps.Copy(msg.Metadata, eventMeta) + } + } return msg, nil } @@ -70,7 +79,7 @@ func ToSessionEvent(ctx agent.InvocationContext, event a2a.Event) (*session.Even // ToSessionEventWithParts converts the provided a2a event to session event with custom part converter. func ToSessionEventWithParts(ctx agent.InvocationContext, event a2a.Event, partConverter A2APartConverter) (*session.Event, error) { if partConverter == nil { - partConverter = func(ctx context.Context, a2aEvent a2a.Event, part a2a.Part) (*genai.Part, error) { + partConverter = func(ctx context.Context, a2aEvent a2a.Event, part *a2a.Part) (*genai.Part, error) { return ToGenAIPart(part) } } @@ -96,7 +105,6 @@ func ToSessionEventWithParts(ctx agent.InvocationContext, event a2a.Event, partC if len(event.Content.Parts) == 0 { return nil, nil } - event.LongRunningToolIDs = getLongRunningToolIDs(v.Artifact.Parts, event.Content.Parts) if err := processA2AMeta(v, event); err != nil { return nil, fmt.Errorf("metadata processing failed: %w", err) } @@ -112,25 +120,30 @@ func ToSessionEventWithParts(ctx agent.InvocationContext, event a2a.Event, partC return event, nil case *a2a.TaskStatusUpdateEvent: - if v.Final { + if v.Status.State.Terminal() || v.Status.State == a2a.TaskStateInputRequired { return finalTaskStatusUpdateToEvent(ctx, v, partConverter) } if v.Status.Message == nil { return nil, nil } event, err := messageToEvent(ctx, v.Status.Message, partConverter) - event.TurnComplete = false if err != nil { return nil, fmt.Errorf("custom metadata conversion failed: %w", err) } + if event == nil { + return nil, nil + } + event.TurnComplete = false if len(event.Content.Parts) == 0 { return nil, nil } if err := processA2AMeta(v, event); err != nil { return nil, fmt.Errorf("metadata processing failed: %w", err) } - for _, part := range event.Content.Parts { - part.Thought = true + for _, p := range event.Content.Parts { + if p.Text != "" { + p.Thought = true + } } event.Partial = true return event, nil @@ -209,13 +222,14 @@ func artifactUpdateEventToEvent(ctx agent.InvocationContext, update *a2a.TaskArt return nil, nil } - parts, err := convertParts(ctx, update, update.Artifact.Parts, partConverter) + allParts, err := convertParts(ctx, update, update.Artifact.Parts, partConverter) if err != nil { return nil, fmt.Errorf("failed to convert artifact parts: %w", err) } event := NewRemoteAgentEvent(ctx) - event.Content = genai.NewContentFromParts(parts, genai.RoleModel) + event.Content = genai.NewContentFromParts(filterNilParts(allParts), genai.RoleModel) + event.LongRunningToolIDs = getLongRunningToolIDs(update.Artifact.Parts, allParts) return event, nil } @@ -227,25 +241,26 @@ func taskToEvent(ctx agent.InvocationContext, task *a2a.Task, partConverter A2AP var parts []*genai.Part var longRunningToolIDs []string for _, artifact := range task.Artifacts { - artifactParts, err := convertParts(ctx, task, artifact.Parts, partConverter) + allParts, err := convertParts(ctx, task, artifact.Parts, partConverter) if err != nil { return nil, fmt.Errorf("failed to convert artifact parts: %w", err) } - lrtIDs := getLongRunningToolIDs(artifact.Parts, artifactParts) + lrtIDs := getLongRunningToolIDs(artifact.Parts, allParts) - parts = append(parts, artifactParts...) + parts = append(parts, filterNilParts(allParts)...) longRunningToolIDs = append(longRunningToolIDs, lrtIDs...) } event := NewRemoteAgentEvent(ctx) if task.Status.Message != nil { - msgParts, err := convertParts(ctx, task, task.Status.Message.Parts, partConverter) + allMsgParts, err := convertParts(ctx, task, task.Status.Message.Parts, partConverter) if err != nil { return nil, fmt.Errorf("failed to convert status message parts: %w", err) } - lrtIDs := getLongRunningToolIDs(task.Status.Message.Parts, msgParts) + lrtIDs := getLongRunningToolIDs(task.Status.Message.Parts, allMsgParts) + msgParts := filterNilParts(allMsgParts) if task.Status.State == a2a.TaskStateFailed && len(msgParts) == 1 && msgParts[0].Text != "" { event.ErrorMessage = msgParts[0].Text @@ -279,14 +294,15 @@ func finalTaskStatusUpdateToEvent(ctx agent.InvocationContext, update *a2a.TaskS event := NewRemoteAgentEvent(ctx) - var parts []*genai.Part + var allParts []*genai.Part var err error if update.Status.Message != nil { - parts, err = convertParts(ctx, update, update.Status.Message.Parts, partConverter) + allParts, err = convertParts(ctx, update, update.Status.Message.Parts, partConverter) if err != nil { return nil, fmt.Errorf("failed to convert status message parts: %w", err) } } + parts := filterNilParts(allParts) if update.Status.State == a2a.TaskStateFailed && len(parts) == 1 && parts[0].Text != "" { event.ErrorMessage = parts[0].Text } else if len(parts) > 0 { @@ -296,20 +312,22 @@ func finalTaskStatusUpdateToEvent(ctx agent.InvocationContext, update *a2a.TaskS return nil, fmt.Errorf("metadata processing failed: %w", err) } if update.Status.Message != nil { - event.LongRunningToolIDs = getLongRunningToolIDs(update.Status.Message.Parts, parts) + event.LongRunningToolIDs = getLongRunningToolIDs(update.Status.Message.Parts, allParts) } event.TurnComplete = true return event, nil } -func getLongRunningToolIDs(parts []a2a.Part, converted []*genai.Part) []string { +func getLongRunningToolIDs(parts []*a2a.Part, converted []*genai.Part) []string { var ids []string for i, part := range parts { - dp, ok := part.(a2a.DataPart) - if !ok { + if part.Data() == nil { continue } - if longRunning, ok := dp.Metadata[a2aDataPartMetaLongRunningKey].(bool); ok && longRunning { + if longRunning, ok := part.Meta()[a2aDataPartMetaLongRunningKey].(bool); ok && longRunning { + if i >= len(converted) || converted[i] == nil { + continue + } fnCall := converted[i] if fnCall.FunctionCall == nil { // TODO(yarolegovich): log a warning @@ -339,16 +357,28 @@ func toEventActions(meta map[string]any) session.EventActions { return result } -func convertParts(ctx agent.InvocationContext, event a2a.Event, parts []a2a.Part, partConverter A2APartConverter) ([]*genai.Part, error) { - var genaiParts []*genai.Part - for _, part := range parts { +// convertParts converts A2A parts to GenAI parts using the provided converter. +// The returned slice preserves index alignment with the input: parts for which +// the converter returns nil are kept as nil entries. Use filterNilParts to get a +// dense slice suitable for content creation. +func convertParts(ctx agent.InvocationContext, event a2a.Event, parts []*a2a.Part, partConverter A2APartConverter) ([]*genai.Part, error) { + genaiParts := make([]*genai.Part, len(parts)) + for i, part := range parts { genaiPart, err := partConverter(ctx, event, part) if err != nil { return nil, fmt.Errorf("failed to convert part: %w", err) } - if genaiPart != nil { - genaiParts = append(genaiParts, genaiPart) - } + genaiParts[i] = genaiPart } return genaiParts, nil } + +func filterNilParts(parts []*genai.Part) []*genai.Part { + var result []*genai.Part + for _, p := range parts { + if p != nil { + result = append(result, p) + } + } + return result +} diff --git a/server/adka2a/events_test.go b/server/adka2a/v2/events_test.go similarity index 85% rename from server/adka2a/events_test.go rename to server/adka2a/v2/events_test.go index e0c6e563b..2d248c60d 100644 --- a/server/adka2a/events_test.go +++ b/server/adka2a/v2/events_test.go @@ -18,7 +18,7 @@ import ( "context" "testing" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genai" @@ -46,7 +46,7 @@ func TestToSessionEvent(t *testing.T) { { name: "message", input: &a2a.Message{ - Parts: []a2a.Part{a2a.TextPart{Text: "foo"}}, + Parts: a2a.ContentParts{a2a.NewTextPart("foo")}, TaskID: taskID, ContextID: contextID, Metadata: map[string]any{ @@ -77,7 +77,7 @@ func TestToSessionEvent(t *testing.T) { { name: "nil values", input: &a2a.Message{ - Parts: []a2a.Part{a2a.TextPart{Text: "foo"}}, + Parts: a2a.ContentParts{a2a.NewTextPart("foo")}, TaskID: taskID, ContextID: contextID, Metadata: map[string]any{ @@ -101,6 +101,7 @@ func TestToSessionEvent(t *testing.T) { input: &a2a.Message{ TaskID: taskID, ContextID: contextID, + Parts: a2a.ContentParts{}, }, want: &session.Event{ LLMResponse: model.LLMResponse{ @@ -122,19 +123,20 @@ func TestToSessionEvent(t *testing.T) { Artifacts: []*a2a.Artifact{ { // long running key is ignored for non-input-required states ID: a2a.NewArtifactID(), - Parts: []a2a.Part{ - a2a.DataPart{ - Data: map[string]any{"id": "get_weather", "args": map[string]any{"city": "Warsaw"}, "name": "GetWeather"}, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, a2aDataPartMetaLongRunningKey: true}, - }, + Parts: a2a.ContentParts{ + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"id": "get_weather", "args": map[string]any{"city": "Warsaw"}, "name": "GetWeather"}) + p.Metadata = map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, a2aDataPartMetaLongRunningKey: true} + return p + }(), }, }, - {ID: a2a.NewArtifactID(), Parts: a2a.ContentParts{a2a.TextPart{Text: "foo"}}}, - {ID: a2a.NewArtifactID(), Parts: a2a.ContentParts{a2a.TextPart{Text: "bar"}}}, + {ID: a2a.NewArtifactID(), Parts: a2a.ContentParts{a2a.NewTextPart("foo")}}, + {ID: a2a.NewArtifactID(), Parts: a2a.ContentParts{a2a.NewTextPart("bar")}}, }, Status: a2a.TaskStatus{ State: a2a.TaskStateCompleted, - Message: a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "done"}), + Message: a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("done")), }, Metadata: map[string]any{ metadataGroundingKey: map[string]any{"sourceFlaggingUris": []any{map[string]any{"sourceId": "id1"}}}, @@ -207,11 +209,12 @@ func TestToSessionEvent(t *testing.T) { Artifacts: []*a2a.Artifact{ { ID: a2a.NewArtifactID(), - Parts: []a2a.Part{ - a2a.DataPart{ - Data: map[string]any{"id": "get_weather", "args": map[string]any{"city": "Warsaw"}, "name": "GetWeather"}, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, a2aDataPartMetaLongRunningKey: true}, - }, + Parts: a2a.ContentParts{ + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"id": "get_weather", "args": map[string]any{"city": "Warsaw"}, "name": "GetWeather"}) + p.Metadata = map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, a2aDataPartMetaLongRunningKey: true} + return p + }(), }, }, }, @@ -247,7 +250,7 @@ func TestToSessionEvent(t *testing.T) { TaskID: taskID, ContextID: contextID, Artifact: &a2a.Artifact{ - ID: a2a.NewArtifactID(), Parts: a2a.ContentParts{a2a.TextPart{Text: "foo"}, a2a.TextPart{Text: "bar"}}, + ID: a2a.NewArtifactID(), Parts: a2a.ContentParts{a2a.NewTextPart("foo"), a2a.NewTextPart("bar")}, }, Metadata: map[string]any{ metadataGroundingKey: map[string]any{"sourceFlaggingUris": []any{map[string]any{"sourceId": "id1"}}}, @@ -281,7 +284,7 @@ func TestToSessionEvent(t *testing.T) { ContextID: contextID, Artifact: &a2a.Artifact{ ID: a2a.NewArtifactID(), - Parts: []a2a.Part{}, + Parts: a2a.ContentParts{}, }, }, want: nil, @@ -293,11 +296,12 @@ func TestToSessionEvent(t *testing.T) { ContextID: contextID, Artifact: &a2a.Artifact{ ID: a2a.NewArtifactID(), - Parts: []a2a.Part{ - a2a.DataPart{ - Data: map[string]any{"id": "get_weather", "args": map[string]any{"city": "Warsaw"}, "name": "GetWeather"}, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, a2aDataPartMetaLongRunningKey: true}, - }, + Parts: a2a.ContentParts{ + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"id": "get_weather", "args": map[string]any{"city": "Warsaw"}, "name": "GetWeather"}) + p.Metadata = map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, a2aDataPartMetaLongRunningKey: true} + return p + }(), }, }, }, @@ -328,10 +332,10 @@ func TestToSessionEvent(t *testing.T) { input: &a2a.TaskStatusUpdateEvent{ TaskID: taskID, ContextID: contextID, - Final: true, Status: a2a.TaskStatus{ + State: a2a.TaskStateCompleted, Message: &a2a.Message{ - Parts: []a2a.Part{a2a.TextPart{Text: "foo"}}, + Parts: a2a.ContentParts{a2a.NewTextPart("foo")}, }, }, Metadata: map[string]any{ @@ -360,7 +364,7 @@ func TestToSessionEvent(t *testing.T) { }, { name: "final task status update without message", - input: &a2a.TaskStatusUpdateEvent{TaskID: taskID, ContextID: contextID, Final: true}, + input: &a2a.TaskStatusUpdateEvent{TaskID: taskID, ContextID: contextID, Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}}, want: &session.Event{ LLMResponse: model.LLMResponse{ CustomMetadata: map[string]any{ @@ -379,9 +383,9 @@ func TestToSessionEvent(t *testing.T) { TaskID: taskID, ContextID: contextID, Status: a2a.TaskStatus{ - State: a2a.TaskStateCompleted, + State: a2a.TaskStateWorking, Message: &a2a.Message{ - Parts: []a2a.Part{a2a.TextPart{Text: "foo"}}, + Parts: a2a.ContentParts{a2a.NewTextPart("foo")}, }, }, }, @@ -408,10 +412,9 @@ func TestToSessionEvent(t *testing.T) { input: &a2a.TaskStatusUpdateEvent{ TaskID: taskID, ContextID: contextID, - Final: true, Status: a2a.TaskStatus{ State: a2a.TaskStateFailed, - Message: &a2a.Message{Parts: []a2a.Part{a2a.TextPart{Text: "failed with an error"}}}, + Message: &a2a.Message{Parts: a2a.ContentParts{a2a.NewTextPart("failed with an error")}}, }, }, want: &session.Event{ @@ -435,10 +438,10 @@ func TestToSessionEvent(t *testing.T) { Artifacts: []*a2a.Artifact{ { ID: "artifact-1", - Parts: []a2a.Part{ - a2a.TextPart{Text: "Checking weather..."}, - a2a.DataPart{ - Data: map[string]any{"id": "tool_1", "name": "GetWeather", "args": map[string]any{"city": "London"}}, + Parts: []*a2a.Part{ + a2a.NewTextPart("Checking weather..."), + { + Content: a2a.Data{Value: map[string]any{"id": "tool_1", "name": "GetWeather", "args": map[string]any{"city": "London"}}}, Metadata: map[string]any{ a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, a2aDataPartMetaLongRunningKey: true, @@ -448,9 +451,9 @@ func TestToSessionEvent(t *testing.T) { }, { ID: "artifact-2", - Parts: []a2a.Part{ - a2a.DataPart{ - Data: map[string]any{"id": "tool_2", "name": "GetNews", "args": map[string]any{"topic": "tech"}}, + Parts: []*a2a.Part{ + { + Content: a2a.Data{Value: map[string]any{"id": "tool_2", "name": "GetNews", "args": map[string]any{"topic": "tech"}}}, Metadata: map[string]any{ a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, a2aDataPartMetaLongRunningKey: true, @@ -486,7 +489,7 @@ func TestToSessionEvent(t *testing.T) { ContextID: contextID, Status: a2a.TaskStatus{ State: a2a.TaskStateFailed, - Message: &a2a.Message{Parts: []a2a.Part{a2a.TextPart{Text: "failed with an error"}}}, + Message: &a2a.Message{Parts: a2a.ContentParts{a2a.NewTextPart("failed with an error")}}, }, }, want: &session.Event{ @@ -528,11 +531,11 @@ func TestToSessionEventWithParts_NilResultFiltered(t *testing.T) { t.Fatalf("failed to create an agent: %v", err) } - keepPart := a2a.TextPart{Text: "KEEP"} - dropPart := a2a.TextPart{Text: "DROP"} + keepPart := a2a.NewTextPart("KEEP") + dropPart := a2a.NewTextPart("DROP") - filterConverter := func(ctx context.Context, ev a2a.Event, p a2a.Part) (*genai.Part, error) { - if tp, ok := p.(a2a.TextPart); ok && tp.Text == "DROP" { + filterConverter := func(ctx context.Context, ev a2a.Event, p *a2a.Part) (*genai.Part, error) { + if p.Text() == "DROP" { return nil, nil } return ToGenAIPart(p) @@ -547,23 +550,23 @@ func TestToSessionEventWithParts_NilResultFiltered(t *testing.T) { input: &a2a.Task{ ID: taskID, ContextID: contextID, - Artifacts: []*a2a.Artifact{{Parts: []a2a.Part{keepPart, dropPart}}}, + Artifacts: []*a2a.Artifact{{Parts: []*a2a.Part{keepPart, dropPart}}}, Status: a2a.TaskStatus{ State: a2a.TaskStateCompleted, - Message: &a2a.Message{Parts: []a2a.Part{keepPart, dropPart}}, + Message: &a2a.Message{Parts: []*a2a.Part{keepPart, dropPart}}, }, }, }, { name: "message event", input: &a2a.Message{ - Parts: []a2a.Part{keepPart, dropPart}, + Parts: []*a2a.Part{keepPart, dropPart}, }, }, { name: "artifact update event", input: &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: []a2a.Part{keepPart, dropPart}}, + Artifact: &a2a.Artifact{Parts: []*a2a.Part{keepPart, dropPart}}, }, }, { @@ -571,9 +574,9 @@ func TestToSessionEventWithParts_NilResultFiltered(t *testing.T) { input: &a2a.TaskStatusUpdateEvent{ TaskID: taskID, ContextID: contextID, - Final: true, Status: a2a.TaskStatus{ - Message: &a2a.Message{Parts: []a2a.Part{keepPart, dropPart}}, + State: a2a.TaskStateCompleted, + Message: &a2a.Message{Parts: []*a2a.Part{keepPart, dropPart}}, }, }, }, diff --git a/server/adka2a/v2/executor.go b/server/adka2a/v2/executor.go new file mode 100644 index 000000000..a7fa25c99 --- /dev/null +++ b/server/adka2a/v2/executor.go @@ -0,0 +1,459 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package adka2a + +import ( + "context" + "errors" + "fmt" + "iter" + "slices" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/a2aproject/a2a-go/v2/log" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + iremoteagent "google.golang.org/adk/internal/agent/remoteagent" + "google.golang.org/adk/plugin" + "google.golang.org/adk/runner" + "google.golang.org/adk/session" +) + +// BeforeExecuteCallback is the callback which will be called before an execution is started. +type BeforeExecuteCallback func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) (context.Context, error) + +// AfterEventCallback is the callback which will be called after an ADK event is converted to an A2A event. +type AfterEventCallback func(ctx ExecutorContext, event *session.Event, processed *a2a.TaskArtifactUpdateEvent) error + +// AfterExecuteCallback is the callback which will be called after an execution resolved into a completed or failed task. +type AfterExecuteCallback func(ctx ExecutorContext, finalEvent *a2a.TaskStatusUpdateEvent, err error) error + +// A2APartConverter is a custom converter for converting A2A parts to GenAI parts. +// Implementations should generally remember to leverage adka2a.ToGenAiPart for default conversions +// nil returns are considered intentionally dropped parts. +type A2APartConverter func(ctx context.Context, a2aEvent a2a.Event, part *a2a.Part) (*genai.Part, error) + +// GenAIPartConverter is a custom converter for converting GenAI parts to A2A parts. +// Implementations should generally remember to leverage adka2a.ToA2APart for default conversions +// nil returns are considered intentionally dropped parts. +type GenAIPartConverter func(ctx context.Context, adkEvent *session.Event, part *genai.Part) (*a2a.Part, error) + +// A2AExecutionCleanupCallback is a callback which will be called after an execution or cancellation has completed or failed. +type A2AExecutionCleanupCallback func(ctx context.Context, reqCtx *a2asrv.ExecutorContext, subAgentCards []*a2a.AgentCard, result a2a.SendMessageResult, cause error) + +// OutputMode controls how artifacts are produced. +type OutputMode string + +const ( + // OutputArtifactPerRun produces a single artifact per [runner.Runner.Run]. + OutputArtifactPerRun OutputMode = "artifact-per-run" + // OutputArtifactPerEvent produces an artifact per non-partial [session.Event]. + // While agent is emitting events an artifact is build incrementally (parts are append to it). + // The next partial event replaces accumulated contents and seals the artifact, meaning + // the next event from this agent will create a new artifact. + OutputArtifactPerEvent OutputMode = "artifact-per-event" +) + +// Runner is an interface matching [runner.Runner] API. +// It exists to let users use custom runner implementations with A2A agent executor. +type Runner interface { + // Run runs the agent for the given user input, yielding events from agents. + Run(ctx context.Context, userID, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] +} + +// RunnerProvider is a [Runner] factory function. The provided plugin must be installed in the returned [Runner] for +// callbacks taking [ExecutorContext] to work correctly. +type RunnerProvider func(ctx context.Context, reqCtx *a2asrv.ExecutorContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) + +// RunnerConfig is part of the runner configuration executor code depends on. +// Custom [RunnerProvider] needs to return it back to callers. +type RunnerConfig struct { + // AppName is the name of the application used in [session.Service] keys and A2A event metadata. + AppName string + // Agent is the root agent. + Agent agent.Agent + // SessionService is the session service to use. + SessionService session.Service +} + +// ExecutorConfig allows to configure Executor. +type ExecutorConfig struct { + // RunnerConfig is used for creating a default RunnerProvider. The field is ignored when RunnerProvider is set. + RunnerConfig runner.Config + // RunnerProvider is a function which allows to control how a runner is created. + // If not provided the default provider is used which calls [runner.New] with the RunnerConfig field. + RunnerProvider RunnerProvider + + // RunConfig is the configuration which will be passed to [runner.Runner.Run] during A2A Execute invocation. + RunConfig agent.RunConfig + + // BeforeExecuteCallback is the callback which will be called before an execution is started. + // It can be used to instrument a context or prevent the execution by returning an error. + BeforeExecuteCallback BeforeExecuteCallback + + // AfterEventCallback is the callback which will be called after an ADK event is successfully converted to an A2A event. + // This gives an opportunity to enrich the event with additional metadata or abort the execution by returning an error. + // The callback is not invoked for errors originating from ADK or event processing. Such errors are converted to + // TaskStatusUpdateEvent-s with TaskStateFailed state. If needed these can be intercepted using AfterExecuteCallback. + AfterEventCallback AfterEventCallback + + // AfterExecuteCallback is the callback which will be called after an execution resolved into a completed or failed task. + // This gives an opportunity to enrich the event with additional metadata or log it. + AfterExecuteCallback AfterExecuteCallback + + // A2APartConverter is a custom converter for converting A2A parts to GenAI parts. + // Implementations should generally remember to leverage [adka2a.ToGenAiPart] for default conversions + // nil returns are considered intentionally dropped parts. + A2APartConverter A2APartConverter + + // GenAIPartConverter is a custom converter for converting GenAI parts to A2A parts. + // Implementations should generally remember to leverage [adka2a.ToA2APart] for default conversions + // nil returns are considered intentionally dropped parts. + GenAIPartConverter GenAIPartConverter + + // OutputMode controls how artifacts are produced. Can be [OutputArtifactPerRun] or [OutputArtifactPerEvent]. + // Defaults to [OutputArtifactPerRun]. + OutputMode OutputMode + + // A2AExecutionCleanupCallback is a callback which will be called after an execution or cancellation has completed or failed. + // If not provided, the default behavior is to log the failure cause, if any. + A2AExecutionCleanupCallback A2AExecutionCleanupCallback +} + +var _ a2asrv.AgentExecutor = (*Executor)(nil) + +// Executor invokes an ADK agent and translates [session.Event]-s to [a2a.Event]-s according to the following rules: +// - If the input doesn't reference any a2a.Task, produce a Task with TaskStateSubmitted state. +// - Right before runner.Runner invocation, produce TaskStatusUpdateEvent with TaskStateWorking. +// - For every session.Event produce a TaskArtifactUpdateEvent{Append=true} with transformed parts. +// - After the last session.Event is processed produce an empty TaskArtifactUpdateEvent{Append=true} with LastChunk=true, +// if at least one artifact update was produced during the run. +// - If there was an LLMResponse with non-zero error code, produce a TaskStatusUpdateEvent with TaskStateFailed. +// Else if there was an LLMResponse with long-running tool invocation, produce a TaskStatusUpdateEvent with TaskStateInputRequired. +// Else produce a TaskStatusUpdateEvent with TaskStateCompleted. +type Executor struct { + config ExecutorConfig +} + +// NewExecutor creates an initialized [Executor] instance. +func NewExecutor(config ExecutorConfig) *Executor { + if config.RunnerProvider == nil { + config.RunnerProvider = newDefaultRunnerProvider(config.RunnerConfig) + } + return &Executor{config: config} +} + +func (e *Executor) Execute(ctx context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + msg := execCtx.Message + if msg == nil { + yield(nil, fmt.Errorf("message not provided")) + return + } + content, err := toGenAIContent(ctx, msg, e.config.A2APartConverter) + if err != nil { + yield(nil, fmt.Errorf("a2a message conversion failed: %w", err)) + return + } + + executorPlugin, err := newExecutorPlugin() + if err != nil { + yield(nil, fmt.Errorf("failed to create a2a-executor plugin: %w", err)) + return + } + + cfg, r, err := e.config.RunnerProvider(ctx, execCtx, executorPlugin.plugin) + if err != nil { + yield(nil, fmt.Errorf("failed to create a runner: %w", err)) + return + } + if e.config.BeforeExecuteCallback != nil { + ctx, err = e.config.BeforeExecuteCallback(ctx, execCtx) + if err != nil { + yield(nil, fmt.Errorf("before execute: %w", err)) + return + } + if ctx == nil { + yield(nil, fmt.Errorf("before execute: callback returned nil context")) + return + } + } + + if event, err := HandleInputRequired(execCtx, content); event != nil || err != nil { + if err != nil { + yield(nil, err) + } else { + yield(event, nil) + } + return + } + + if execCtx.StoredTask == nil { + event := a2a.NewSubmittedTask(execCtx, msg) + if !yield(event, nil) { + return + } + } + + invocationMeta := toInvocationMeta(ctx, cfg, execCtx) + + err = e.prepareSession(ctx, cfg, invocationMeta) + if err != nil { + statusEvent := toTaskFailedUpdateEvent(execCtx, err, invocationMeta.eventMeta) + execCtx := newExecutorContext(ctx, invocationMeta, executorPlugin, content) + e.writeFinalTaskStatus(execCtx, yield, nil, statusEvent, err) + return + } + + event := a2a.NewStatusUpdateEvent(execCtx, a2a.TaskStateWorking, nil) + event.Metadata = invocationMeta.eventMeta + if !yield(event, nil) { + return + } + + var artifactTransform eventToArtifactTransform + if e.config.OutputMode == OutputArtifactPerEvent { + artifactTransform = newArtifactMaker(execCtx) + } else { + artifactTransform = newLegacyArtifactMaker(execCtx) + } + + processor := newEventProcessor(execCtx, invocationMeta, e.config.GenAIPartConverter, artifactTransform) + executorContext := newExecutorContext(ctx, invocationMeta, executorPlugin, content) + e.process(executorContext, r, processor, yield) + } +} + +func (e *Executor) Cancel(ctx context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + event := a2a.NewStatusUpdateEvent(execCtx, a2a.TaskStateCanceled, nil) + yield(event, nil) + } +} + +func (e *Executor) Cleanup(ctx context.Context, execCtx *a2asrv.ExecutorContext, result a2a.SendMessageResult, cause error) { + cfg, err := e.createRunnerConfig(ctx, execCtx) + if err != nil { + log.Error(ctx, "failed to create runner config", err) + + if e.config.A2AExecutionCleanupCallback != nil { + e.config.A2AExecutionCleanupCallback(ctx, execCtx, []*a2a.AgentCard{}, result, cause) + } + return + } + + remoteSubagents := findRemoteSubagents(cfg.Agent) + + // If task was in input-required and got successfully cancelled - run the cleanup logic + if execCtx.StoredTask != nil && execCtx.StoredTask.Status.State == a2a.TaskStateInputRequired { + if task, ok := result.(*a2a.Task); ok && task.Status.State == a2a.TaskStateCanceled && execCtx.Message == nil { + if err := e.cancelChildInputRequiredTasks(ctx, execCtx, execCtx.StoredTask.Status, cfg, remoteSubagents); err != nil { + log.Warn(ctx, "failed to cancel subagent tasks waiting for input", "cause", err) + } + } + } + + if e.config.A2AExecutionCleanupCallback != nil { + subAgentCards := make([]*a2a.AgentCard, len(remoteSubagents)) + for i, subagent := range remoteSubagents { + subAgentCards[i] = subagent.config.AgentCard + } + e.config.A2AExecutionCleanupCallback(ctx, execCtx, subAgentCards, result, cause) + } else if cause != nil { + if execCtx.Message != nil { + log.Warn(ctx, "execution failed", "error", cause) + } else { + log.Warn(ctx, "cancellation failed", "error", cause) + } + } +} + +func (e *Executor) cancelChildInputRequiredTasks(ctx context.Context, reqCtx *a2asrv.ExecutorContext, status a2a.TaskStatus, cfg RunnerConfig, subagents []remoteAgent) error { + if len(subagents) == 0 { + return nil + } + + meta := toInvocationMeta(ctx, cfg, reqCtx) + getSessionResponse, err := cfg.SessionService.Get(ctx, &session.GetRequest{ + AppName: cfg.AppName, + UserID: meta.userID, + SessionID: meta.sessionID, + }) + if err != nil { + return fmt.Errorf("failed to get a session: %w", err) + } + + tasksToCancel, err := getSubagentTasksToCancel(ctx, status, getSessionResponse.Session) + if err != nil { + return fmt.Errorf("subtask search failed: %w", err) + } + if len(tasksToCancel) == 0 { + return nil + } + + var failures []error + clientCache := map[string]iremoteagent.A2AClient{} + for _, task := range tasksToCancel { // TODO(yarolegovich): run in parallel (how to limit?) + remoteSubagentIdx := slices.IndexFunc(subagents, func(a remoteAgent) bool { return a.agent.Name() == task.agentName }) + if remoteSubagentIdx < 0 { + continue + } + remoteSubagent := subagents[remoteSubagentIdx] + client, ok := clientCache[task.agentName] + if !ok { + _, newClient, err := iremoteagent.CreateA2AClient(ctx, remoteSubagent.config) + if err != nil { + failures = append(failures, fmt.Errorf("failed to create A2A client: %w", err)) + continue + } + clientCache[task.agentName] = newClient + client = newClient + } + _, err = client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: task.taskID}) + if err != nil { + failures = append(failures, fmt.Errorf("failed to cancel task: %w", err)) + continue + } + } + for _, client := range clientCache { + if err := client.Destroy(); err != nil { + failures = append(failures, fmt.Errorf("client destroy failed: %w", err)) + } + } + return errors.Join(failures...) +} + +// Processing failures should be delivered as Task failed events. An error is returned from this method if an event write fails. +func (e *Executor) process(ctx ExecutorContext, r Runner, processor *eventProcessor, yield func(a2a.Event, error) bool) { + meta := processor.meta + for adkEvent, adkErr := range r.Run(ctx, meta.userID, meta.sessionID, ctx.UserContent(), e.config.RunConfig) { + if adkErr != nil { + event := processor.makeTaskFailedEvent(ctx, fmt.Errorf("agent run failed: %w", adkErr), nil) + e.writeFinalTaskStatus(ctx, yield, processor.makeFinalArtifactUpdate(), event, adkErr) + return + } + + a2aEvent, pErr := processor.process(ctx, adkEvent) + if pErr == nil && a2aEvent != nil && e.config.AfterEventCallback != nil { + pErr = e.config.AfterEventCallback(ctx, adkEvent, a2aEvent) + } + + if pErr != nil { + event := processor.makeTaskFailedEvent(ctx, fmt.Errorf("processor failed: %w", pErr), adkEvent) + e.writeFinalTaskStatus(ctx, yield, processor.makeFinalArtifactUpdate(), event, pErr) + return + } + + if a2aEvent != nil { + if !yield(a2aEvent, nil) { + return + } + } + } + + finalStatus := processor.makeFinalStatusUpdate() + e.writeFinalTaskStatus(ctx, yield, processor.makeFinalArtifactUpdate(), finalStatus, nil) +} + +func (e *Executor) writeFinalTaskStatus( + ctx ExecutorContext, + yield func(a2a.Event, error) bool, + partialReset *a2a.TaskArtifactUpdateEvent, + status *a2a.TaskStatusUpdateEvent, + err error, +) { + if e.config.AfterExecuteCallback != nil { + if err = e.config.AfterExecuteCallback(ctx, status, err); err != nil { + yield(nil, fmt.Errorf("after execute: %w", err)) + return + } + } + if partialReset != nil { + if !yield(partialReset, nil) { + return + } + } + yield(status, nil) +} + +func (e *Executor) prepareSession(ctx context.Context, cfg RunnerConfig, meta invocationMeta) error { + service := cfg.SessionService + + _, err := service.Get(ctx, &session.GetRequest{ + AppName: cfg.AppName, + UserID: meta.userID, + SessionID: meta.sessionID, + }) + if err == nil { + return nil + } + + _, err = service.Create(ctx, &session.CreateRequest{ + AppName: cfg.AppName, + UserID: meta.userID, + SessionID: meta.sessionID, + State: make(map[string]any), + }) + if err != nil { + return fmt.Errorf("failed to create a session: %w", err) + } + return nil +} + +func (e *Executor) createRunnerConfig(ctx context.Context, reqCtx *a2asrv.ExecutorContext) (RunnerConfig, error) { + executorPlugin, err := newExecutorPlugin() + if err != nil { + return RunnerConfig{}, fmt.Errorf("failed to create a2a-plugin: %w", err) + } + cfg, _, err := e.config.RunnerProvider(ctx, reqCtx, executorPlugin.plugin) + if err != nil { + return RunnerConfig{}, fmt.Errorf("runner provider failed: %w", err) + } + return cfg, nil +} + +func newDefaultRunnerProvider(baseConfig runner.Config) RunnerProvider { + return func(ctx context.Context, reqCtx *a2asrv.ExecutorContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) { + if baseConfig.Agent == nil { + return RunnerConfig{}, nil, fmt.Errorf("runner.Config.Agent is not provided") + } + if baseConfig.SessionService == nil { + return RunnerConfig{}, nil, fmt.Errorf("runner.Config.SessionService is not provided") + } + + cfg := baseConfig + cfg.PluginConfig.Plugins = append(slices.Clone(cfg.PluginConfig.Plugins), plugin) + r, err := runner.New(cfg) + if err != nil { + return RunnerConfig{}, nil, err + } + return toInternalRunnerConfig(cfg), &defaultRunner{runner: r}, nil + } +} + +type defaultRunner struct { + runner *runner.Runner +} + +func (r *defaultRunner) Run(ctx context.Context, userID, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] { + return r.runner.Run(ctx, userID, sessionID, msg, cfg) +} + +func toInternalRunnerConfig(cfg runner.Config) RunnerConfig { + return RunnerConfig{Agent: cfg.Agent, AppName: cfg.AppName, SessionService: cfg.SessionService} +} diff --git a/server/adka2a/executor_context.go b/server/adka2a/v2/executor_context.go similarity index 92% rename from server/adka2a/executor_context.go rename to server/adka2a/v2/executor_context.go index 33a54b207..54345547d 100644 --- a/server/adka2a/executor_context.go +++ b/server/adka2a/v2/executor_context.go @@ -18,7 +18,7 @@ import ( "context" "iter" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2asrv" "google.golang.org/genai" "google.golang.org/adk/session" @@ -42,8 +42,8 @@ type ExecutorContext interface { Events() session.Events // UserContent is a converted A2A message which is passed to runner.Run. UserContent() *genai.Content - // RequestContext containts information about the original A2A Request, the current task and related tasks. - RequestContext() *a2asrv.RequestContext + // RequestContext contains information about the original A2A Request, the current task and related tasks. + RequestContext() *a2asrv.ExecutorContext } type executorContext struct { @@ -90,7 +90,7 @@ func (ec *executorContext) Events() session.Events { return session.Events() } -func (ec *executorContext) RequestContext() *a2asrv.RequestContext { +func (ec *executorContext) RequestContext() *a2asrv.ExecutorContext { return ec.meta.reqCtx } diff --git a/server/adka2a/executor_plugin.go b/server/adka2a/v2/executor_plugin.go similarity index 100% rename from server/adka2a/executor_plugin.go rename to server/adka2a/v2/executor_plugin.go diff --git a/server/adka2a/executor_test.go b/server/adka2a/v2/executor_test.go similarity index 70% rename from server/adka2a/executor_test.go rename to server/adka2a/v2/executor_test.go index 60525bb92..ef27b96ad 100644 --- a/server/adka2a/executor_test.go +++ b/server/adka2a/v2/executor_test.go @@ -22,10 +22,9 @@ import ( "testing" "time" - "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2aclient" - "github.com/a2aproject/a2a-go/a2asrv" - "github.com/a2aproject/a2a-go/a2asrv/eventqueue" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genai" @@ -38,19 +37,9 @@ import ( "google.golang.org/adk/session" ) -type testQueue struct { - eventqueue.Queue - events []a2a.Event - writeErr *eventIndex -} +// No testQueue needed anymore. -func (q *testQueue) Write(_ context.Context, e a2a.Event) error { - if q.writeErr != nil && q.writeErr.i == len(q.events) { - return fmt.Errorf("queue write failed") - } - q.events = append(q.events, e) - return nil -} +// testQueue removed. type testSessionService struct { session.Service @@ -82,26 +71,16 @@ func newEventReplayAgent(events []*session.Event, failWith error) (agent.Agent, }) } -func newInMemoryQueue(t *testing.T) eventqueue.Queue { - t.Helper() - qm := eventqueue.NewInMemoryManager() - q, err := qm.GetOrCreate(t.Context(), "test") - if err != nil { - t.Fatalf("qm.GetOrCreate() error = %v", err) - } - return q -} - type eventIndex struct{ i int } func TestExecutor_Execute(t *testing.T) { task := &a2a.Task{ID: a2a.NewTaskID(), ContextID: a2a.NewContextID()} - hiMsg := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: "hi"}) - hiMsgForTask := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.TextPart{Text: "hi"}) + hiMsg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("hi")) + hiMsgForTask := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.NewTextPart("hi")) testCases := []struct { name string - request *a2a.MessageSendParams + request *a2a.SendMessageRequest events []*session.Event wantEvents []a2a.Event createSessionFails bool @@ -111,30 +90,34 @@ func TestExecutor_Execute(t *testing.T) { }{ { name: "no message", - request: &a2a.MessageSendParams{}, + request: &a2a.SendMessageRequest{}, wantErr: true, }, { name: "malformed data", - request: &a2a.MessageSendParams{Message: a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.FilePart{ - File: a2a.FileBytes{Bytes: "(*_*)"}, // malformed base64 - })}, + request: &a2a.SendMessageRequest{ + Message: a2a.NewMessageForTask(a2a.MessageRoleUser, task, func() *a2a.Part { + p := a2a.NewDataPart(make(chan int)) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionCall) + return p + }()), + }, wantErr: true, }, { name: "session setup fails", - request: &a2a.MessageSendParams{Message: hiMsgForTask}, + request: &a2a.SendMessageRequest{Message: hiMsgForTask}, createSessionFails: true, wantEvents: []a2a.Event{ newFinalStatusUpdate( task, a2a.TaskStateFailed, - a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.TextPart{Text: "failed to create a session: session creation failed"}), + a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.NewTextPart("failed to create a session: session creation failed")), ), }, }, { name: "success for a new task", - request: &a2a.MessageSendParams{Message: hiMsg}, + request: &a2a.SendMessageRequest{Message: hiMsg}, events: []*session.Event{ {LLMResponse: modelResponseFromParts(genai.NewPartFromText("Hello"))}, {LLMResponse: modelResponseFromParts(genai.NewPartFromText(", world!"))}, @@ -142,34 +125,34 @@ func TestExecutor_Execute(t *testing.T) { wantEvents: []a2a.Event{ a2a.NewSubmittedTask(task, hiMsg), a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), - a2a.NewArtifactEvent(task, a2a.TextPart{Text: "Hello"}), - a2a.NewArtifactUpdateEvent(task, a2a.NewArtifactID(), a2a.TextPart{Text: ", world!"}), + a2a.NewArtifactEvent(task, a2a.NewTextPart("Hello")), + a2a.NewArtifactUpdateEvent(task, a2a.NewArtifactID(), a2a.NewTextPart(", world!")), newFinalStatusUpdate(task, a2a.TaskStateCompleted, nil), }, }, { name: "success for existing task", - request: &a2a.MessageSendParams{Message: hiMsgForTask}, + request: &a2a.SendMessageRequest{Message: hiMsgForTask}, events: []*session.Event{ {LLMResponse: modelResponseFromParts(genai.NewPartFromText("Hello"))}, {LLMResponse: modelResponseFromParts(genai.NewPartFromText(", world!"))}, }, wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), - a2a.NewArtifactEvent(task, a2a.TextPart{Text: "Hello"}), - a2a.NewArtifactUpdateEvent(task, a2a.NewArtifactID(), a2a.TextPart{Text: ", world!"}), + a2a.NewArtifactEvent(task, a2a.NewTextPart("Hello")), + a2a.NewArtifactUpdateEvent(task, a2a.NewArtifactID(), a2a.NewTextPart(", world!")), newFinalStatusUpdate(task, a2a.TaskStateCompleted, nil), }, }, { name: "queue write fails", - request: &a2a.MessageSendParams{Message: hiMsgForTask}, + request: &a2a.SendMessageRequest{Message: hiMsgForTask}, queueWriteFails: &eventIndex{0}, wantErr: true, }, { name: "llm fails", - request: &a2a.MessageSendParams{Message: hiMsgForTask}, + request: &a2a.SendMessageRequest{Message: hiMsgForTask}, events: []*session.Event{ {LLMResponse: modelResponseFromParts(genai.NewPartFromText("Hello"))}, {LLMResponse: model.LLMResponse{ErrorCode: "418", ErrorMessage: "I'm a teapot"}}, @@ -177,8 +160,8 @@ func TestExecutor_Execute(t *testing.T) { }, wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), - a2a.NewArtifactEvent(task, a2a.TextPart{Text: "Hello"}), - a2a.NewArtifactUpdateEvent(task, a2a.NewArtifactID(), a2a.TextPart{Text: ", world!"}), + a2a.NewArtifactEvent(task, a2a.NewTextPart("Hello")), + a2a.NewArtifactUpdateEvent(task, a2a.NewArtifactID(), a2a.NewTextPart(", world!")), toTaskFailedUpdateEvent( task, errorFromResponse(&model.LLMResponse{ErrorCode: "418", ErrorMessage: "I'm a teapot"}), map[string]any{ToA2AMetaKey("error_code"): "418"}, @@ -187,23 +170,23 @@ func TestExecutor_Execute(t *testing.T) { }, { name: "agent run fails", - request: &a2a.MessageSendParams{Message: hiMsgForTask}, + request: &a2a.SendMessageRequest{Message: hiMsgForTask}, events: []*session.Event{ {LLMResponse: modelResponseFromParts(genai.NewPartFromText("Hello"))}, }, agentRunFails: fmt.Errorf("OOF"), wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), - a2a.NewArtifactEvent(task, a2a.TextPart{Text: "Hello"}), + a2a.NewArtifactEvent(task, a2a.NewTextPart("Hello")), newFinalStatusUpdate( task, a2a.TaskStateFailed, - a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.TextPart{Text: "agent run failed: OOF"}), + a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.NewTextPart("agent run failed: OOF")), ), }, }, { name: "agent run and queue write fail", - request: &a2a.MessageSendParams{Message: hiMsgForTask}, + request: &a2a.SendMessageRequest{Message: hiMsgForTask}, events: []*session.Event{ {LLMResponse: modelResponseFromParts(genai.NewPartFromText("Hello"))}, }, @@ -212,13 +195,13 @@ func TestExecutor_Execute(t *testing.T) { wantErr: true, wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), - a2a.NewArtifactEvent(task, a2a.TextPart{Text: "Hello"}), + a2a.NewArtifactEvent(task, a2a.NewTextPart("Hello")), }, }, } for _, tc := range testCases { - ignoreFields := []cmp.Option{ + ignoreOptions := []cmp.Option{ cmpopts.IgnoreFields(a2a.Message{}, "ID"), cmpopts.IgnoreFields(a2a.Artifact{}, "ID"), cmpopts.IgnoreFields(a2a.TaskStatus{}, "Timestamp"), @@ -234,23 +217,35 @@ func TestExecutor_Execute(t *testing.T) { sessionService := &testSessionService{Service: session.InMemoryService(), createErr: tc.createSessionFails} runnerConfig := runner.Config{AppName: agent.Name(), Agent: agent, SessionService: sessionService} executor := NewExecutor(ExecutorConfig{RunnerConfig: runnerConfig}) - queue := &testQueue{Queue: newInMemoryQueue(t), writeErr: tc.queueWriteFails} - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID, Message: tc.request.Message} + var gotEvents []a2a.Event + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID, Message: tc.request.Message} if tc.request.Message != nil && tc.request.Message.TaskID == task.ID { reqCtx.StoredTask = task } - err = executor.Execute(t.Context(), reqCtx, queue) - if err != nil && !tc.wantErr { - t.Fatalf("executor.Execute() error = %v, want nil", err) - } - if err == nil && tc.wantErr { - t.Fatalf("executor.Execute() produced %d events, want error", len(queue.events)) + var executeErr error + for event, err := range executor.Execute(t.Context(), reqCtx) { + if err != nil { + executeErr = err + break + } + if tc.queueWriteFails != nil && tc.queueWriteFails.i == len(gotEvents) { + executeErr = fmt.Errorf("queue write failed") + break + } + gotEvents = append(gotEvents, event) } - if tc.wantEvents != nil { - if diff := cmp.Diff(tc.wantEvents, queue.events, ignoreFields...); diff != "" { - t.Fatalf("executor.Execute() wrong events (+got,-want):\ngot = %v\nwant = %v\ndiff = %s", queue.events, tc.wantEvents, diff) + if executeErr != nil { + if !tc.wantErr { + t.Fatalf("executor.Execute() error = %v, want nil", executeErr) } + return + } + if tc.wantErr { + t.Fatalf("executor.Execute() error = nil, want error") + } + if diff := cmp.Diff(tc.wantEvents, gotEvents, ignoreOptions...); diff != "" { + t.Errorf("executor.Execute() produced wrong events (-want +got):\n%s", diff) } }) } @@ -259,19 +254,21 @@ func TestExecutor_Execute(t *testing.T) { func TestExecutor_Cancel(t *testing.T) { task := &a2a.Task{ID: a2a.NewTaskID(), ContextID: a2a.NewContextID()} executor := NewExecutor(ExecutorConfig{}) - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID} + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID} - queue := &testQueue{Queue: newInMemoryQueue(t)} + var gotEvents []a2a.Event reqCtx.StoredTask = task - err := executor.Cancel(t.Context(), reqCtx, queue) - if err != nil { - t.Fatalf("executor.Cancel() error = %v, want nil", err) + for event, err := range executor.Cancel(t.Context(), reqCtx) { + if err != nil { + t.Fatalf("executor.Cancel() error = %v, want nil", err) + } + gotEvents = append(gotEvents, event) } - if len(queue.events) != 1 { - t.Fatalf("executor.Cancel() produced %d events, want 1", queue.events) + if len(gotEvents) != 1 { + t.Fatalf("executor.Cancel() produced %d events, want 1", len(gotEvents)) } - event := queue.events[0].(*a2a.TaskStatusUpdateEvent) + event := gotEvents[0].(*a2a.TaskStatusUpdateEvent) if event.Status.State != a2a.TaskStateCanceled { t.Fatalf("executor.Cancel() = %v, want a single TaskStateCanceled update", event) } @@ -286,20 +283,24 @@ func TestExecutor_SessionReuse(t *testing.T) { sessionService := session.InMemoryService() task := &a2a.Task{ID: a2a.NewTaskID(), ContextID: a2a.NewContextID()} - req := &a2a.MessageSendParams{Message: a2a.NewMessageForTask(a2a.MessageRoleUser, task)} - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID, Message: req.Message} + req := &a2a.SendMessageRequest{Message: a2a.NewMessageForTask(a2a.MessageRoleUser, task)} + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID, Message: req.Message} runnerConfig := runner.Config{AppName: agent.Name(), Agent: agent, SessionService: sessionService} config := ExecutorConfig{RunnerConfig: runnerConfig} executor := NewExecutor(config) - queue := newInMemoryQueue(t) + var gotEvents []a2a.Event - err = executor.Execute(ctx, reqCtx, queue) - if err != nil { - t.Fatalf("executor.Execute() error = %v, want nil", err) + for event, err := range executor.Execute(ctx, reqCtx) { + if err != nil { + t.Fatalf("executor.Execute() error = %v, want nil", err) + } + gotEvents = append(gotEvents, event) } - err = executor.Execute(ctx, reqCtx, queue) - if err != nil { - t.Fatalf("executor.Execute() error = %v, want nil", err) + for event, err := range executor.Execute(ctx, reqCtx) { + if err != nil { + t.Fatalf("executor.Execute() error = %v, want nil", err) + } + gotEvents = append(gotEvents, event) } meta := toInvocationMeta(ctx, toInternalRunnerConfig(config.RunnerConfig), reqCtx) @@ -321,7 +322,7 @@ func TestExecutor_SessionReuse(t *testing.T) { func TestExecutor_Callbacks(t *testing.T) { type contextKeyType struct{} task := &a2a.Task{ID: a2a.NewTaskID(), ContextID: a2a.NewContextID()} - hiMsg := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.TextPart{Text: "hi"}) + hiMsg := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.NewTextPart("hi")) testCases := []struct { name string @@ -335,24 +336,24 @@ func TestExecutor_Callbacks(t *testing.T) { }{ { name: "abort execution", - beforeExecution: func(ctx context.Context, reqCtx *a2asrv.RequestContext) (context.Context, error) { + beforeExecution: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) (context.Context, error) { return nil, fmt.Errorf("aborted") }, wantErr: fmt.Errorf("aborted"), }, { name: "instrument context", - beforeExecution: func(ctx context.Context, reqCtx *a2asrv.RequestContext) (context.Context, error) { + beforeExecution: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) (context.Context, error) { return context.WithValue(ctx, contextKeyType{}, "bar"), nil }, afterExecution: func(ctx ExecutorContext, finalUpdate *a2a.TaskStatusUpdateEvent, err error) error { text, _ := ctx.Value(contextKeyType{}).(string) - finalUpdate.Status.Message = a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: text}) + finalUpdate.Status.Message = a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart(text)) return nil }, wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), - newFinalStatusUpdate(task, a2a.TaskStateCompleted, a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "bar"})), + newFinalStatusUpdate(task, a2a.TaskStateCompleted, a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("bar"))), }, }, { @@ -364,12 +365,12 @@ func TestExecutor_Callbacks(t *testing.T) { return fmt.Errorf("fail!") }, afterExecution: func(ctx ExecutorContext, finalUpdate *a2a.TaskStatusUpdateEvent, err error) error { - finalUpdate.Status.Message = a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "bar"}) + finalUpdate.Status.Message = a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("bar")) return nil }, wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), - newFinalStatusUpdate(task, a2a.TaskStateFailed, a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "bar"})), + newFinalStatusUpdate(task, a2a.TaskStateFailed, a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("bar"))), }, }, { @@ -380,10 +381,10 @@ func TestExecutor_Callbacks(t *testing.T) { for range ctx.ReadonlyState().All() { eventCount++ } - finalUpdate.Status.Message = a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: fmt.Sprintf("%d events", eventCount)}) + finalUpdate.Status.Message = a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart(fmt.Sprintf("%d events", eventCount))) return nil }, - wantEvents: []a2a.Event{newFinalStatusUpdate(task, a2a.TaskStateFailed, a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "0 events"}))}, + wantEvents: []a2a.Event{newFinalStatusUpdate(task, a2a.TaskStateFailed, a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("0 events")))}, }, { name: "enrich event", @@ -392,13 +393,13 @@ func TestExecutor_Callbacks(t *testing.T) { {LLMResponse: modelResponseFromParts(genai.NewPartFromText(", world!"))}, }, afterEvent: func(ctx ExecutorContext, event *session.Event, processed *a2a.TaskArtifactUpdateEvent) error { - processed.Artifact.Parts = append(processed.Artifact.Parts, a2a.TextPart{Text: " (enriched)"}) + processed.Artifact.Parts = append(processed.Artifact.Parts, a2a.NewTextPart(" (enriched)")) return nil }, wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), - a2a.NewArtifactEvent(task, a2a.TextPart{Text: "Hello"}, a2a.TextPart{Text: " (enriched)"}), - a2a.NewArtifactUpdateEvent(task, a2a.NewArtifactID(), a2a.TextPart{Text: ", world!"}, a2a.TextPart{Text: " (enriched)"}), + a2a.NewArtifactEvent(task, a2a.NewTextPart("Hello"), a2a.NewTextPart(" (enriched)")), + a2a.NewArtifactUpdateEvent(task, a2a.NewArtifactID(), a2a.NewTextPart(", world!"), a2a.NewTextPart(" (enriched)")), newFinalStatusUpdate(task, a2a.TaskStateCompleted, nil), }, }, @@ -410,15 +411,15 @@ func TestExecutor_Callbacks(t *testing.T) { }, afterExecution: func(ctx ExecutorContext, finalUpdate *a2a.TaskStatusUpdateEvent, err error) error { eventCount := ctx.Events().Len() - finalUpdate.Status.Message = a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: fmt.Sprintf("event count = %d", eventCount)}) + finalUpdate.Status.Message = a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart(fmt.Sprintf("event count = %d", eventCount))) return nil }, wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), - a2a.NewArtifactEvent(task, a2a.TextPart{Text: "Hello"}), - a2a.NewArtifactUpdateEvent(task, a2a.NewArtifactID(), a2a.TextPart{Text: ", world!"}), + a2a.NewArtifactEvent(task, a2a.NewTextPart("Hello")), + a2a.NewArtifactUpdateEvent(task, a2a.NewArtifactID(), a2a.NewTextPart(", world!")), newFinalStatusUpdate(task, a2a.TaskStateCompleted, - a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "event count = 3"}), + a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("event count = 3")), ), }, }, @@ -439,7 +440,7 @@ func TestExecutor_Callbacks(t *testing.T) { } for _, tc := range testCases { - ignoreFields := []cmp.Option{ + ignoreOptions := []cmp.Option{ cmpopts.IgnoreFields(a2a.Message{}, "ID"), cmpopts.IgnoreFields(a2a.Artifact{}, "ID"), cmpopts.IgnoreFields(a2a.TaskStatus{}, "Timestamp"), @@ -460,19 +461,29 @@ func TestExecutor_Callbacks(t *testing.T) { AfterEventCallback: tc.afterEvent, AfterExecuteCallback: tc.afterExecution, }) - queue := &testQueue{Queue: newInMemoryQueue(t)} - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} - - err = executor.Execute(t.Context(), reqCtx, queue) - if err != nil && tc.wantErr == nil { - t.Fatalf("executor.Execute() error = %v, want nil", err) + var gotEvents []a2a.Event + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} + + var executeErr error + for event, err := range executor.Execute(t.Context(), reqCtx) { + if err != nil { + executeErr = err + break + } + gotEvents = append(gotEvents, event) } - if err == nil && tc.wantErr != nil { + if executeErr != nil { + if tc.wantErr == nil { + t.Fatalf("executor.Execute() error = %v, want nil", executeErr) + } + return + } + if tc.wantErr != nil { t.Fatalf("executor.Execute() error = nil, want %v", tc.wantErr) } if tc.wantEvents != nil { - if diff := cmp.Diff(tc.wantEvents, queue.events, ignoreFields...); diff != "" { - t.Fatalf("executor.Execute() wrong events (+got,-want):\ngot = %v\nwant = %v\ndiff = %s", queue.events, tc.wantEvents, diff) + if diff := cmp.Diff(tc.wantEvents, gotEvents, ignoreOptions...); diff != "" { + t.Errorf("executor.Execute() produced wrong events (-want +got):\n%s", diff) } } }) @@ -514,9 +525,9 @@ func TestExecutor_Cancel_AfterEvent(t *testing.T) { defer server.Close() card := &a2a.AgentCard{ - Name: "test", - URL: server.URL, - PreferredTransport: a2a.TransportProtocolJSONRPC, + SupportedInterfaces: []*a2a.AgentInterface{ + a2a.NewAgentInterface(server.URL, a2a.TransportProtocolJSONRPC), + }, } client, err := a2aclient.NewFromCard(t.Context(), card) @@ -525,11 +536,9 @@ func TestExecutor_Cancel_AfterEvent(t *testing.T) { } msgId := a2a.NewMessageID() - blocking := false - - result, sendErr := client.SendMessage(t.Context(), &a2a.MessageSendParams{ - Message: &a2a.Message{ID: string(msgId), Parts: []a2a.Part{a2a.TextPart{Text: "TEST"}}, Role: a2a.MessageRoleUser}, - Config: &a2a.MessageSendConfig{Blocking: &blocking}, + result, sendErr := client.SendMessage(t.Context(), &a2a.SendMessageRequest{ + Message: &a2a.Message{ID: string(msgId), Parts: a2a.ContentParts{a2a.NewTextPart("TEST")}, Role: a2a.MessageRoleUser}, + Config: &a2a.SendMessageConfig{ReturnImmediately: true}, }) if sendErr != nil { @@ -538,7 +547,7 @@ func TestExecutor_Cancel_AfterEvent(t *testing.T) { taskID := result.TaskInfo().TaskID - task, err := client.CancelTask(t.Context(), &a2a.TaskIDParams{ID: taskID}) + task, err := client.CancelTask(t.Context(), &a2a.CancelTaskRequest{ID: taskID}) if err != nil { t.Fatalf("client.CancelTask() error = %v, want nil", err) } @@ -558,7 +567,7 @@ func TestExecutor_Cancel_AfterEvent(t *testing.T) { func TestExecutor_Converters(t *testing.T) { task := &a2a.Task{ID: a2a.NewTaskID(), ContextID: a2a.NewContextID()} - hiMsg := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.TextPart{Text: "hi"}) + hiMsg := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.NewTextPart("hi")) t.Run("A2APartConverter", func(t *testing.T) { t.Run("modify input", func(t *testing.T) { @@ -578,17 +587,20 @@ func TestExecutor_Converters(t *testing.T) { executor := NewExecutor(ExecutorConfig{ RunnerConfig: runner.Config{AppName: agent.Name(), Agent: agent, SessionService: session.InMemoryService()}, - A2APartConverter: func(ctx context.Context, evt a2a.Event, part a2a.Part) (*genai.Part, error) { - if p, ok := part.(a2a.TextPart); ok && p.Text == "hi" { + A2APartConverter: func(ctx context.Context, evt a2a.Event, part *a2a.Part) (*genai.Part, error) { + if part.Text() == "hi" { return genai.NewPartFromText("HELLO"), nil } return nil, nil }, }) - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} - if err := executor.Execute(t.Context(), reqCtx, newInMemoryQueue(t)); err != nil { - t.Fatalf("executor.Execute() error = %v", err) + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} + for event, err := range executor.Execute(t.Context(), reqCtx) { + if err != nil { + t.Fatalf("executor.Execute() error = %v", err) + } + _ = event // ignored } if receivedText != "HELLO" { @@ -611,14 +623,17 @@ func TestExecutor_Converters(t *testing.T) { executor := NewExecutor(ExecutorConfig{ RunnerConfig: runner.Config{AppName: agent.Name(), Agent: agent, SessionService: session.InMemoryService()}, - A2APartConverter: func(ctx context.Context, evt a2a.Event, part a2a.Part) (*genai.Part, error) { + A2APartConverter: func(ctx context.Context, evt a2a.Event, part *a2a.Part) (*genai.Part, error) { return nil, nil }, }) - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} - if err := executor.Execute(t.Context(), reqCtx, newInMemoryQueue(t)); err != nil { - t.Fatalf("executor.Execute() error = %v", err) + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} + for event, err := range executor.Execute(t.Context(), reqCtx) { + if err != nil { + t.Fatalf("executor.Execute() error = %v", err) + } + _ = event // ignored } if receivedParts != 0 { @@ -642,32 +657,35 @@ func TestExecutor_Converters(t *testing.T) { executor := NewExecutor(ExecutorConfig{ RunnerConfig: runner.Config{AppName: agent.Name(), Agent: agent, SessionService: session.InMemoryService()}, - GenAIPartConverter: func(ctx context.Context, evt *session.Event, part *genai.Part) (a2a.Part, error) { + GenAIPartConverter: func(ctx context.Context, evt *session.Event, part *genai.Part) (*a2a.Part, error) { if part.Text == "world" { - return a2a.TextPart{Text: "WORLD"}, nil + return a2a.NewTextPart("WORLD"), nil } - return a2a.TextPart{Text: part.Text}, nil + return a2a.NewTextPart(part.Text), nil }, }) - queue := &testQueue{Queue: newInMemoryQueue(t)} - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} - if err := executor.Execute(t.Context(), reqCtx, queue); err != nil { - t.Fatalf("executor.Execute() error = %v", err) + var gotEvents []a2a.Event + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} + for event, err := range executor.Execute(t.Context(), reqCtx) { + if err != nil { + t.Fatalf("executor.Execute() error = %v", err) + } + gotEvents = append(gotEvents, event) } found := false - for _, e := range queue.events { + for _, e := range gotEvents { if ae, ok := e.(*a2a.TaskArtifactUpdateEvent); ok { for _, p := range ae.Artifact.Parts { - if tp, ok := p.(a2a.TextPart); ok && tp.Text == "WORLD" { + if p.Text() == "WORLD" { found = true } } } } if !found { - t.Errorf("did not find 'WORLD' in events: %v", queue.events) + t.Errorf("did not find 'WORLD' in events: %v", gotEvents) } }) @@ -679,18 +697,21 @@ func TestExecutor_Converters(t *testing.T) { executor := NewExecutor(ExecutorConfig{ RunnerConfig: runner.Config{AppName: agent.Name(), Agent: agent, SessionService: session.InMemoryService()}, - GenAIPartConverter: func(ctx context.Context, evt *session.Event, part *genai.Part) (a2a.Part, error) { + GenAIPartConverter: func(ctx context.Context, evt *session.Event, part *genai.Part) (*a2a.Part, error) { return nil, nil }, }) - queue := &testQueue{Queue: newInMemoryQueue(t)} - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} - if err := executor.Execute(t.Context(), reqCtx, queue); err != nil { - t.Fatalf("executor.Execute() error = %v", err) + var gotEvents []a2a.Event + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} + for event, err := range executor.Execute(t.Context(), reqCtx) { + if err != nil { + t.Fatalf("executor.Execute() error = %v", err) + } + gotEvents = append(gotEvents, event) } - for _, e := range queue.events { + for _, e := range gotEvents { if ae, ok := e.(*a2a.TaskArtifactUpdateEvent); ok { if len(ae.Artifact.Parts) != 0 { t.Errorf("found %d parts but expected 0", len(ae.Artifact.Parts)) @@ -703,7 +724,7 @@ func TestExecutor_Converters(t *testing.T) { func TestExecutor_OutputArtifactPerEvent(t *testing.T) { task := &a2a.Task{ID: a2a.NewTaskID(), ContextID: a2a.NewContextID()} - hiMsg := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.TextPart{Text: "hi"}) + hiMsg := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.NewTextPart("hi")) testCases := []struct { name string @@ -721,15 +742,15 @@ func TestExecutor_OutputArtifactPerEvent(t *testing.T) { wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "Hello, "}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("Hello, ")}}, Append: false, LastChunk: false, }, &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "world!"}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("world!")}}, Append: true, LastChunk: false, }, &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "Hello, world!"}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("Hello, world!")}}, Append: false, LastChunk: true, }, newFinalStatusUpdate(task, a2a.TaskStateCompleted, nil), @@ -746,19 +767,19 @@ func TestExecutor_OutputArtifactPerEvent(t *testing.T) { wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "Agent1: H"}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("Agent1: H")}}, Append: false, LastChunk: false, }, &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "Agent2: W"}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("Agent2: W")}}, Append: false, LastChunk: false, }, &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "Agent1: Hello"}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("Agent1: Hello")}}, Append: false, LastChunk: true, }, &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "Agent2: World"}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("Agent2: World")}}, Append: false, LastChunk: true, }, newFinalStatusUpdate(task, a2a.TaskStateCompleted, nil), @@ -782,16 +803,19 @@ func TestExecutor_OutputArtifactPerEvent(t *testing.T) { a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), &a2a.TaskArtifactUpdateEvent{ Artifact: &a2a.Artifact{ - Parts: a2a.ContentParts{a2a.TextPart{ - Text: "Thinking...", - Metadata: map[string]any{ToA2AMetaKey("thought"): true}, - }}, + Parts: a2a.ContentParts{ + func() *a2a.Part { + p := a2a.NewTextPart("Thinking...") + p.SetMeta(ToA2AMetaKey("thought"), true) + return p + }(), + }, }, Append: false, LastChunk: false, }, &a2a.TaskArtifactUpdateEvent{ Artifact: &a2a.Artifact{ - Parts: a2a.ContentParts{a2a.TextPart{Text: "Done"}}, + Parts: a2a.ContentParts{a2a.NewTextPart("Done")}, }, Append: false, LastChunk: true, }, @@ -818,11 +842,12 @@ func TestExecutor_OutputArtifactPerEvent(t *testing.T) { &a2a.TaskArtifactUpdateEvent{ Artifact: &a2a.Artifact{ Parts: a2a.ContentParts{ - a2a.TextPart{ - Text: "Thought part", - Metadata: map[string]any{ToA2AMetaKey("thought"): true}, - }, - a2a.TextPart{Text: "Text part"}, + func() *a2a.Part { + p := a2a.NewTextPart("Thought part") + p.SetMeta(ToA2AMetaKey("thought"), true) + return p + }(), + a2a.NewTextPart("Text part"), }, }, Append: false, LastChunk: true, @@ -841,19 +866,19 @@ func TestExecutor_OutputArtifactPerEvent(t *testing.T) { wantEvents: []a2a.Event{ a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "1.a"}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("1.a")}}, Append: false, LastChunk: false, }, &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "1.final"}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("1.final")}}, Append: false, LastChunk: true, }, &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "2.a"}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("2.a")}}, Append: false, LastChunk: false, }, &a2a.TaskArtifactUpdateEvent{ - Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.TextPart{Text: "2.final"}}}, + Artifact: &a2a.Artifact{Parts: a2a.ContentParts{a2a.NewTextPart("2.final")}}, Append: false, LastChunk: true, }, newFinalStatusUpdate(task, a2a.TaskStateCompleted, nil), @@ -869,11 +894,14 @@ func TestExecutor_OutputArtifactPerEvent(t *testing.T) { OutputMode: OutputArtifactPerEvent, }) - queue := &testQueue{Queue: newInMemoryQueue(t)} - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} + var gotEvents []a2a.Event + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} - if err := executor.Execute(t.Context(), reqCtx, queue); err != nil { - t.Fatalf("executor.Execute() error = %v", err) + for event, err := range executor.Execute(t.Context(), reqCtx) { + if err != nil { + t.Fatalf("executor.Execute() error = %v", err) + } + gotEvents = append(gotEvents, event) } ignoreOptions := []cmp.Option{ @@ -883,15 +911,15 @@ func TestExecutor_OutputArtifactPerEvent(t *testing.T) { cmpopts.IgnoreFields(a2a.TaskStatusUpdateEvent{}, "Metadata", "TaskID", "ContextID"), cmpopts.IgnoreFields(a2a.TaskArtifactUpdateEvent{}, "Metadata", "TaskID", "ContextID"), } - if len(queue.events) != len(tc.wantEvents) { - t.Fatalf("got %d events, want %d", len(queue.events), len(tc.wantEvents)) + if len(gotEvents) != len(tc.wantEvents) { + t.Fatalf("got %d events, want %d", len(gotEvents), len(tc.wantEvents)) } authorToID, lastFinishedID := make(map[string]a2a.ArtifactID), make(map[string]a2a.ArtifactID) artifactCount := 0 - for i := range queue.events { - got := queue.events[i] + for i := range gotEvents { + got := gotEvents[i] want := tc.wantEvents[i] if diff := cmp.Diff(want, got, ignoreOptions...); diff != "" { @@ -942,8 +970,8 @@ func TestExecutor_RunnerProvider(t *testing.T) { wantText := "Hello" ctx := t.Context() task := &a2a.Task{ID: a2a.NewTaskID(), ContextID: a2a.NewContextID()} - hiMsg := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.TextPart{Text: "hi"}) - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} + hiMsg := a2a.NewMessageForTask(a2a.MessageRoleUser, task, a2a.NewTextPart("hi")) + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID, Message: hiMsg, StoredTask: task} runnerConfig := runner.Config{ AppName: "test", @@ -952,7 +980,7 @@ func TestExecutor_RunnerProvider(t *testing.T) { } executor := NewExecutor(ExecutorConfig{ RunnerConfig: runnerConfig, - RunnerProvider: func(pCtx context.Context, pReqCtx *a2asrv.RequestContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) { + RunnerProvider: func(pCtx context.Context, pReqCtx *a2asrv.ExecutorContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) { return toInternalRunnerConfig(runnerConfig), &testRunner{ runFunc: func(ctx context.Context, userID, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] { return func(yield func(*session.Event, error) bool) { @@ -963,16 +991,22 @@ func TestExecutor_RunnerProvider(t *testing.T) { }, }) - queue := &testQueue{Queue: newInMemoryQueue(t)} - if err := executor.Execute(ctx, reqCtx, queue); err != nil { - t.Fatalf("executor.Execute() error = %v", err) + var events []a2a.Event + for event, err := range executor.Execute(ctx, reqCtx) { + if err != nil { + t.Fatalf("executor.Execute() error = %v", err) + } + events = append(events, event) + } + if len(events) < 2 { + t.Fatalf("executor.Execute() produced %d events, want at least 2", len(events)) } - ta, ok := queue.events[1].(*a2a.TaskArtifactUpdateEvent) + ta, ok := events[1].(*a2a.TaskArtifactUpdateEvent) if !ok { - t.Fatalf("queue.events[1] = %T, want a2a.TaskArtifactUpdateEvent", queue.events[1]) + t.Fatalf("queue.events[1] = %T, want a2a.TaskArtifactUpdateEvent", events[1]) } - if tp, ok := ta.Artifact.Parts[0].(a2a.TextPart); !ok || tp.Text != wantText { - t.Fatalf("ta.Artifact.Parts[0] = %v, want text part with text = %q", tp, wantText) + if ta.Artifact.Parts[0].Text() != wantText { + t.Fatalf("ta.Artifact.Parts[0] = %v, want text part with text = %q", ta.Artifact.Parts[0], wantText) } } diff --git a/server/adka2a/input_required.go b/server/adka2a/v2/input_required.go similarity index 78% rename from server/adka2a/input_required.go rename to server/adka2a/v2/input_required.go index d6d57e78e..a2a6671dd 100644 --- a/server/adka2a/input_required.go +++ b/server/adka2a/v2/input_required.go @@ -19,23 +19,24 @@ import ( "fmt" "slices" - "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" - "github.com/a2aproject/a2a-go/log" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/a2aproject/a2a-go/v2/log" "google.golang.org/genai" + "google.golang.org/adk/internal/utils" "google.golang.org/adk/session" ) type inputRequiredProcessor struct { - reqCtx *a2asrv.RequestContext + reqCtx *a2asrv.ExecutorContext event *a2a.TaskStatusUpdateEvent partConverter GenAIPartConverter // handles possible duplication in partial and non-partial events addedParts []*genai.Part } -func newInputRequiredProcessor(reqCtx *a2asrv.RequestContext, partConverter GenAIPartConverter) *inputRequiredProcessor { +func newInputRequiredProcessor(reqCtx *a2asrv.ExecutorContext, partConverter GenAIPartConverter) *inputRequiredProcessor { return &inputRequiredProcessor{reqCtx: reqCtx, partConverter: partConverter} } @@ -88,7 +89,6 @@ func (p *inputRequiredProcessor) process(ctx context.Context, event *session.Eve } else { msg := a2a.NewMessage(a2a.MessageRoleAgent, a2aParts...) ev := a2a.NewStatusUpdateEvent(p.reqCtx, a2a.TaskStateInputRequired, msg) - ev.Final = true p.event = ev } } @@ -105,11 +105,11 @@ func (p *inputRequiredProcessor) process(ctx context.Context, event *session.Eve return &modifiedEvent, nil } -func (p *inputRequiredProcessor) convertParts(ctx context.Context, event *session.Event, parts []*genai.Part, longRunningCallIDs []string) ([]a2a.Part, error) { +func (p *inputRequiredProcessor) convertParts(ctx context.Context, event *session.Event, parts []*genai.Part, longRunningCallIDs []string) ([]*a2a.Part, error) { if p.partConverter == nil { return ToA2AParts(parts, longRunningCallIDs) } - converted := make([]a2a.Part, 0, len(parts)) + converted := make([]*a2a.Part, 0, len(parts)) for _, part := range parts { cp, err := p.partConverter(ctx, event, part) if err != nil { @@ -135,9 +135,9 @@ func (p *inputRequiredProcessor) isLongRunningResponse(event *session.Event, par return false } for _, part := range p.event.Status.Message.Parts { - if dp, ok := part.(a2a.DataPart); ok { - if typeVal, ok := dp.Metadata[a2aDataPartMetaTypeKey]; ok && typeVal == a2aDataPartTypeFunctionCall { - if callID, ok := dp.Data["id"].(string); ok && callID == id { + if data, ok := part.Data().(map[string]any); ok { + if typeVal, ok := part.Meta()[a2aDataPartMetaTypeKey]; ok && typeVal == a2aDataPartTypeFunctionCall { + if callID, ok := data["id"].(string); ok && callID == id { return true } } @@ -146,10 +146,10 @@ func (p *inputRequiredProcessor) isLongRunningResponse(event *session.Event, par return false } -// handleInputRequired checks if the input message contains responses to all function calls +// HandleInputRequired checks if the input message contains responses to all function calls // that happened during the previous invocation and were recorded in the Task input-required state message. // If a non-nil event is returned the invoking code needs to use the event as the result of the execution -func handleInputRequired(reqCtx *a2asrv.RequestContext, content *genai.Content) (*a2a.TaskStatusUpdateEvent, error) { +func HandleInputRequired(reqCtx *a2asrv.ExecutorContext, content *genai.Content) (*a2a.TaskStatusUpdateEvent, error) { if reqCtx.StoredTask == nil { return nil, nil } @@ -174,7 +174,6 @@ func handleInputRequired(reqCtx *a2asrv.RequestContext, content *genai.Content) parts := makeInputMissingErrorMessage(statusMsg.Parts, statusPart.FunctionCall.ID) msg := a2a.NewMessageForTask(a2a.MessageRoleAgent, reqCtx.StoredTask, parts...) event := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateInputRequired, msg) - event.Final = true return event, nil } } @@ -190,28 +189,36 @@ func getSubagentTasksToCancel(ctx context.Context, status a2a.TaskStatus, sessio return nil, nil } - foundCalls := 0 + foundCalls, foundTaskIDs := map[string]bool{}, 0 var tasksToCancel []subagentTask events := session.Events() for i := events.Len() - 1; i >= 0; i-- { event := events.At(i) - if event.Content != nil { - if slices.ContainsFunc(event.Content.Parts, func(p *genai.Part) bool { - return p.FunctionCall != nil && slices.Contains(pendingCallIDs, p.FunctionCall.ID) - }) { - foundCalls++ - if taskID, _ := GetA2ATaskInfo(event); taskID != "" { - tasksToCancel = append(tasksToCancel, subagentTask{agentName: event.Author, taskID: a2a.TaskID(taskID)}) - } + for _, call := range utils.FunctionCalls(event.Content) { + if !slices.Contains(pendingCallIDs, call.ID) { + continue + } + if foundCalls[call.ID] { + log.Warn(ctx, "duplicate function call id", "id", call.ID) + continue + } + foundCalls[call.ID] = true + if taskID, _ := GetA2ATaskInfo(event); taskID != "" { + tasksToCancel = append(tasksToCancel, subagentTask{agentName: event.Author, taskID: a2a.TaskID(taskID)}) + foundTaskIDs++ } } - if foundCalls == len(pendingCallIDs) { + if len(foundCalls) == len(pendingCallIDs) { break } } - if foundCalls < len(pendingCallIDs) { - log.Warn(ctx, "could not find all function calls from status message", "found", foundCalls, "total", len(pendingCallIDs)) + if len(foundCalls) < len(pendingCallIDs) { + log.Warn(ctx, "could not find all function calls from status message", "found", len(foundCalls), "total", len(pendingCallIDs)) + } + + if foundTaskIDs < len(foundCalls) { + log.Warn(ctx, "not all function calls had a taskID", "found_calls", len(foundCalls), "found_ids", foundTaskIDs) } return tasksToCancel, nil @@ -235,12 +242,11 @@ func getPendingLongRunningCallIDs(status a2a.TaskStatus) ([]string, error) { return callIDs, nil } -func makeInputMissingErrorMessage(inputRequiredParts []a2a.Part, callID string) []a2a.Part { - errPart := a2a.TextPart{ - Text: fmt.Sprintf("no input provided for function call ID %q", callID), - Metadata: map[string]any{"validation_error": true}, - } - var preservedParts []a2a.Part +func makeInputMissingErrorMessage(inputRequiredParts []*a2a.Part, callID string) []*a2a.Part { + errPart := a2a.NewTextPart(fmt.Sprintf("no input provided for function call ID %q", callID)) + errPart.SetMeta("validation_error", true) + + var preservedParts []*a2a.Part for _, p := range inputRequiredParts { if meta := p.Meta(); meta != nil { if v, ok := meta["validation_error"].(bool); ok && v { diff --git a/server/adka2a/metadata.go b/server/adka2a/v2/metadata.go similarity index 93% rename from server/adka2a/metadata.go rename to server/adka2a/v2/metadata.go index fb29367be..36511f392 100644 --- a/server/adka2a/metadata.go +++ b/server/adka2a/v2/metadata.go @@ -18,8 +18,8 @@ import ( "context" "maps" - "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" "google.golang.org/adk/internal/converters" "google.golang.org/adk/session" @@ -39,12 +39,12 @@ var ( metadataPartialKey = ToA2AMetaKey("partial") ) -// ToA2AMetaKey adds a prefix used to differentiage ADK-related values stored in Metadata an A2A event. +// ToA2AMetaKey adds a prefix used to differentiate ADK-related values stored in Metadata an A2A event. func ToA2AMetaKey(key string) string { return "adk_" + key } -// ToADKMetaKey adds a prefix used to differentiage A2A-related values stored in custom metadata of an ADK session event. +// ToADKMetaKey adds a prefix used to differentiate A2A-related values stored in custom metadata of an ADK session event. func ToADKMetaKey(key string) string { return "a2a:" + key } @@ -53,17 +53,17 @@ type invocationMeta struct { userID string sessionID string agentName string - reqCtx *a2asrv.RequestContext + reqCtx *a2asrv.ExecutorContext eventMeta map[string]any } -func toInvocationMeta(ctx context.Context, config RunnerConfig, reqCtx *a2asrv.RequestContext) invocationMeta { +func toInvocationMeta(ctx context.Context, config RunnerConfig, reqCtx *a2asrv.ExecutorContext) invocationMeta { userID, sessionID := "A2A_USER_"+reqCtx.ContextID, reqCtx.ContextID // a2a sdk attaches authn info to the call context, use it when provided if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { - if callCtx.User != nil && callCtx.User.Name() != "" { - userID = callCtx.User.Name() + if callCtx.User != nil && callCtx.User.Name != "" { + userID = callCtx.User.Name } } diff --git a/server/adka2a/metadata_test.go b/server/adka2a/v2/metadata_test.go similarity index 99% rename from server/adka2a/metadata_test.go rename to server/adka2a/v2/metadata_test.go index 03c92d79b..d0e2c1da8 100644 --- a/server/adka2a/metadata_test.go +++ b/server/adka2a/v2/metadata_test.go @@ -17,7 +17,7 @@ package adka2a import ( "testing" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "github.com/google/go-cmp/cmp" "google.golang.org/genai" diff --git a/server/adka2a/parts.go b/server/adka2a/v2/parts.go similarity index 65% rename from server/adka2a/parts.go rename to server/adka2a/v2/parts.go index c693bceb3..ea22ab5d6 100644 --- a/server/adka2a/parts.go +++ b/server/adka2a/v2/parts.go @@ -17,13 +17,12 @@ package adka2a import ( "bytes" "context" - "encoding/base64" "encoding/json" "fmt" "maps" "slices" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "google.golang.org/genai" "google.golang.org/adk/internal/converters" @@ -41,7 +40,7 @@ const ( a2aDataPartTypeCodeExecutableCode = "executable_code" ) -// IsPartial takes metadata of an A2A object (eg. a2a.Part, a2a.Artifact) and returs true if +// IsPartial takes metadata of an A2A object (eg. a2a.Part, a2a.Artifact) and returns true if // it was marked as partial based on the ADK partial flag set on the original ADK object. func IsPartial(meta map[string]any) bool { if meta == nil { @@ -51,7 +50,7 @@ func IsPartial(meta map[string]any) bool { return isPartial } -// IsPartialFlagSet takes metadata of an A2A object (eg. a2a.Part, a2a.Artifact) and returs true if +// IsPartialFlagSet takes metadata of an A2A object (eg. a2a.Part, a2a.Artifact) and returns true if // the ADK partial flag was set on it. func IsPartialFlagSet(meta map[string]any) bool { if meta == nil { @@ -75,7 +74,7 @@ func validateDataPartJSON(d *genai.Part) ([]byte, bool) { // ToA2APart converts the provided genai part to A2A equivalent. Long running tool IDs are used for attaching metadata to // the relevant data parts. -func ToA2APart(part *genai.Part, longRunningToolIDs []string) (a2a.Part, error) { +func ToA2APart(part *genai.Part, longRunningToolIDs []string) (*a2a.Part, error) { parts, err := ToA2AParts([]*genai.Part{part}, longRunningToolIDs) if err != nil { return nil, err @@ -85,13 +84,13 @@ func ToA2APart(part *genai.Part, longRunningToolIDs []string) (a2a.Part, error) // ToA2AParts converts the provided genai parts to A2A equivalents. Long running tool IDs are used for attaching metadata to // the relevant data parts. -func ToA2AParts(parts []*genai.Part, longRunningToolIDs []string) ([]a2a.Part, error) { - result := make([]a2a.Part, len(parts)) +func ToA2AParts(parts []*genai.Part, longRunningToolIDs []string) ([]*a2a.Part, error) { + result := make([]*a2a.Part, len(parts)) for i, part := range parts { if part.Text != "" { - r := a2a.TextPart{Text: part.Text} + r := a2a.NewTextPart(part.Text) if part.Thought { - r.Metadata = map[string]any{ToA2AMetaKey("thought"): true} + r.SetMeta(ToA2AMetaKey("thought"), true) } result[i] = r } else if jsonBytes, ok := validateDataPartJSON(part); ok { @@ -99,7 +98,7 @@ func ToA2AParts(parts []*genai.Part, longRunningToolIDs []string) ([]a2a.Part, e if err := json.Unmarshal(jsonBytes, &data); err != nil { return nil, err } - result[i] = a2a.DataPart{Data: data} + result[i] = a2a.NewDataPart(data) } else if part.InlineData != nil || part.FileData != nil { r, err := toA2AFilePart(part) if err != nil { @@ -117,84 +116,50 @@ func ToA2AParts(parts []*genai.Part, longRunningToolIDs []string) ([]a2a.Part, e return result, nil } -func updatePartsMetadata(parts []a2a.Part, update map[string]any) { - for i, part := range parts { - var meta map[string]any - switch p := part.(type) { - case a2a.TextPart: - if p.Metadata == nil { - p.Metadata = make(map[string]any) - parts[i] = p - } - meta = p.Metadata - case a2a.FilePart: - if p.Metadata == nil { - p.Metadata = make(map[string]any) - parts[i] = p - } - meta = p.Metadata - case a2a.DataPart: - if p.Metadata == nil { - p.Metadata = make(map[string]any) - parts[i] = p - } - meta = p.Metadata - default: - // TODO: log unknown part type warning (should never happen) - continue +func updatePartsMetadata(parts []*a2a.Part, update map[string]any) { + for _, part := range parts { + if part.Metadata == nil { + part.Metadata = make(map[string]any) } - maps.Copy(meta, update) + maps.Copy(part.Metadata, update) } } -func toA2AFilePart(v *genai.Part) (a2a.FilePart, error) { +func toA2AFilePart(v *genai.Part) (*a2a.Part, error) { if v == nil || (v.FileData == nil && v.InlineData == nil) { - return a2a.FilePart{}, fmt.Errorf("not a file part: %v", v) + return nil, fmt.Errorf("not a file part: %v", v) } if v.FileData != nil { - return a2a.FilePart{ - File: a2a.FileURI{ - FileMeta: a2a.FileMeta{ - Name: v.FileData.DisplayName, - MimeType: v.FileData.MIMEType, - }, - URI: v.FileData.FileURI, - }, - }, nil + r := a2a.NewFileURLPart(a2a.URL(v.FileData.FileURI), v.FileData.MIMEType) + r.Filename = v.FileData.DisplayName + return r, nil } - part := a2a.FilePart{ - File: a2a.FileBytes{ - FileMeta: a2a.FileMeta{ - Name: v.InlineData.DisplayName, - MimeType: v.InlineData.MIMEType, - }, - Bytes: base64.StdEncoding.EncodeToString(v.InlineData.Data), - }, - } + r := a2a.NewRawPart(v.InlineData.Data) + r.MediaType = v.InlineData.MIMEType + r.Filename = v.InlineData.DisplayName if v.VideoMetadata != nil { data, err := converters.ToMapStructure(v.VideoMetadata) if err != nil { - return a2a.FilePart{}, err + return nil, err } - part.Metadata = map[string]any{"video_metadata": data} + r.SetMeta("video_metadata", data) } - return part, nil + return r, nil } -func toA2ADataPart(part *genai.Part, longRunningToolIDs []string) (a2a.Part, error) { +func toA2ADataPart(part *genai.Part, longRunningToolIDs []string) (*a2a.Part, error) { if part.CodeExecutionResult != nil { data, err := converters.ToMapStructure(part.CodeExecutionResult) if err != nil { return nil, err } - return a2a.DataPart{ - Data: data, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeCodeExecResult}, - }, nil + r := a2a.NewDataPart(data) + r.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeCodeExecResult) + return r, nil } if part.FunctionResponse != nil { @@ -202,10 +167,9 @@ func toA2ADataPart(part *genai.Part, longRunningToolIDs []string) (a2a.Part, err if err != nil { return nil, err } - return a2a.DataPart{ - Data: data, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionResponse}, - }, nil + r := a2a.NewDataPart(data) + r.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionResponse) + return r, nil } if part.ExecutableCode != nil { @@ -213,10 +177,9 @@ func toA2ADataPart(part *genai.Part, longRunningToolIDs []string) (a2a.Part, err if err != nil { return nil, err } - return a2a.DataPart{ - Data: data, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeCodeExecutableCode}, - }, nil + r := a2a.NewDataPart(data) + r.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeCodeExecutableCode) + return r, nil } if part.FunctionCall != nil { @@ -224,20 +187,17 @@ func toA2ADataPart(part *genai.Part, longRunningToolIDs []string) (a2a.Part, err if err != nil { return nil, err } - return a2a.DataPart{ - Data: data, - Metadata: map[string]any{ - a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, - a2aDataPartMetaLongRunningKey: slices.Contains(longRunningToolIDs, part.FunctionCall.ID), - }, - }, nil + r := a2a.NewDataPart(data) + r.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionCall) + r.SetMeta(a2aDataPartMetaLongRunningKey, slices.Contains(longRunningToolIDs, part.FunctionCall.ID)) + return r, nil } mapStruct, err := converters.ToMapStructure(part) if err != nil { return nil, err } - return a2a.DataPart{Data: mapStruct}, nil + return a2a.NewDataPart(mapStruct), nil } func toGenAIContent(ctx context.Context, msg *a2a.Message, converter A2APartConverter) (*genai.Content, error) { @@ -264,8 +224,8 @@ func toGenAIContent(ctx context.Context, msg *a2a.Message, converter A2APartConv } // ToGenAIPart converts the provided A2A part to a genai equivalent. -func ToGenAIPart(part a2a.Part) (*genai.Part, error) { - parts, err := ToGenAIParts([]a2a.Part{part}) +func ToGenAIPart(part *a2a.Part) (*genai.Part, error) { + parts, err := ToGenAIParts([]*a2a.Part{part}) if err != nil { return nil, err } @@ -273,67 +233,62 @@ func ToGenAIPart(part a2a.Part) (*genai.Part, error) { } // ToGenAIParts converts the provided A2A parts to genai equivalents. -func ToGenAIParts(parts []a2a.Part) ([]*genai.Part, error) { +func ToGenAIParts(parts []*a2a.Part) ([]*genai.Part, error) { result := make([]*genai.Part, len(parts)) for i, part := range parts { - switch v := part.(type) { - case a2a.TextPart: - r := genai.NewPartFromText(v.Text) - if v.Metadata != nil { - if thought, ok := v.Metadata[ToA2AMetaKey("thought")].(bool); ok { - r.Thought = thought - } + if text := part.Text(); text != "" { + r := genai.NewPartFromText(text) + if thought, ok := part.Meta()[ToA2AMetaKey("thought")].(bool); ok { + r.Thought = thought } result[i] = r - - case a2a.DataPart: - r, err := toGenAIDataPart(v) + } else if data := part.Data(); data != nil { + r, err := toGenAIDataPart(part) if err != nil { return nil, err } result[i] = r - - case a2a.FilePart: - r, err := toGenAIFilePart(v) + } else if raw := part.Raw(); raw != nil { + r, err := toGenAIFilePart(part) if err != nil { return nil, err } result[i] = r - - default: - return nil, fmt.Errorf("unknown part type: %T", v) + } else if url := part.URL(); url != "" { + r, err := toGenAIFilePart(part) + if err != nil { + return nil, err + } + result[i] = r + } else { + return nil, fmt.Errorf("unknown part type: %v", part) } } return result, nil } -func toGenAIFilePart(part a2a.FilePart) (*genai.Part, error) { - switch v := part.File.(type) { - case a2a.FileBytes: - bytes, err := base64.StdEncoding.DecodeString(v.Bytes) - if err != nil { - return nil, err - } - data := &genai.Blob{Data: bytes, MIMEType: v.MimeType, DisplayName: v.Name} +func toGenAIFilePart(part *a2a.Part) (*genai.Part, error) { + if raw := part.Raw(); raw != nil { + data := &genai.Blob{Data: raw, MIMEType: part.MediaType, DisplayName: part.Filename} return &genai.Part{InlineData: data}, nil + } - case a2a.FileURI: - data := &genai.FileData{FileURI: v.URI, MIMEType: v.MimeType, DisplayName: v.Name} + if url := part.URL(); url != "" { + data := &genai.FileData{FileURI: string(url), MIMEType: part.MediaType, DisplayName: part.Filename} return &genai.Part{FileData: data}, nil - - default: - return nil, fmt.Errorf("unknown file content type: %T", v) } -} -func toGenAIDataPart(part a2a.DataPart) (*genai.Part, error) { - adkMetaType := part.Metadata[a2aDataPartMetaTypeKey] + return nil, fmt.Errorf("no file content in part") +} - bytes, err := json.Marshal(part.Data) +func toGenAIDataPart(part *a2a.Part) (*genai.Part, error) { + data := part.Data() + bytes, err := json.Marshal(data) if err != nil { return nil, err } + adkMetaType := part.Metadata[a2aDataPartMetaTypeKey] switch adkMetaType { case a2aDataPartTypeCodeExecResult: var val genai.CodeExecutionResult diff --git a/server/adka2a/parts_test.go b/server/adka2a/v2/parts_test.go similarity index 68% rename from server/adka2a/parts_test.go rename to server/adka2a/v2/parts_test.go index 6479f0296..292fbd679 100644 --- a/server/adka2a/parts_test.go +++ b/server/adka2a/v2/parts_test.go @@ -17,7 +17,7 @@ package adka2a import ( "testing" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "github.com/google/go-cmp/cmp" "google.golang.org/genai" ) @@ -25,51 +25,59 @@ import ( func TestPartsTwoWayConversion(t *testing.T) { testCases := []struct { name string - a2aPart a2a.Part + a2aPart *a2a.Part genaiPart *genai.Part longRunningFunctionIDs []string }{ { name: "text", - a2aPart: a2a.TextPart{Text: "Hello"}, + a2aPart: a2a.NewTextPart("Hello"), genaiPart: &genai.Part{Text: "Hello"}, }, { - name: "thought", - a2aPart: a2a.TextPart{Text: "Hello", Metadata: map[string]any{ToA2AMetaKey("thought"): true}}, + name: "thought", + a2aPart: func() *a2a.Part { + p := a2a.NewTextPart("Hello") + p.SetMeta(ToA2AMetaKey("thought"), true) + return p + }(), genaiPart: &genai.Part{Text: "Hello", Thought: true}, }, { name: "file uri", - a2aPart: a2a.FilePart{ - File: a2a.FileURI{URI: "ftp://cat.com", FileMeta: a2a.FileMeta{MimeType: "image/jpeg", Name: "cat.jpeg"}}, - }, + a2aPart: func() *a2a.Part { + p := a2a.NewFileURLPart("ftp://cat.com", "image/jpeg") + p.Filename = "cat.jpeg" + return p + }(), genaiPart: &genai.Part{ FileData: &genai.FileData{FileURI: "ftp://cat.com", MIMEType: "image/jpeg", DisplayName: "cat.jpeg"}, }, }, { name: "file bytes", - a2aPart: a2a.FilePart{ - File: a2a.FileBytes{Bytes: "/w==", FileMeta: a2a.FileMeta{MimeType: "image/jpeg", Name: "cat.jpeg"}}, - }, + a2aPart: func() *a2a.Part { + p := a2a.NewRawPart([]byte{0xfF}) + p.MediaType = "image/jpeg" + p.Filename = "cat.jpeg" + return p + }(), genaiPart: &genai.Part{ InlineData: &genai.Blob{Data: []byte{0xfF}, MIMEType: "image/jpeg", DisplayName: "cat.jpeg"}, }, }, { name: "function call", - a2aPart: a2a.DataPart{ - Data: map[string]any{ + a2aPart: func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{ "id": "get_weather", "args": map[string]any{"city": "Warsaw"}, "name": "GetWeather", - }, - Metadata: map[string]any{ - a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, - a2aDataPartMetaLongRunningKey: false, - }, - }, + }) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionCall) + p.SetMeta(a2aDataPartMetaLongRunningKey, false) + return p + }(), genaiPart: &genai.Part{ FunctionCall: &genai.FunctionCall{ ID: "get_weather", @@ -80,17 +88,16 @@ func TestPartsTwoWayConversion(t *testing.T) { }, { name: "long running function call", - a2aPart: a2a.DataPart{ - Data: map[string]any{ + a2aPart: func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{ "id": "get_weather", "args": map[string]any{"city": "Warsaw"}, "name": "GetWeather", - }, - Metadata: map[string]any{ - a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, - a2aDataPartMetaLongRunningKey: true, - }, - }, + }) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionCall) + p.SetMeta(a2aDataPartMetaLongRunningKey, true) + return p + }(), genaiPart: &genai.Part{ FunctionCall: &genai.FunctionCall{ ID: "get_weather", @@ -102,15 +109,16 @@ func TestPartsTwoWayConversion(t *testing.T) { }, { name: "function response", - a2aPart: a2a.DataPart{ - Data: map[string]any{ + a2aPart: func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{ "id": "get_weather", "scheduling": string(genai.FunctionResponseSchedulingInterrupt), "response": map[string]any{"temperature": "7C"}, "name": "GetWeather", - }, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionResponse}, - }, + }) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionResponse) + return p + }(), genaiPart: &genai.Part{ FunctionResponse: &genai.FunctionResponse{ ID: "get_weather", @@ -122,10 +130,11 @@ func TestPartsTwoWayConversion(t *testing.T) { }, { name: "code execution result", - a2aPart: a2a.DataPart{ - Data: map[string]any{"outcome": string(genai.OutcomeOK), "output": "4"}, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeCodeExecResult}, - }, + a2aPart: func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"outcome": string(genai.OutcomeOK), "output": "4"}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeCodeExecResult) + return p + }(), genaiPart: &genai.Part{ CodeExecutionResult: &genai.CodeExecutionResult{ Outcome: genai.OutcomeOK, @@ -135,10 +144,11 @@ func TestPartsTwoWayConversion(t *testing.T) { }, { name: "code execution result", - a2aPart: a2a.DataPart{ - Data: map[string]any{"code": "print(2+2)", "language": string(genai.LanguagePython)}, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeCodeExecutableCode}, - }, + a2aPart: func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"code": "print(2+2)", "language": string(genai.LanguagePython)}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeCodeExecutableCode) + return p + }(), genaiPart: &genai.Part{ ExecutableCode: &genai.ExecutableCode{ Code: "print(2+2)", @@ -154,11 +164,11 @@ func TestPartsTwoWayConversion(t *testing.T) { if err != nil { t.Errorf("toA2AParts() error = %v, want nil", err) } - if diff := cmp.Diff([]a2a.Part{tc.a2aPart}, toA2A); diff != "" { + if diff := cmp.Diff([]*a2a.Part{tc.a2aPart}, toA2A); diff != "" { t.Errorf("toA2AParts() wrong result (+got,-want)\ngot = %v\nwant = %v\ndiff = %s", toA2A, tc.a2aPart, diff) } - toGenAI, err := ToGenAIParts([]a2a.Part{tc.a2aPart}) + toGenAI, err := ToGenAIParts([]*a2a.Part{tc.a2aPart}) if err != nil { t.Errorf("toGenAIParts() error = %v, want nil", err) } @@ -170,10 +180,10 @@ func TestPartsTwoWayConversion(t *testing.T) { } func TestPartsDataPartConversionRoundTrip(t *testing.T) { - a2aPart := a2a.DataPart{Data: map[string]any{"arbitrary": "data"}} + a2aPart := a2a.NewDataPart(map[string]any{"arbitrary": "data"}) wantGenAI := &genai.Part{InlineData: &genai.Blob{Data: []byte("{\"arbitrary\":\"data\"}"), MIMEType: "text/plain"}} - gotGenAI, err := ToGenAIParts([]a2a.Part{a2aPart}) + gotGenAI, err := ToGenAIParts([]*a2a.Part{a2aPart}) if err != nil { t.Fatalf("toGenAI() error = %v, want nil", err) } @@ -185,7 +195,7 @@ func TestPartsDataPartConversionRoundTrip(t *testing.T) { if err != nil { t.Fatalf("toA2AParts() error = %v, want nil", err) } - if diff := cmp.Diff([]a2a.Part{a2aPart}, gotbackA2A); diff != "" { + if diff := cmp.Diff([]*a2a.Part{a2aPart}, gotbackA2A); diff != "" { t.Fatalf("toA2AParts() wrong result (+got,-want)\ngot = %v\nwant = %v\ndiff = %s", gotbackA2A, a2aPart, diff) } } diff --git a/server/adka2a/processor.go b/server/adka2a/v2/processor.go similarity index 88% rename from server/adka2a/processor.go rename to server/adka2a/v2/processor.go index 696d46bce..f11ffae26 100644 --- a/server/adka2a/processor.go +++ b/server/adka2a/v2/processor.go @@ -19,20 +19,21 @@ import ( "fmt" "maps" - "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/a2aproject/a2a-go/v2/log" "google.golang.org/adk/model" "google.golang.org/adk/session" ) type eventToArtifactTransform interface { - transform(event *session.Event, parts []a2a.Part, meta map[string]any) (*a2a.TaskArtifactUpdateEvent, error) + transform(event *session.Event, parts []*a2a.Part, meta map[string]any) (*a2a.TaskArtifactUpdateEvent, error) makeFinalUpdate() *a2a.TaskArtifactUpdateEvent } type eventProcessor struct { - reqCtx *a2asrv.RequestContext + reqCtx *a2asrv.ExecutorContext meta invocationMeta partConverter GenAIPartConverter @@ -54,7 +55,7 @@ type eventProcessor struct { } func newEventProcessor( - reqCtx *a2asrv.RequestContext, + reqCtx *a2asrv.ExecutorContext, meta invocationMeta, converter GenAIPartConverter, transform eventToArtifactTransform, @@ -125,7 +126,6 @@ func (p *eventProcessor) makeFinalStatusUpdate() *a2a.TaskStatusUpdateEvent { } ev := a2a.NewStatusUpdateEvent(p.reqCtx, a2a.TaskStateCompleted, nil) - ev.Final = true // we're modifying base processor metadata which might have been sent with one of the previous events. // this update shouldn't be reflected in the sent events' metadata. baseMetaCopy := maps.Clone(p.meta.eventMeta) @@ -133,11 +133,11 @@ func (p *eventProcessor) makeFinalStatusUpdate() *a2a.TaskStatusUpdateEvent { return ev } -func (p *eventProcessor) makeTaskFailedEvent(cause error, event *session.Event) *a2a.TaskStatusUpdateEvent { +func (p *eventProcessor) makeTaskFailedEvent(ctx context.Context, cause error, event *session.Event) *a2a.TaskStatusUpdateEvent { meta := p.meta.eventMeta if event != nil { if eventMeta, err := toEventMeta(p.meta, event); err != nil { - // TODO(yarolegovich): log ignored error + log.Warn(ctx, "failed to convert event metadata for task failed event", "cause", err) } else { meta = eventMeta } @@ -152,7 +152,7 @@ func (p *eventProcessor) updateTerminalActions(event *session.Event) { } } -func (p *eventProcessor) convertParts(ctx context.Context, event *session.Event) ([]a2a.Part, error) { +func (p *eventProcessor) convertParts(ctx context.Context, event *session.Event) ([]*a2a.Part, error) { if event.Content == nil || len(event.Content.Parts) == 0 { return nil, nil } @@ -160,7 +160,7 @@ func (p *eventProcessor) convertParts(ctx context.Context, event *session.Event) if p.partConverter == nil { return ToA2AParts(parts, event.LongRunningToolIDs) } - converted := make([]a2a.Part, 0, len(parts)) + converted := make([]*a2a.Part, 0, len(parts)) for _, part := range parts { cp, err := p.partConverter(ctx, event, part) if err != nil { @@ -175,10 +175,9 @@ func (p *eventProcessor) convertParts(ctx context.Context, event *session.Event) } func toTaskFailedUpdateEvent(task a2a.TaskInfoProvider, cause error, meta map[string]any) *a2a.TaskStatusUpdateEvent { - msg := a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.TextPart{Text: cause.Error()}) + msg := a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.NewTextPart(cause.Error())) ev := a2a.NewStatusUpdateEvent(task, a2a.TaskStateFailed, msg) ev.Metadata = meta - ev.Final = true return ev } diff --git a/server/adka2a/processor_test.go b/server/adka2a/v2/processor_test.go similarity index 78% rename from server/adka2a/processor_test.go rename to server/adka2a/v2/processor_test.go index 138bb68bc..d7845996b 100644 --- a/server/adka2a/processor_test.go +++ b/server/adka2a/v2/processor_test.go @@ -17,8 +17,8 @@ package adka2a import ( "testing" - "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genai" @@ -37,7 +37,7 @@ func modelPartialResponseFromParts(parts ...*genai.Part) model.LLMResponse { return resp } -func newNonPartialArtifactEvent(task *a2a.Task, parts ...a2a.Part) *a2a.TaskArtifactUpdateEvent { +func newNonPartialArtifactEvent(task *a2a.Task, parts ...*a2a.Part) *a2a.TaskArtifactUpdateEvent { ev := a2a.NewArtifactEvent(task, parts...) // It is important for events to be explicitely marked as ADK partial or non-partial. // This signals to consumers that the remote agent is running its own aggregation logic. @@ -45,22 +45,20 @@ func newNonPartialArtifactEvent(task *a2a.Task, parts ...a2a.Part) *a2a.TaskArti return ev } -func newNonPartialArtifactUpdateEvent(task *a2a.Task, parts ...a2a.Part) *a2a.TaskArtifactUpdateEvent { +func newNonPartialArtifactUpdateEvent(task *a2a.Task, parts ...*a2a.Part) *a2a.TaskArtifactUpdateEvent { ev := newNonPartialArtifactEvent(task, parts...) ev.Append = true return ev } func newDiscardPartialArtifactUpdate(task *a2a.Task) *a2a.TaskArtifactUpdateEvent { - ev := newLegacyPartialArtifactUpdate(task, "", []a2a.Part{a2a.DataPart{Data: map[string]any{}}}) + ev := newLegacyPartialArtifactUpdate(task, "", []*a2a.Part{a2a.NewDataPart(map[string]any{})}) ev.LastChunk = true return ev } func newFinalStatusUpdate(task *a2a.Task, state a2a.TaskState, msg *a2a.Message) *a2a.TaskStatusUpdateEvent { - ev := a2a.NewStatusUpdateEvent(task, state, msg) - ev.Final = true - return ev + return a2a.NewStatusUpdateEvent(task, state, msg) } func TestEventProcessor_Process(t *testing.T) { @@ -95,7 +93,7 @@ func TestEventProcessor_Process(t *testing.T) { LLMResponse: modelResponseFromParts(genai.NewPartFromText("Hello"), genai.NewPartFromText(", world!")), }}, processed: []*a2a.TaskArtifactUpdateEvent{ - newNonPartialArtifactEvent(task, a2a.TextPart{Text: "Hello"}, a2a.TextPart{Text: ", world!"}), + newNonPartialArtifactEvent(task, a2a.NewTextPart("Hello"), a2a.NewTextPart(", world!")), }, terminal: []a2a.Event{newFinalStatusUpdate(task, a2a.TaskStateCompleted, nil)}, }, @@ -107,15 +105,17 @@ func TestEventProcessor_Process(t *testing.T) { {LLMResponse: modelResponseFromParts(genai.NewPartFromText("The answer is 42"))}, }, processed: []*a2a.TaskArtifactUpdateEvent{ - newNonPartialArtifactEvent(task, a2a.DataPart{ - Data: map[string]any{"code": "get_the_answer()", "language": string(genai.LanguagePython)}, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeCodeExecutableCode}, - }), - newNonPartialArtifactUpdateEvent(task, a2a.DataPart{ - Data: map[string]any{"outcome": string(genai.OutcomeOK), "output": "42"}, - Metadata: map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeCodeExecResult}, - }), - newNonPartialArtifactUpdateEvent(task, a2a.TextPart{Text: "The answer is 42"}), + newNonPartialArtifactEvent(task, func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"code": "get_the_answer()", "language": string(genai.LanguagePython)}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeCodeExecutableCode) + return p + }()), + newNonPartialArtifactUpdateEvent(task, func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"outcome": string(genai.OutcomeOK), "output": "42"}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeCodeExecResult) + return p + }()), + newNonPartialArtifactUpdateEvent(task, a2a.NewTextPart("The answer is 42")), }, terminal: []a2a.Event{newFinalStatusUpdate(task, a2a.TaskStateCompleted, nil)}, }, @@ -152,8 +152,8 @@ func TestEventProcessor_Process(t *testing.T) { {LLMResponse: model.LLMResponse{ErrorCode: "1", ErrorMessage: "failed"}}, }, processed: []*a2a.TaskArtifactUpdateEvent{ - newNonPartialArtifactEvent(task, a2a.TextPart{Text: "The answer is"}), - newNonPartialArtifactUpdateEvent(task, a2a.TextPart{Text: "42"}), + newNonPartialArtifactEvent(task, a2a.NewTextPart("The answer is")), + newNonPartialArtifactUpdateEvent(task, a2a.NewTextPart("42")), }, terminal: []a2a.Event{ toTaskFailedUpdateEvent( @@ -170,8 +170,8 @@ func TestEventProcessor_Process(t *testing.T) { {LLMResponse: modelResponseFromParts(genai.NewPartFromText("42"))}, }, processed: []*a2a.TaskArtifactUpdateEvent{ - newNonPartialArtifactEvent(task, a2a.TextPart{Text: "The answer is"}), - newNonPartialArtifactUpdateEvent(task, a2a.TextPart{Text: "42"}), + newNonPartialArtifactEvent(task, a2a.NewTextPart("The answer is")), + newNonPartialArtifactUpdateEvent(task, a2a.NewTextPart("42")), }, terminal: []a2a.Event{ toTaskFailedUpdateEvent( @@ -186,13 +186,12 @@ func TestEventProcessor_Process(t *testing.T) { {LLMResponse: modelResponseFromParts(genai.NewPartFromFunctionCall("get_weather", map[string]any{"city": "Warsaw"}))}, }, processed: []*a2a.TaskArtifactUpdateEvent{ - newNonPartialArtifactEvent(task, a2a.DataPart{ - Data: map[string]any{"name": "get_weather", "args": map[string]any{"city": "Warsaw"}}, - Metadata: map[string]any{ - a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, - a2aDataPartMetaLongRunningKey: false, - }, - }), + newNonPartialArtifactEvent(task, func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"name": "get_weather", "args": map[string]any{"city": "Warsaw"}}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionCall) + p.SetMeta(a2aDataPartMetaLongRunningKey, false) + return p + }()), }, terminal: []a2a.Event{ newFinalStatusUpdate(task, a2a.TaskStateCompleted, nil), @@ -212,14 +211,13 @@ func TestEventProcessor_Process(t *testing.T) { terminal: []a2a.Event{ newFinalStatusUpdate(task, a2a.TaskStateInputRequired, &a2a.Message{ Role: a2a.MessageRoleAgent, - Parts: []a2a.Part{ - a2a.DataPart{ - Data: map[string]any{"id": "get_weather", "name": "weather", "args": map[string]any{"city": "Warsaw"}}, - Metadata: map[string]any{ - a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, - a2aDataPartMetaLongRunningKey: true, - }, - }, + Parts: []*a2a.Part{ + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"id": "get_weather", "name": "weather", "args": map[string]any{"city": "Warsaw"}}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionCall) + p.SetMeta(a2aDataPartMetaLongRunningKey, true) + return p + }(), }, }), }, @@ -238,20 +236,19 @@ func TestEventProcessor_Process(t *testing.T) { }, }, processed: []*a2a.TaskArtifactUpdateEvent{ - newNonPartialArtifactEvent(task, a2a.TextPart{Text: "This will take a while"}), + newNonPartialArtifactEvent(task, a2a.NewTextPart("This will take a while")), }, terminal: []a2a.Event{ newFinalStatusUpdate(task, a2a.TaskStateInputRequired, &a2a.Message{ Role: a2a.MessageRoleAgent, - Parts: []a2a.Part{ - a2a.DataPart{ - Data: map[string]any{"id": "get_weather", "name": "weather", "args": map[string]any{"city": "Warsaw"}}, - Metadata: map[string]any{ - a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, - a2aDataPartMetaLongRunningKey: true, - }, - }, + Parts: []*a2a.Part{ + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"id": "get_weather", "name": "weather", "args": map[string]any{"city": "Warsaw"}}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionCall) + p.SetMeta(a2aDataPartMetaLongRunningKey, true) + return p + }(), }, }), }, @@ -276,20 +273,18 @@ func TestEventProcessor_Process(t *testing.T) { terminal: []a2a.Event{ newFinalStatusUpdate(task, a2a.TaskStateInputRequired, &a2a.Message{ Role: a2a.MessageRoleAgent, - Parts: []a2a.Part{ - a2a.DataPart{ - Data: map[string]any{"id": "get_weather", "name": "weather", "args": map[string]any{"city": "Warsaw"}}, - Metadata: map[string]any{ - a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, - a2aDataPartMetaLongRunningKey: true, - }, - }, - a2a.DataPart{ - Data: map[string]any{"id": "get_weather", "name": "weather", "response": map[string]any{"status": "pending"}}, - Metadata: map[string]any{ - a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionResponse, - }, - }, + Parts: []*a2a.Part{ + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"id": "get_weather", "name": "weather", "args": map[string]any{"city": "Warsaw"}}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionCall) + p.SetMeta(a2aDataPartMetaLongRunningKey, true) + return p + }(), + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"id": "get_weather", "name": "weather", "response": map[string]any{"status": "pending"}}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeFunctionResponse) + return p + }(), }, }), }, @@ -305,7 +300,6 @@ func TestEventProcessor_Process(t *testing.T) { ContextID: task.ContextID, Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, Metadata: map[string]any{metadataEscalateKey: true, metadataTransferToAgentKey: "a-2"}, - Final: true, }, }, }, @@ -321,7 +315,6 @@ func TestEventProcessor_Process(t *testing.T) { ContextID: task.ContextID, Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, Metadata: map[string]any{metadataTransferToAgentKey: "a-3"}, - Final: true, }, }, }, @@ -335,7 +328,7 @@ func TestEventProcessor_Process(t *testing.T) { {LLMResponse: model.LLMResponse{ErrorCode: "1", ErrorMessage: "failed"}}, }, processed: []*a2a.TaskArtifactUpdateEvent{ - newNonPartialArtifactEvent(task, a2a.TextPart{Text: "The answer is"}), + newNonPartialArtifactEvent(task, a2a.NewTextPart("The answer is")), }, terminal: []a2a.Event{ toTaskFailedUpdateEvent( @@ -358,25 +351,31 @@ func TestEventProcessor_Process(t *testing.T) { )}, }, processed: []*a2a.TaskArtifactUpdateEvent{ - newLegacyPartialArtifactUpdate(task, artifactIDPlaceholder, []a2a.Part{ - a2a.TextPart{Text: "The answer is", Metadata: map[string]any{ToA2AMetaKey("partial"): true}}, - a2a.DataPart{ - Data: map[string]any{"code": "get_the_answer()", "language": string(genai.LanguagePython)}, - Metadata: map[string]any{ - a2aDataPartMetaTypeKey: a2aDataPartTypeCodeExecutableCode, - ToA2AMetaKey("partial"): true, - }, - }, + newLegacyPartialArtifactUpdate(task, artifactIDPlaceholder, []*a2a.Part{ + func() *a2a.Part { + p := a2a.NewTextPart("The answer is") + p.SetMeta(ToA2AMetaKey("partial"), true) + return p + }(), + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"code": "get_the_answer()", "language": string(genai.LanguagePython)}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeCodeExecutableCode) + p.SetMeta(ToA2AMetaKey("partial"), true) + return p + }(), }), - newLegacyPartialArtifactUpdate(task, artifactIDPlaceholder, []a2a.Part{ - a2a.DataPart{ - Data: map[string]any{"outcome": string(genai.OutcomeOK), "output": "42"}, - Metadata: map[string]any{ - a2aDataPartMetaTypeKey: a2aDataPartTypeCodeExecResult, - ToA2AMetaKey("partial"): true, - }, - }, - a2a.TextPart{Text: "42", Metadata: map[string]any{ToA2AMetaKey("partial"): true}}, + newLegacyPartialArtifactUpdate(task, artifactIDPlaceholder, []*a2a.Part{ + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"outcome": string(genai.OutcomeOK), "output": "42"}) + p.SetMeta(a2aDataPartMetaTypeKey, a2aDataPartTypeCodeExecResult) + p.SetMeta(ToA2AMetaKey("partial"), true) + return p + }(), + func() *a2a.Part { + p := a2a.NewTextPart("42") + p.SetMeta(ToA2AMetaKey("partial"), true) + return p + }(), }), }, terminal: []a2a.Event{ @@ -392,7 +391,7 @@ func TestEventProcessor_Process(t *testing.T) { }}, processed: []*a2a.TaskArtifactUpdateEvent{ func() *a2a.TaskArtifactUpdateEvent { - ev := newNonPartialArtifactEvent(task, a2a.TextPart{Text: "Hello"}) + ev := newNonPartialArtifactEvent(task, a2a.NewTextPart("Hello")) ev.Metadata[ToA2AMetaKey("invocation_id")] = "test-invocation-id" return ev }(), @@ -408,7 +407,7 @@ func TestEventProcessor_Process(t *testing.T) { cmpopts.IgnoreFields(a2a.TaskStatus{}, "Timestamp"), } t.Run(tc.name, func(t *testing.T) { - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID} + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID} processor := newEventProcessor(reqCtx, invocationMeta{}, nil, newLegacyArtifactMaker(reqCtx)) var gotEvents []*a2a.TaskArtifactUpdateEvent @@ -456,7 +455,7 @@ func TestEventProcessor_ArtifactUpdates(t *testing.T) { }, } - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID} + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID} processor := newEventProcessor(reqCtx, invocationMeta{}, nil, newLegacyArtifactMaker(reqCtx)) got := make([]*a2a.TaskArtifactUpdateEvent, len(events)) for i, event := range events { @@ -499,7 +498,7 @@ func TestEventProcessor_PartialEventsAreDiscardedAsAnArtifact(t *testing.T) { {LLMResponse: modelResponseFromParts(genai.NewPartFromText("Hello, world!"))}, } - reqCtx := &a2asrv.RequestContext{TaskID: task.ID, ContextID: task.ContextID} + reqCtx := &a2asrv.ExecutorContext{TaskID: task.ID, ContextID: task.ContextID} processor := newEventProcessor(reqCtx, invocationMeta{}, nil, newLegacyArtifactMaker(reqCtx)) got := make([]*a2a.TaskArtifactUpdateEvent, len(events)) for i, event := range events { @@ -544,8 +543,12 @@ func TestEventProcessor_PartialEventsAreDiscardedAsAnArtifact(t *testing.T) { TaskID: task.ID, ContextID: task.ContextID, Artifact: &a2a.Artifact{ - ID: got[0].Artifact.ID, - Parts: a2a.ContentParts{a2a.DataPart{Data: map[string]any{}, Metadata: map[string]any{metadataPartialKey: true}}}, + ID: got[0].Artifact.ID, + Parts: a2a.ContentParts{func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{}) + p.SetMeta(metadataPartialKey, true) + return p + }()}, Metadata: map[string]any{metadataPartialKey: true}, }, Metadata: map[string]any{metadataPartialKey: true}, diff --git a/server/adka2a/task_artifact.go b/server/adka2a/v2/task_artifact.go similarity index 86% rename from server/adka2a/task_artifact.go rename to server/adka2a/v2/task_artifact.go index 4458f8a20..6200b79d4 100644 --- a/server/adka2a/task_artifact.go +++ b/server/adka2a/v2/task_artifact.go @@ -17,18 +17,18 @@ package adka2a import ( "maps" - "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" "google.golang.org/adk/session" ) type artifactMaker struct { - reqCtx *a2asrv.RequestContext + reqCtx *a2asrv.ExecutorContext lastAgentPartialArtifact map[string]a2a.ArtifactID } -func newArtifactMaker(reqCtx *a2asrv.RequestContext) *artifactMaker { +func newArtifactMaker(reqCtx *a2asrv.ExecutorContext) *artifactMaker { return &artifactMaker{ reqCtx: reqCtx, lastAgentPartialArtifact: make(map[string]a2a.ArtifactID), @@ -37,7 +37,7 @@ func newArtifactMaker(reqCtx *a2asrv.RequestContext) *artifactMaker { var _ eventToArtifactTransform = (*artifactMaker)(nil) -func (m *artifactMaker) transform(event *session.Event, parts []a2a.Part, meta map[string]any) (*a2a.TaskArtifactUpdateEvent, error) { +func (m *artifactMaker) transform(event *session.Event, parts []*a2a.Part, meta map[string]any) (*a2a.TaskArtifactUpdateEvent, error) { result := a2a.NewArtifactEvent(m.reqCtx, parts...) if artifactID, ok := m.lastAgentPartialArtifact[event.Author]; ok { @@ -68,7 +68,7 @@ func (m *artifactMaker) makeFinalUpdate() *a2a.TaskArtifactUpdateEvent { } type legacyArtifactMaker struct { - reqCtx *a2asrv.RequestContext + reqCtx *a2asrv.ExecutorContext // responseID is created once the first TaskArtifactUpdateEvent is sent. Used for subsequent artifact updates. responseID a2a.ArtifactID @@ -79,7 +79,7 @@ type legacyArtifactMaker struct { partialResponseID a2a.ArtifactID } -func newLegacyArtifactMaker(reqCtx *a2asrv.RequestContext) *legacyArtifactMaker { +func newLegacyArtifactMaker(reqCtx *a2asrv.ExecutorContext) *legacyArtifactMaker { return &legacyArtifactMaker{ reqCtx: reqCtx, } @@ -87,7 +87,7 @@ func newLegacyArtifactMaker(reqCtx *a2asrv.RequestContext) *legacyArtifactMaker var _ eventToArtifactTransform = (*legacyArtifactMaker)(nil) -func (p *legacyArtifactMaker) transform(event *session.Event, parts []a2a.Part, meta map[string]any) (*a2a.TaskArtifactUpdateEvent, error) { +func (p *legacyArtifactMaker) transform(event *session.Event, parts []*a2a.Part, meta map[string]any) (*a2a.TaskArtifactUpdateEvent, error) { var result *a2a.TaskArtifactUpdateEvent if event.Partial { result = newLegacyPartialArtifactUpdate(p.reqCtx, p.partialResponseID, parts) @@ -109,12 +109,12 @@ func (p *legacyArtifactMaker) makeFinalUpdate() *a2a.TaskArtifactUpdateEvent { if p.partialResponseID == "" { return nil } - ev := newLegacyPartialArtifactUpdate(p.reqCtx, p.partialResponseID, []a2a.Part{a2a.DataPart{Data: map[string]any{}}}) + ev := newLegacyPartialArtifactUpdate(p.reqCtx, p.partialResponseID, []*a2a.Part{a2a.NewDataPart(map[string]any{})}) ev.LastChunk = true return ev } -func newLegacyArtifactUpdate(task a2a.TaskInfoProvider, id a2a.ArtifactID, parts []a2a.Part) *a2a.TaskArtifactUpdateEvent { +func newLegacyArtifactUpdate(task a2a.TaskInfoProvider, id a2a.ArtifactID, parts []*a2a.Part) *a2a.TaskArtifactUpdateEvent { var result *a2a.TaskArtifactUpdateEvent if id == "" { result = a2a.NewArtifactEvent(task, parts...) @@ -127,7 +127,7 @@ func newLegacyArtifactUpdate(task a2a.TaskInfoProvider, id a2a.ArtifactID, parts return result } -func newLegacyPartialArtifactUpdate(task a2a.TaskInfoProvider, artifactID a2a.ArtifactID, parts []a2a.Part) *a2a.TaskArtifactUpdateEvent { +func newLegacyPartialArtifactUpdate(task a2a.TaskInfoProvider, artifactID a2a.ArtifactID, parts []*a2a.Part) *a2a.TaskArtifactUpdateEvent { ev := newLegacyArtifactUpdate(task, artifactID, parts) updatePartsMetadata(parts, map[string]any{metadataPartialKey: true}) if ev.Artifact.Metadata == nil { diff --git a/server/adka2a/utils.go b/server/adka2a/v2/utils.go similarity index 97% rename from server/adka2a/utils.go rename to server/adka2a/v2/utils.go index 04f60615d..c8bf31835 100644 --- a/server/adka2a/utils.go +++ b/server/adka2a/v2/utils.go @@ -15,7 +15,7 @@ package adka2a import ( - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "google.golang.org/adk/agent" iagent "google.golang.org/adk/internal/agent" From 6ceec3bba5dc7cf11f24b7b6a970303cc9855249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20G=C3=B3rski?= Date: Tue, 5 May 2026 10:02:09 +0200 Subject: [PATCH 32/86] =?UTF-8?q?refactor:=20rename=20experimental=20reaso?= =?UTF-8?q?ning=20tokens=20attribute=20to=20gen=5Fai.us=E2=80=A6=20(#779)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: rename experimental reasoning tokens attribute to gen_ai.usage.reasoning.output_tokens * fix: include thoughts token count in total output token calculation for telemetry spans * refactor: fix formatting of telemetry attribute key and token count calculation * refactor: add comment explaining gen_ai.usage.reasoning.output_tokens and gen_ai.usage.output_tokens relation --- internal/telemetry/telemetry.go | 19 +++++++++++-------- internal/telemetry/telemetry_test.go | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 68ccca916..87cfe6846 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -42,12 +42,12 @@ const ( ) var ( - gcpVertexAgentToolCallArgsName = attribute.Key("gcp.vertex.agent.tool_call_args") - gcpVertexAgentEventID = attribute.Key("gcp.vertex.agent.event_id") - gcpVertexAgentToolResponseName = attribute.Key("gcp.vertex.agent.tool_response") - gcpVertexAgentInvocationID = attribute.Key("gcp.vertex.agent.invocation_id") - genAIUsageCacheReadInputTokens = attribute.Key("gen_ai.usage.cache_read.input_tokens") - genAIUsageExperimentalReasoningTokens = attribute.Key("gen_ai.usage.experimental.reasoning_tokens") + gcpVertexAgentToolCallArgsName = attribute.Key("gcp.vertex.agent.tool_call_args") + gcpVertexAgentEventID = attribute.Key("gcp.vertex.agent.event_id") + gcpVertexAgentToolResponseName = attribute.Key("gcp.vertex.agent.tool_response") + gcpVertexAgentInvocationID = attribute.Key("gcp.vertex.agent.invocation_id") + genAIUsageCacheReadInputTokens = attribute.Key("gen_ai.usage.cache_read.input_tokens") + genAIUsageReasoningOutputTokens = attribute.Key("gen_ai.usage.reasoning.output_tokens") ) // tracer is the tracer instance for ADK go. @@ -126,9 +126,12 @@ func TraceGenerateContentResult(span trace.Span, params TraceGenerateContentResu if params.Response.UsageMetadata != nil { span.SetAttributes( semconv.GenAIUsageInputTokens(int(params.Response.UsageMetadata.PromptTokenCount)), - semconv.GenAIUsageOutputTokens(int(params.Response.UsageMetadata.CandidatesTokenCount)), + // According to OpenTelemetry Semantic Conventions: + // https://github.com/open-telemetry/semantic-conventions/blob/v1.41.0/docs/registry/attributes/gen-ai.md + // gen_ai.usage.reasoning.output_tokens (ThoughtsTokenCount) SHOULD be included in gen_ai.usage.output_tokens. + semconv.GenAIUsageOutputTokens(int(params.Response.UsageMetadata.CandidatesTokenCount+params.Response.UsageMetadata.ThoughtsTokenCount)), genAIUsageCacheReadInputTokens.Int(int(params.Response.UsageMetadata.CachedContentTokenCount)), - genAIUsageExperimentalReasoningTokens.Int(int(params.Response.UsageMetadata.ThoughtsTokenCount)), + genAIUsageReasoningOutputTokens.Int(int(params.Response.UsageMetadata.ThoughtsTokenCount)), ) } } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index d775a4f3c..7e1b6e298 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -230,9 +230,9 @@ func TestGenerateContent(t *testing.T) { semconv.GenAIOperationNameKey: "generate_content", semconv.GenAIRequestModelKey: "test-model", semconv.GenAIUsageInputTokensKey: "10", - semconv.GenAIUsageOutputTokensKey: "20", + semconv.GenAIUsageOutputTokensKey: "35", genAIUsageCacheReadInputTokens: "5", - genAIUsageExperimentalReasoningTokens: "15", + genAIUsageReasoningOutputTokens: "15", semconv.GenAIResponseFinishReasonsKey: "[\"STOP\"]", gcpVertexAgentInvocationID: invocationID, }, From 8369260531b706978d5200feb4ab1d80743c4d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Tue, 5 May 2026 16:01:40 +0100 Subject: [PATCH 33/86] fix: propagate thought signature to first function call in mixed responses (#788) * fix: propagate thought signature to first function call in mixed responses Propagates the thought signature of a candidate response to its first subsequent function call if it lacks one. This ensures Vertex AI validates the turn history when echoing it back on the next turn. * remove gitignore changes --- internal/llminternal/stream_aggregator.go | 9 + .../llminternal/stream_aggregator_test.go | 154 ++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/internal/llminternal/stream_aggregator.go b/internal/llminternal/stream_aggregator.go index ac7b2d0af..6ab0e5550 100644 --- a/internal/llminternal/stream_aggregator.go +++ b/internal/llminternal/stream_aggregator.go @@ -37,6 +37,8 @@ type streamingResponseAggregator struct { citationMetadata *genai.CitationMetadata response *model.LLMResponse + currentThoughtSignature []byte + sequence []*genai.Part currentTextBuffer string currentTextIsThought bool @@ -102,6 +104,9 @@ func (s *streamingResponseAggregator) aggregateResponse(llmResponse *model.LLMRe if reflect.ValueOf(*part).IsZero() { continue } + if len(part.ThoughtSignature) > 0 { + s.currentThoughtSignature = part.ThoughtSignature + } if part.Text != "" { if s.currentTextBuffer != "" && part.Thought != s.currentTextIsThought { s.flushTextBufferToSequence() @@ -135,6 +140,10 @@ func (s *streamingResponseAggregator) processFunctionCallPart(part *genai.Part) } else { if part.FunctionCall.Name != "" { s.flushTextBufferToSequence() + if part.ThoughtSignature == nil && s.currentThoughtSignature != nil { + part.ThoughtSignature = s.currentThoughtSignature + } + s.currentThoughtSignature = nil s.sequence = append(s.sequence, part) } } diff --git a/internal/llminternal/stream_aggregator_test.go b/internal/llminternal/stream_aggregator_test.go index 0e18153ee..87c23f764 100644 --- a/internal/llminternal/stream_aggregator_test.go +++ b/internal/llminternal/stream_aggregator_test.go @@ -148,6 +148,160 @@ func TestProgressiveSSEStreamingFunctionCallArguments(t *testing.T) { } } +func TestThoughtSignaturePropagationToFirstFunctionCallSeparateResponses(t *testing.T) { + aggregator := llminternal.NewStreamingResponseAggregator() + ctx := t.Context() + + testThoughtSignature := []byte("test_signature_123") + + chunk1 := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + { + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{ + { + // Emulate an executable code part carrying thought signature + ThoughtSignature: testThoughtSignature, + }, + }, + }, + }, + }, + } + + chunk2 := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + { + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{ + { + FunctionCall: &genai.FunctionCall{ + Name: "print", + Args: map[string]any{"message": "hello"}, + }, + }, + { + FunctionCall: &genai.FunctionCall{ + Name: "print", + Args: map[string]any{"message": "world"}, + }, + }, + }, + }, + FinishReason: genai.FinishReasonStop, + }, + }, + } + + for _, chunk := range []*genai.GenerateContentResponse{chunk1, chunk2} { + for _, err := range aggregator.ProcessResponse(ctx, chunk) { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + } + + finalResponse := aggregator.Close() + if finalResponse == nil { + t.Fatal("expected final response") + } + + parts := finalResponse.Content.Parts + if len(parts) != 3 { + t.Fatalf("expected 3 parts, got %d", len(parts)) + } + + // The first function call should get the propagated thought signature + fcPart1 := parts[1] + if fcPart1.FunctionCall == nil || fcPart1.FunctionCall.Name != "print" { + t.Fatal("expected first function call to be print") + } + if string(fcPart1.ThoughtSignature) != string(testThoughtSignature) { + t.Errorf("expected first function call to have thought signature %s, got %s", string(testThoughtSignature), string(fcPart1.ThoughtSignature)) + } + + // The second function call should NOT get the signature (as intended by user) + fcPart2 := parts[2] + if fcPart2.FunctionCall == nil || fcPart2.FunctionCall.Name != "print" { + t.Fatal("expected second function call to be print") + } + if len(fcPart2.ThoughtSignature) > 0 { + t.Errorf("expected second function call to have no thought signature, but got %s", string(fcPart2.ThoughtSignature)) + } +} + +func TestThoughtSignaturePropagationToFirstFunctionCallSingleResponse(t *testing.T) { + aggregator := llminternal.NewStreamingResponseAggregator() + ctx := t.Context() + + testThoughtSignature := []byte("test_signature_123") + + chunk := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + { + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{ + { + // Emulate an executable code part carrying thought signature + ThoughtSignature: testThoughtSignature, + }, + { + FunctionCall: &genai.FunctionCall{ + Name: "print", + Args: map[string]any{"message": "hello"}, + }, + }, + { + FunctionCall: &genai.FunctionCall{ + Name: "print", + Args: map[string]any{"message": "world"}, + }, + }, + }, + }, + FinishReason: genai.FinishReasonStop, + }, + }, + } + + for _, err := range aggregator.ProcessResponse(ctx, chunk) { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + finalResponse := aggregator.Close() + if finalResponse == nil { + t.Fatal("expected final response") + } + + parts := finalResponse.Content.Parts + if len(parts) != 3 { + t.Fatalf("expected 3 parts, got %d", len(parts)) + } + + // The first function call should get the propagated thought signature + fcPart1 := parts[1] + if fcPart1.FunctionCall == nil || fcPart1.FunctionCall.Name != "print" { + t.Fatal("expected first function call to be print") + } + if string(fcPart1.ThoughtSignature) != string(testThoughtSignature) { + t.Errorf("expected first function call to have thought signature %s, got %s", string(testThoughtSignature), string(fcPart1.ThoughtSignature)) + } + + // The second function call should NOT get the signature (as intended by user) + fcPart2 := parts[2] + if fcPart2.FunctionCall == nil || fcPart2.FunctionCall.Name != "print" { + t.Fatal("expected second function call to be print") + } + if len(fcPart2.ThoughtSignature) > 0 { + t.Errorf("expected second function call to have no thought signature, but got %s", string(fcPart2.ThoughtSignature)) + } +} + func TestProgressiveSSEPreservesThoughtSignature(t *testing.T) { aggregator := llminternal.NewStreamingResponseAggregator() ctx := t.Context() From 48ac84b97dc3b9bbf0647a705a6e72a9fd4e57c5 Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Fri, 8 May 2026 14:30:10 +0200 Subject: [PATCH 34/86] Added VertexAI MemoryBank support (#801) * Added support for Memory Bank * Fixed comments and names in util/vertexai * Fixed names * Fixed aiplatform package comment * Removed debug * Small fixes * Context fix --- .../deploy/agentengine/agentengine.go | 59 +++++++- cmd/launcher/console/console.go | 1 + cmd/launcher/web/a2a/a2a.go | 1 + examples/agentengine/main.go | 101 ++++++++++++- memory/vertexai/vertexai.go | 95 ++++++++++++ memory/vertexai/vertexai_client.go | 139 ++++++++++++++++++ .../controllers/method/stream_query.go | 1 + session/vertexai/vertexai_client.go | 46 ++++-- session/vertexai/vertexai_test.go | 6 +- util/aiplatform/aiplatform.go | 31 ++++ util/vertexai/agent_engine.go | 41 ++++++ 11 files changed, 500 insertions(+), 21 deletions(-) create mode 100644 memory/vertexai/vertexai.go create mode 100644 memory/vertexai/vertexai_client.go create mode 100644 util/aiplatform/aiplatform.go create mode 100644 util/vertexai/agent_engine.go diff --git a/cmd/adkgo/internal/deploy/agentengine/agentengine.go b/cmd/adkgo/internal/deploy/agentengine/agentengine.go index 2f9518c27..2e03f9e2f 100644 --- a/cmd/adkgo/internal/deploy/agentengine/agentengine.go +++ b/cmd/adkgo/internal/deploy/agentengine/agentengine.go @@ -28,10 +28,11 @@ import ( "strings" "time" - aiplatform "cloud.google.com/go/aiplatform/apiv1" - "cloud.google.com/go/aiplatform/apiv1/aiplatformpb" + aiplatform "cloud.google.com/go/aiplatform/apiv1beta1" + "cloud.google.com/go/aiplatform/apiv1beta1/aiplatformpb" "github.com/spf13/cobra" "google.golang.org/api/option" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/fieldmaskpb" "google.golang.org/adk/cmd/adkgo/internal/deploy" @@ -44,11 +45,18 @@ type gCloudFlags struct { projectName string } +type memoryBankFlags struct { + deploy bool + model string + ttl time.Duration +} + type agentEngineServiceFlags struct { name string displayName string serverPort int agentEngineID string + memoryBank memoryBankFlags } type buildFlags struct { @@ -81,7 +89,7 @@ var agentEngineCmd = &cobra.Command{ Short: "Deploys the application to Agent Engine.", Long: `Deploys the application to Agent Engine. It creates a source archive, uploads it to create a Reasoning Engine, and cleans up temporary files.`, RunE: func(cmd *cobra.Command, args []string) error { - return flags.deployOnagentEngine() + return flags.deployOnAgentEngine() }, } @@ -97,6 +105,11 @@ func init() { agentEngineCmd.PersistentFlags().StringVarP(&flags.source.entryPointPath, "entry_point_path", "e", "", "Path to an entry point (go 'main')") agentEngineCmd.PersistentFlags().StringVarP(&flags.source.sourceDir, "source_dir", "d", "", "Directory to archive, defaults to current working directory") agentEngineCmd.PersistentFlags().StringVar(&flags.agentEngine.agentEngineID, "agent_engine_id", "", "ID of the Agent Engine instance to update if it exists (default: \"\", which means a new instance will be created).") + agentEngineCmd.PersistentFlags().BoolVar(&flags.agentEngine.memoryBank.deploy, "mem_deploy", false, "If set to true then memory bank will be deployed too") + agentEngineCmd.PersistentFlags().StringVar(&flags.agentEngine.memoryBank.model, "mem_model", "publishers/google/models/gemini-2.5-flash", "Name of the model to be used for memory generation - for list you can GET"+ + " https://${LOCATION_ID}-aiplatform.googleapis.com/v1beta1/publishers/google/models. It will be prefixed with projects//locations/ forming for instance full model name like "+ + " 'projects/project_name/locations/us-central1/publishers/google/models/gemini-2.5-flash'") + agentEngineCmd.PersistentFlags().DurationVar(&flags.agentEngine.memoryBank.ttl, "mem_ttl", time.Hour*24*365, "Time-To-Live for memories") } // computeFlags uses command line arguments to create a full config @@ -144,6 +157,9 @@ func (f *deployAgentEngineFlags) computeFlags() error { f.agentEngine.displayName = "ADK Agent: " + dateTimeString } + // add provided model as suffix + f.agentEngine.memoryBank.model = fmt.Sprintf("projects/%s/locations/%s/%s", f.gcloud.projectName, f.gcloud.region, f.agentEngine.memoryBank.model) + return nil }) } @@ -272,6 +288,22 @@ func (f *deployAgentEngineFlags) gcloudDeployToAgentEngine() error { }, }, } + + if f.agentEngine.memoryBank.deploy { + req.ReasoningEngine.ContextSpec = &aiplatformpb.ReasoningEngineContextSpec{ + MemoryBankConfig: &aiplatformpb.ReasoningEngineContextSpec_MemoryBankConfig{ + GenerationConfig: &aiplatformpb.ReasoningEngineContextSpec_MemoryBankConfig_GenerationConfig{ + Model: f.agentEngine.memoryBank.model, + }, + TtlConfig: &aiplatformpb.ReasoningEngineContextSpec_MemoryBankConfig_TtlConfig{ + Ttl: &aiplatformpb.ReasoningEngineContextSpec_MemoryBankConfig_TtlConfig_DefaultTtl{ + DefaultTtl: durationpb.New(f.agentEngine.memoryBank.ttl), + }, + }, + }, + } + } + p("Sending CreateReasoningEngine request...") op, err := client.CreateReasoningEngine(ctx, req) if err != nil { @@ -344,6 +376,23 @@ func (f *deployAgentEngineFlags) gcloudUpdateAgentEngine() error { }, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"spec.source_code_spec", "spec.class_methods"}}, } + + if f.agentEngine.memoryBank.deploy { + req.ReasoningEngine.ContextSpec = &aiplatformpb.ReasoningEngineContextSpec{ + MemoryBankConfig: &aiplatformpb.ReasoningEngineContextSpec_MemoryBankConfig{ + GenerationConfig: &aiplatformpb.ReasoningEngineContextSpec_MemoryBankConfig_GenerationConfig{ + Model: f.agentEngine.memoryBank.model, + }, + TtlConfig: &aiplatformpb.ReasoningEngineContextSpec_MemoryBankConfig_TtlConfig{ + Ttl: &aiplatformpb.ReasoningEngineContextSpec_MemoryBankConfig_TtlConfig_DefaultTtl{ + DefaultTtl: durationpb.New(f.agentEngine.memoryBank.ttl), + }, + }, + }, + } + req.UpdateMask.Paths = append(req.UpdateMask.Paths, "spec.context_spec") + } + p("Sending UpdateReasoningEngine request...") op, err := client.UpdateReasoningEngine(ctx, req) if err != nil { @@ -363,8 +412,8 @@ func (f *deployAgentEngineFlags) gcloudUpdateAgentEngine() error { }) } -// deployOnagentEngine executes the sequence of actions preparing and deploying the agent to agentEngine -func (f *deployAgentEngineFlags) deployOnagentEngine() error { +// deployOnAgentEngine executes the sequence of actions preparing and deploying the agent to agentEngine +func (f *deployAgentEngineFlags) deployOnAgentEngine() error { fmt.Println(flags) err := f.computeFlags() diff --git a/cmd/launcher/console/console.go b/cmd/launcher/console/console.go index 5b7f69da1..f40b4e26e 100644 --- a/cmd/launcher/console/console.go +++ b/cmd/launcher/console/console.go @@ -107,6 +107,7 @@ func (l *consoleLauncher) Run(ctx context.Context, config *launcher.Config) erro SessionService: sessionService, ArtifactService: config.ArtifactService, PluginConfig: config.PluginConfig, + MemoryService: config.MemoryService, }) if err != nil { return fmt.Errorf("failed to create runner: %v", err) diff --git a/cmd/launcher/web/a2a/a2a.go b/cmd/launcher/web/a2a/a2a.go index 06d7b1fbd..0eab946f6 100644 --- a/cmd/launcher/web/a2a/a2a.go +++ b/cmd/launcher/web/a2a/a2a.go @@ -103,6 +103,7 @@ func (a *a2aLauncher) SetupSubrouters(router *mux.Router, config *launcher.Confi RunnerConfig: runner.Config{ AppName: agent.Name(), Agent: agent, + MemoryService: config.MemoryService, SessionService: config.SessionService, ArtifactService: config.ArtifactService, PluginConfig: config.PluginConfig, diff --git a/examples/agentengine/main.go b/examples/agentengine/main.go index 6c87c3b79..a9446b4f9 100644 --- a/examples/agentengine/main.go +++ b/examples/agentengine/main.go @@ -17,6 +17,7 @@ package main import ( "context" + "fmt" "log" "math/rand/v2" "os" @@ -27,12 +28,51 @@ import ( "google.golang.org/adk/agent/llmagent" "google.golang.org/adk/cmd/launcher" "google.golang.org/adk/cmd/launcher/agentengine" + vertexaiMem "google.golang.org/adk/memory/vertexai" "google.golang.org/adk/model/gemini" + "google.golang.org/adk/plugin" + "google.golang.org/adk/runner" "google.golang.org/adk/session/vertexai" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" + vertexaiutil "google.golang.org/adk/util/vertexai" ) +// Args defines the input structure for the memory search tool. +type Args struct { + Query string `json:"query" jsonschema:"The query to search for in the memory."` +} + +// Result defines the output structure for the memory search tool. +type Result struct { + Results []string `json:"results"` +} + +const ( + stateKeySessionLastUpdateTime = "sessionLastUpdateTime" +) + +// memorySearchToolFunc is the implementation of the memory search tool. +// This function demonstrates accessing memory via tool.Context. +func memorySearchToolFunc(tctx tool.Context, args Args) (Result, error) { + // The SearchMemory function is available on the context. + searchResults, err := tctx.SearchMemory(tctx, args.Query) + if err != nil { + log.Printf("Error searching memory: %v", err) + return Result{}, fmt.Errorf("failed memory search: %w", err) + } + + var results []string + for _, res := range searchResults.Memories { + if res.Content != nil { + for _, part := range res.Content.Parts { + results = append(results, part.Text) + } + } + } + return Result{Results: results}, nil +} + func main() { ctx := context.Background() @@ -71,13 +111,26 @@ func main() { log.Fatalf("Failed to create tool: %v", err) } + // Define a tool that can search memory. + memorySearchTool, err := functiontool.New( + functiontool.Config{ + Name: "search_past_conversations", + Description: "Searches past conversations for relevant information.", + }, + memorySearchToolFunc, + ) + if err != nil { + log.Fatalf("Failed to create tool: %v", err) + } + a, err := llmagent.New(llmagent.Config{ Name: "ae_agent", Model: model, Description: "General helpful agent", - Instruction: "You are a helpful agent, you should answer any questions you are given. Use 'random' tool to provide random numbers.", + Instruction: "You are a helpful agent, you should answer any questions you are given. Use 'random' tool to provide random numbers. Use search_past_conversations tool to get the facts about the user", Tools: []tool.Tool{ randomTool, + memorySearchTool, }, }) if err != nil { @@ -94,9 +147,55 @@ func main() { log.Fatalf("Failed to create session service: %v", err) } + memService, err := vertexaiMem.NewService(ctx, + &vertexaiMem.ServiceConfig{ + AgentEngineData: vertexaiutil.AgentEngineData{ + ProjectID: projectID, + Location: location, + ReasoningEngine: agentEngineID, + }, + StateKeySessionLastUpdateTime: stateKeySessionLastUpdateTime, + }) + if err != nil { + log.Fatalf("Failed to create memory service: %v", err) + } + + memPlugin, err := plugin.New(plugin.Config{ + Name: "Memory generator", + BeforeRunCallback: func(ic agent.InvocationContext) (*genai.Content, error) { + state := ic.Session().State() + err := state.Set(stateKeySessionLastUpdateTime, ic.Session().LastUpdateTime()) + if err != nil { + log.Printf("state.Set failed: %v\n", err) + return nil, err + } + return nil, nil + }, + AfterRunCallback: func(ic agent.InvocationContext) { + m := ic.Memory() + if m == nil { + log.Printf("ic.Memory() is nil\n") + return + } + err := m.AddSessionToMemory(ic, ic.Session()) + if err != nil { + log.Printf("ic.Memory().AddSessionToMemory failed: %v\n", err) + } + }, + }) + if err != nil { + log.Fatalf("Failed to create plugin: %v", err) + } + config := &launcher.Config{ SessionService: sessionService, AgentLoader: agent.NewSingleLoader(a), + MemoryService: memService, + PluginConfig: runner.PluginConfig{ + Plugins: []*plugin.Plugin{ + memPlugin, + }, + }, } l := agentengine.NewLauncher(agentEngineID) diff --git a/memory/vertexai/vertexai.go b/memory/vertexai/vertexai.go new file mode 100644 index 000000000..32bb74f5d --- /dev/null +++ b/memory/vertexai/vertexai.go @@ -0,0 +1,95 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// package vertexai provides support for using MemoryBank provided by VertexAI +package vertexai + +import ( + "context" + "fmt" + "time" + + "google.golang.org/adk/memory" + "google.golang.org/adk/session" + vertexaiutil "google.golang.org/adk/util/vertexai" +) + +type vertexAIService struct { + client *vertexAIClient + stateKeySessionLastUpdateTime string +} + +// ServiceConfig allows you to specify the instance of MemoryBank (by specifying an AgentEngine instance). +// Specifies also a way to convert session events to memories (see StateKeySessionLastUpdateTime) +type ServiceConfig struct { + vertexaiutil.AgentEngineData + // StateKeySessionLastUpdateTime controlls the process of the generation of memories. + // If set to "", the whole session is used to generate the memories. + // If provided, the value is treated as a key for Session State. Retrieved value (time.Time is expected) is used to filter the events to the most recent ones. + // Warning! The value for State under this key should be set as soon as possible (for instance, during the BeforeRunCallback) + StateKeySessionLastUpdateTime string + WaitForCompletion bool +} + +// NewService creates a new instance of Memory Service supported by VertexAI MemoryBank. +func NewService(ctx context.Context, config *ServiceConfig) (memory.Service, error) { + client, err := newVertexAIClient(ctx, &vertexAIClientConfig{ + AgentEngineData: config.AgentEngineData, + waitForCompletion: config.WaitForCompletion, + }) + if err != nil { + return nil, fmt.Errorf("newVertexAIClient failed: %w", err) + } + return &vertexAIService{ + client: client, + stateKeySessionLastUpdateTime: config.StateKeySessionLastUpdateTime, + }, nil +} + +var _ memory.Service = &vertexAIService{} + +// AddSessionToMemory implements [memory.Service]. +func (v *vertexAIService) AddSessionToMemory(ctx context.Context, s session.Session) error { + var err error + if v.stateKeySessionLastUpdateTime == "" { + // add the whole session + err = v.client.addWholeSession(ctx, s) + if err != nil { + return fmt.Errorf("v.client.addWholeSession failed: %w", err) + } + return nil + } + + // add only events newer than given + t, err := s.State().Get(v.stateKeySessionLastUpdateTime) + if err != nil { + return fmt.Errorf("state.Get(%s) failed: %w", v.stateKeySessionLastUpdateTime, err) + } + + tm, ok := t.(time.Time) + if !ok { + return fmt.Errorf("want type time.Time, got : %T (%v)", t, t) + } + err = v.client.addEventsNewerThan(ctx, s, tm) + if err != nil { + return fmt.Errorf("v.client.addEventsNewerThan failed: %w", err) + } + + return err +} + +// SearchMemory implements [memory.Service]. +func (v *vertexAIService) SearchMemory(ctx context.Context, req *memory.SearchRequest) (*memory.SearchResponse, error) { + return v.client.searchMemory(ctx, req) +} diff --git a/memory/vertexai/vertexai_client.go b/memory/vertexai/vertexai_client.go new file mode 100644 index 000000000..4926337fb --- /dev/null +++ b/memory/vertexai/vertexai_client.go @@ -0,0 +1,139 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vertexai + +import ( + "context" + "fmt" + "time" + + "google.golang.org/api/option" + "google.golang.org/genai" + "google.golang.org/protobuf/types/known/timestamppb" + + "google.golang.org/adk/memory" + "google.golang.org/adk/session" + aiplatformutil "google.golang.org/adk/util/aiplatform" + vertexaiutil "google.golang.org/adk/util/vertexai" + + aiplatform "cloud.google.com/go/aiplatform/apiv1beta1" + "cloud.google.com/go/aiplatform/apiv1beta1/aiplatformpb" +) + +type vertexAIClient struct { + config vertexAIClientConfig + client *aiplatform.MemoryBankClient + agentEngineData *vertexaiutil.AgentEngineData + parent string +} + +type vertexAIClientConfig struct { + vertexaiutil.AgentEngineData + waitForCompletion bool +} + +func newVertexAIClient(ctx context.Context, config *vertexAIClientConfig) (*vertexAIClient, error) { + if config == nil { + return nil, fmt.Errorf("config cannot be nil") + } + c, err := aiplatform.NewMemoryBankClient(ctx, option.WithEndpoint(aiplatformutil.HostPortURL(config.Location))) + if err != nil { + return nil, fmt.Errorf("aiplatform.NewMemoryBankClient failed: %w", err) + } + return &vertexAIClient{ + config: *config, + client: c, + agentEngineData: &config.AgentEngineData, + parent: vertexaiutil.AgentEngineResource(&config.AgentEngineData), + }, nil +} + +// addWholeSession adds the whole session to the Memory +func (v *vertexAIClient) addWholeSession(ctx context.Context, s session.Session) error { + return v.addSession(ctx, s, nil) +} + +// addEventsNewerThan uses time to filter out the old event. The new ones are used to generate memories +func (v *vertexAIClient) addEventsNewerThan(ctx context.Context, s session.Session, start time.Time) error { + return v.addSession(ctx, s, &start) +} + +// addSession adds the whole session or just events created after `start` to the Memory +func (v *vertexAIClient) addSession(ctx context.Context, s session.Session, start *time.Time) error { + sr := vertexaiutil.SessionResource(v.agentEngineData, s.ID()) + + vss := &aiplatformpb.GenerateMemoriesRequest_VertexSessionSource{ + Session: sr, + } + if start != nil { + vss.StartTime = timestamppb.New(*start) + } + req := &aiplatformpb.GenerateMemoriesRequest{ + Parent: v.parent, + Source: &aiplatformpb.GenerateMemoriesRequest_VertexSessionSource_{VertexSessionSource: vss}, + Scope: createUserScope(s.UserID()), + } + + op, err := v.client.GenerateMemories(ctx, req) + if err != nil { + return fmt.Errorf("v.client.GenerateMemories failed: %w", err) + } + if v.config.waitForCompletion { + _, err = op.Wait(ctx) + if err != nil && err.Error() == "unsupported result type : " { + // accept it + err = nil + } + if err != nil { + return fmt.Errorf("op.Wait for GenerateMemories (whole session) failed: %w", err) + } + } + return nil +} + +// searchMemory uses provided query to find the relevant memories for the given user +func (v *vertexAIClient) searchMemory(ctx context.Context, req *memory.SearchRequest) (*memory.SearchResponse, error) { + r, err := v.client.RetrieveMemories(ctx, + &aiplatformpb.RetrieveMemoriesRequest{ + RetrievalParams: &aiplatformpb.RetrieveMemoriesRequest_SimilaritySearchParams_{ + SimilaritySearchParams: &aiplatformpb.RetrieveMemoriesRequest_SimilaritySearchParams{ + SearchQuery: req.Query, + }, + }, + Parent: v.parent, + Scope: createUserScope(req.UserID), + }, + ) + if err != nil { + return nil, fmt.Errorf("v.client.RetrieveMemories failed: %w", err) + } + + res := &memory.SearchResponse{ + Memories: []memory.Entry{}, + } + + for _, m := range r.RetrievedMemories { + res.Memories = append(res.Memories, memory.Entry{ + Content: genai.NewContentFromText(m.Memory.Fact, genai.RoleUser), + }) + } + + return res, nil +} + +// Scope is used to structure the information in MemoryBank. Here we use only the user scope +func createUserScope(userID string) map[string]string { + return map[string]string{"user_id": userID} +} diff --git a/server/agentengine/controllers/method/stream_query.go b/server/agentengine/controllers/method/stream_query.go index f192c05f9..97f75618c 100644 --- a/server/agentengine/controllers/method/stream_query.go +++ b/server/agentengine/controllers/method/stream_query.go @@ -195,6 +195,7 @@ func (s *streamQueryHandler) run(ctx context.Context, req *models.StreamQueryReq AppName: s.agentEngineID, Agent: rootAgent, SessionService: config.SessionService, + MemoryService: config.MemoryService, ArtifactService: config.ArtifactService, PluginConfig: config.PluginConfig, AutoCreateSession: true, diff --git a/session/vertexai/vertexai_client.go b/session/vertexai/vertexai_client.go index 04f1b5000..387a80ce3 100644 --- a/session/vertexai/vertexai_client.go +++ b/session/vertexai/vertexai_client.go @@ -32,20 +32,14 @@ import ( "google.golang.org/adk/model" "google.golang.org/adk/session" + vertexaiutil "google.golang.org/adk/util/vertexai" aiplatform "cloud.google.com/go/aiplatform/apiv1beta1" aiplatformpb "cloud.google.com/go/aiplatform/apiv1beta1/aiplatformpb" ) -const ( - engineResourceTemplate = "projects/%s/locations/%s/reasoningEngines/%s" - sessionResourceTemplate = engineResourceTemplate + "/sessions/%s" -) - type vertexAiClient struct { - location string - projectID string - reasoningEngine string + agentEngineData *vertexaiutil.AgentEngineData rpcClient *aiplatform.SessionClient } @@ -54,7 +48,14 @@ func newVertexAiClient(ctx context.Context, location, projectID, reasoningEngine if err != nil { return nil, fmt.Errorf("could not establish connection to the aiplatform server: %w", err) } - return &vertexAiClient{location, projectID, reasoningEngine, rpcClient}, nil + return &vertexAiClient{ + agentEngineData: &vertexaiutil.AgentEngineData{ + Location: location, + ProjectID: projectID, + ReasoningEngine: reasoningEngine, + }, + rpcClient: rpcClient, + }, nil } // Ensure you close it when your application shuts down @@ -79,8 +80,13 @@ func (c *vertexAiClient) createSession(ctx context.Context, req *session.CreateR if err != nil { return nil, err } + aeData := &vertexaiutil.AgentEngineData{ + Location: c.agentEngineData.Location, + ProjectID: c.agentEngineData.ProjectID, + ReasoningEngine: reasoningEngine, + } rpcReq := &aiplatformpb.CreateSessionRequest{ - Parent: fmt.Sprintf(engineResourceTemplate, c.projectID, c.location, reasoningEngine), + Parent: vertexaiutil.AgentEngineResource(aeData), Session: pbSession, } lro, err := c.rpcClient.CreateSession(ctx, rpcReq) @@ -168,8 +174,15 @@ func (c *vertexAiClient) listSessions(ctx context.Context, req *session.ListRequ if err != nil { return nil, err } + + aeData := vertexaiutil.AgentEngineData{ + Location: c.agentEngineData.Location, + ProjectID: c.agentEngineData.ProjectID, + ReasoningEngine: reasoningEngine, + } + rpcReq := &aiplatformpb.ListSessionsRequest{ - Parent: fmt.Sprintf(engineResourceTemplate, c.projectID, c.location, reasoningEngine), + Parent: vertexaiutil.AgentEngineResource(&aeData), } if req.UserID != "" { rpcReq.Filter = fmt.Sprintf("userId=\"%s\"", req.UserID) @@ -392,7 +405,12 @@ func sessionIDByOperationName(on string) (string, error) { } func sessionNameByID(id string, c *vertexAiClient, reasoningEngine string) string { - return fmt.Sprintf(sessionResourceTemplate, c.projectID, c.location, reasoningEngine, id) + aeData := &vertexaiutil.AgentEngineData{ + Location: c.agentEngineData.Location, + ProjectID: c.agentEngineData.ProjectID, + ReasoningEngine: reasoningEngine, + } + return vertexaiutil.SessionResource(aeData, id) } // (?:...) tells Go "match this, but don't save it in the results array". @@ -400,8 +418,8 @@ func sessionNameByID(id string, c *vertexAiClient, reasoningEngine string) strin var reasoningEnginePattern = regexp.MustCompile(`^projects/(?:[a-zA-Z0-9-_]+)/locations/(?:[a-zA-Z0-9-_]+)/reasoningEngines/(\d+)$`) func (c *vertexAiClient) getReasoningEngineID(appName string) (string, error) { - if c.reasoningEngine != "" { - return c.reasoningEngine, nil + if c.agentEngineData.ReasoningEngine != "" { + return c.agentEngineData.ReasoningEngine, nil } // Check if appName consists only of digits diff --git a/session/vertexai/vertexai_test.go b/session/vertexai/vertexai_test.go index 2e4b9251a..ad6046461 100644 --- a/session/vertexai/vertexai_test.go +++ b/session/vertexai/vertexai_test.go @@ -16,6 +16,8 @@ package vertexai import ( "testing" + + "google.golang.org/adk/util/vertexai" ) func TestGetReasoningEngineID(t *testing.T) { @@ -81,7 +83,9 @@ func TestGetReasoningEngineID(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Setup the client with the test case state c := &vertexAiClient{ - reasoningEngine: tt.existingEngineID, + agentEngineData: &vertexai.AgentEngineData{ + ReasoningEngine: tt.existingEngineID, + }, } // Execute diff --git a/util/aiplatform/aiplatform.go b/util/aiplatform/aiplatform.go new file mode 100644 index 000000000..dfb753c9a --- /dev/null +++ b/util/aiplatform/aiplatform.go @@ -0,0 +1,31 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package aiplatform provides utils for aiplatform.googleapis.com +package aiplatform + +// HostURL returns aiplatform host url for a given region +// Example: "us-central1-aiplatform.googleapis.com" +func HostURL(region string) string { + if region == "" || region == "global" { + return "aiplatform.googleapis.com" + } + return region + "-aiplatform.googleapis.com" +} + +// HostPortUrl returns aiplatform host url for a given region with port number +// Example: "us-central1-aiplatform.googleapis.com:443" +func HostPortURL(region string) string { + return HostURL(region) + ":443" +} diff --git a/util/vertexai/agent_engine.go b/util/vertexai/agent_engine.go new file mode 100644 index 000000000..69bf10f7e --- /dev/null +++ b/util/vertexai/agent_engine.go @@ -0,0 +1,41 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package vertexai provides utilities for Agent Engine deployments +package vertexai + +import "fmt" + +const ( + agentEngineTemplate = "projects/%s/locations/%s/reasoningEngines/%s" + agentEngineSessionTemplate = agentEngineTemplate + "/sessions/%s" +) + +type AgentEngineData struct { + Location string + ProjectID string + ReasoningEngine string +} + +// AgentEngineResource returns a formatted string indicating agent engine instance +// (template `projects/%s/locations/%s/reasoningEngines/%s`) +func AgentEngineResource(data *AgentEngineData) string { + return fmt.Sprintf(agentEngineTemplate, data.ProjectID, data.Location, data.ReasoningEngine) +} + +// SessionResource returns a formatted string indicating specific session for an agent engine instance +// (template `projects/%s/locations/%s/reasoningEngines/%s/sessions/%s`) +func SessionResource(data *AgentEngineData, sessionID string) string { + return fmt.Sprintf(agentEngineSessionTemplate, data.ProjectID, data.Location, data.ReasoningEngine, sessionID) +} From 120787de516e4efed5d2dff36fa511cf268f8ab4 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Mon, 11 May 2026 13:37:56 +0200 Subject: [PATCH 35/86] fix the old adka2a public api depending on the new a2a-go/v2 (#813) --- server/adka2a/executor.go | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/server/adka2a/executor.go b/server/adka2a/executor.go index f87ce19be..f959c4e0f 100644 --- a/server/adka2a/executor.go +++ b/server/adka2a/executor.go @@ -33,6 +33,7 @@ import ( "google.golang.org/genai" "google.golang.org/adk/agent" + "google.golang.org/adk/plugin" "google.golang.org/adk/runner" v2 "google.golang.org/adk/server/adka2a/v2" "google.golang.org/adk/session" @@ -69,7 +70,7 @@ type Runner = v2.Runner // RunnerProvider is a [Runner] factory function. The provided plugin must be installed in the returned [Runner] for // callbacks taking [ExecutorContext] to work correctly. -type RunnerProvider = v2.RunnerProvider +type RunnerProvider func(ctx context.Context, reqCtx *a2asrv.RequestContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) const ( // OutputArtifactPerRun produces a single artifact per [runner.Runner.Run]. @@ -83,14 +84,7 @@ const ( // RunnerConfig is part of the runner configuration executor code depends on. // Custom [RunnerProvider] needs to return it back to callers. -type RunnerConfig struct { - // AppName is the name of the application used in [session.Service] keys and A2A event metadata. - AppName string - // Agent is the root agent. - Agent agent.Agent - // SessionService is the session service to use. - SessionService session.Service -} +type RunnerConfig = v2.RunnerConfig // ExecutorConfig allows to configure Executor. type ExecutorConfig struct { @@ -146,10 +140,15 @@ type Executor struct { // NewExecutor creates an initialized [Executor] instance. func NewExecutor(config ExecutorConfig) *Executor { v1Config := v2.ExecutorConfig{ - RunnerConfig: config.RunnerConfig, - RunnerProvider: config.RunnerProvider, - RunConfig: config.RunConfig, - OutputMode: v2.OutputMode(config.OutputMode), + RunnerConfig: config.RunnerConfig, + RunConfig: config.RunConfig, + OutputMode: v2.OutputMode(config.OutputMode), + } + + if config.RunnerProvider != nil { + v1Config.RunnerProvider = func(ctx context.Context, execCtx *a2asrvv2.ExecutorContext, plugin *plugin.Plugin) (RunnerConfig, Runner, error) { + return config.RunnerProvider(ctx, toRequestContext(execCtx), plugin) + } } if config.BeforeExecuteCallback != nil { From ae7aed42099efabf8f9757ce505e891b11c97d3b Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Tue, 12 May 2026 12:15:15 +0200 Subject: [PATCH 36/86] ADK GO version update for LLM Request tagging (#816) --- internal/version/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/version/version.go b/internal/version/version.go index a4b15e266..bee420370 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -15,4 +15,4 @@ package version // Version exposes the current ADK Go version, used for llm request tagging -const Version = "0.6.0" +const Version = "1.2.0" From 853c11b6dbbbde0c7d99f06ff6c17ab0b94dbf3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Wed, 13 May 2026 07:07:00 +0100 Subject: [PATCH 37/86] Add parallel HITL function test (#817) * Added parallel hitl function test * fix: use tagged switch on origCall.ID --- .../parallel_function_call_hitl_test.go | 464 ++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 internal/llminternal/parallel_function_call_hitl_test.go diff --git a/internal/llminternal/parallel_function_call_hitl_test.go b/internal/llminternal/parallel_function_call_hitl_test.go new file mode 100644 index 000000000..8da57d786 --- /dev/null +++ b/internal/llminternal/parallel_function_call_hitl_test.go @@ -0,0 +1,464 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package llminternal_test + +import ( + "context" + "encoding/json" + "iter" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/model" + "google.golang.org/adk/runner" + "google.golang.org/adk/session" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" + "google.golang.org/adk/tool/toolconfirmation" +) + +type SecureActionArgs struct { + ActionName string `json:"action_name"` +} + +type SecureActionResult struct { + Executed bool `json:"executed"` +} + +func secureActionFunc(ctx tool.Context, input SecureActionArgs) (SecureActionResult, error) { + return SecureActionResult{Executed: true}, nil +} + +type hitlMockModel struct { + model.LLM + Calls int +} + +func (m *hitlMockModel) Name() string { + return "hitl-mock-model" +} + +func (m *hitlMockModel) GenerateContent(ctx context.Context, req *model.LLMRequest, useStream bool) iter.Seq2[*model.LLMResponse, error] { + return func(yield func(*model.LLMResponse, error) bool) { + m.Calls++ + if m.Calls > 1 { + // Turn 2 model execution (after user confirms) + yield(&model.LLMResponse{ + Content: &genai.Content{ + Parts: []*genai.Part{ + genai.NewPartFromText("Successfully processed your request after confirmation."), + }, + Role: "model", + }, + Partial: false, + }, nil) + return + } + + // Turn 1: yields 2 parallel function calls requiring confirmation + yield(&model.LLMResponse{ + Content: &genai.Content{ + Parts: []*genai.Part{ + { + FunctionCall: &genai.FunctionCall{ + ID: "secure_call_1", + Name: "secure_action", + Args: map[string]any{"action_name": "deploy_prod"}, + }, + }, + { + FunctionCall: &genai.FunctionCall{ + ID: "secure_call_2", + Name: "secure_action", + Args: map[string]any{"action_name": "delete_db"}, + }, + }, + }, + Role: "model", + }, + Partial: false, + }, nil) + } +} + +// TestParallelFunctionCallsWithHITL verifies the end-to-end coordination of multiple parallel +// tool executions that require Human-in-the-Loop (HITL) confirmation. +// +// The test simulates a two-turn interaction: +// 1. Turn 1: The model initiates two parallel tool calls to a sensitive tool. Because no user +// confirmation is yet present, both tools trigger RequestConfirmation. This sets the +// SkipSummarization flag, which halts LLM generation immediately after tool responses are returned. +// The runner yields: +// a) A model response event containing the two original parallel function calls. +// b) An aggregated tool confirmation event containing two adk_request_confirmation wrapper calls. +// c) A merged function response event containing the placeholder (unexecuted) tool responses. +// 2. Turn 2: The client simulates user confirmation by returning confirmation responses matching +// the unique wrapper call IDs. The RequestConfirmationRequestProcessor detects these, matches +// them back to the original tools, and concurrently executes the sensitive tools in parallel +// with the confirmed flag enabled. Finally, the model is called to generate a summary. +func TestParallelFunctionCallsWithHITL(t *testing.T) { + secureTool, err := functiontool.New(functiontool.Config{ + Name: "secure_action", + Description: "performs a sensitive/secure action", + RequireConfirmation: true, + }, secureActionFunc) + if err != nil { + t.Fatal(err) + } + + mockModel := &hitlMockModel{} + + a, err := llmagent.New(llmagent.Config{ + Name: "hitl_tester", + Description: "HITL tester agent", + Instruction: "You are a secure helper.", + Model: mockModel, + Tools: []tool.Tool{ + secureTool, + }, + }) + if err != nil { + t.Fatal(err) + } + + sessionService := session.InMemoryService() + _, err = sessionService.Create(t.Context(), &session.CreateRequest{ + AppName: "testApp", + UserID: "testUser", + SessionID: "testSession", + }) + if err != nil { + t.Fatal(err) + } + + r, err := runner.New(runner.Config{ + Agent: a, + SessionService: sessionService, + AppName: "testApp", + }) + if err != nil { + t.Fatal(err) + } + + // --- TURN 1: Requesting parallel tools requiring confirmation --- + it := r.Run(t.Context(), "testUser", "testSession", &genai.Content{ + Parts: []*genai.Part{ + genai.NewPartFromText("Please run prod deploy and db delete"), + }, + Role: "user", + }, agent.RunConfig{StreamingMode: agent.StreamingModeSSE}) + + var turn1Events []*session.Event + for ev, err := range it { + if err != nil { + t.Fatal(err) + } + turn1Events = append(turn1Events, ev) + } + + // Expecting exactly 3 events: + // 1. ModelResponseEvent (yielding the two function calls secure_call_1 and secure_call_2) + // 2. ToolConfirmationEvent (yielding two adk_request_confirmation calls) + // 3. Merged function response event (returning placeholder executed: false responses) + if len(turn1Events) != 3 { + t.Fatalf("Expected 3 events in Turn 1, got %d", len(turn1Events)) + } + + // Verify that the model event contains our parallel calls + modelEvent := turn1Events[0] + if len(modelEvent.Content.Parts) != 2 { + t.Errorf("Expected model event to contain 2 parts (function calls), got %d", len(modelEvent.Content.Parts)) + } + + // Verify that the tool confirmation event has the confirmation wrapper calls + confirmEvent := turn1Events[1] + if len(confirmEvent.Content.Parts) != 2 { + t.Errorf("Expected tool confirmation event to contain 2 wrapper function calls, got %d", len(confirmEvent.Content.Parts)) + } + + var confirmCallID1, confirmCallID2 string + for _, p := range confirmEvent.Content.Parts { + if p.FunctionCall == nil || p.FunctionCall.Name != toolconfirmation.FunctionCallName { + t.Errorf("Expected function call name %s, got %v", toolconfirmation.FunctionCallName, p.FunctionCall) + continue + } + origCall, err := toolconfirmation.OriginalCallFrom(p.FunctionCall) + if err != nil { + t.Fatalf("Failed to extract original call: %v", err) + } + switch origCall.ID { + case "secure_call_1": + confirmCallID1 = p.FunctionCall.ID + case "secure_call_2": + confirmCallID2 = p.FunctionCall.ID + } + } + + if confirmCallID1 == "" || confirmCallID2 == "" { + t.Fatalf("Failed to retrieve both confirmation function call IDs from turn 1 events") + } + + // Verify that the merged function response event contains the placeholder unexecuted responses + placeholderRespEvent := turn1Events[2] + if len(placeholderRespEvent.Content.Parts) != 2 { + t.Errorf("Expected placeholder function response event to contain 2 parts, got %d", len(placeholderRespEvent.Content.Parts)) + } + + expectedPlaceholders := []*genai.Part{ + { + FunctionResponse: &genai.FunctionResponse{ + Name: "secure_action", + ID: "secure_call_1", + Response: map[string]any{"error": `error tool "secure_action" requires confirmation, please approve or reject`}, + }, + }, + { + FunctionResponse: &genai.FunctionResponse{ + Name: "secure_action", + ID: "secure_call_2", + Response: map[string]any{"error": `error tool "secure_action" requires confirmation, please approve or reject`}, + }, + }, + } + + if diff := cmp.Diff(expectedPlaceholders, placeholderRespEvent.Content.Parts); diff != "" { + t.Errorf("Mismatch in placeholder tool responses (-want +got):\n%s", diff) + } + + // --- TURN 2: User confirms the actions --- + // Build user's response to confirmation requests. + userConfirmation := toolconfirmation.ToolConfirmation{Confirmed: true} + userConfirmationJSON, _ := json.Marshal(userConfirmation) + userConfirmationResponse := map[string]any{ + "response": string(userConfirmationJSON), + } + + // Run runner again, passing the user confirmation response content directly as the message + it2 := r.Run(t.Context(), "testUser", "testSession", &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + { + FunctionResponse: &genai.FunctionResponse{ + Name: toolconfirmation.FunctionCallName, + ID: confirmCallID1, + Response: userConfirmationResponse, + }, + }, + { + FunctionResponse: &genai.FunctionResponse{ + Name: toolconfirmation.FunctionCallName, + ID: confirmCallID2, + Response: userConfirmationResponse, + }, + }, + }, + }, agent.RunConfig{StreamingMode: agent.StreamingModeSSE}) + + var turn2Events []*session.Event + for ev, err := range it2 { + if err != nil { + t.Fatal(err) + } + turn2Events = append(turn2Events, ev) + } + + // Turn 2 Events Expectation: + // 1. Function response event containing executed: true for both calls + // 2. Final summarizing model response ("Successfully processed your request after confirmation.") + if len(turn2Events) != 2 { + t.Fatalf("Expected 2 events in Turn 2, got %d", len(turn2Events)) + } + + funcRespEvent := turn2Events[0] + if len(funcRespEvent.Content.Parts) != 2 { + t.Errorf("Expected 2 function responses in Turn 2, got %d", len(funcRespEvent.Content.Parts)) + } + + ignoreFields := []cmp.Option{ + cmpopts.IgnoreFields(genai.FunctionResponse{}, "ID"), + } + + expectedResponses := []*genai.Part{ + { + FunctionResponse: &genai.FunctionResponse{ + Name: "secure_action", + Response: map[string]any{"executed": true}, + }, + }, + { + FunctionResponse: &genai.FunctionResponse{ + Name: "secure_action", + Response: map[string]any{"executed": true}, + }, + }, + } + + if diff := cmp.Diff(expectedResponses, funcRespEvent.Content.Parts, ignoreFields...); diff != "" { + t.Errorf("Mismatch in executed tool responses (-want +got):\n%s", diff) + } + + finalModelEvent := turn2Events[1] + if finalModelEvent.Content.Parts[0].Text != "Successfully processed your request after confirmation." { + t.Errorf("Expected summarizing model event, got: %v", finalModelEvent.Content.Parts[0]) + } +} + +// TestParallelFunctionCallsWithPartialHITL verifies the behavior when multiple parallel +// tool executions are requested, but only one of them is confirmed by the user while the +// other is rejected/denied. +func TestParallelFunctionCallsWithPartialHITL(t *testing.T) { + secureTool, err := functiontool.New(functiontool.Config{ + Name: "secure_action", + Description: "performs a sensitive/secure action", + RequireConfirmation: true, + }, secureActionFunc) + if err != nil { + t.Fatal(err) + } + + mockModel := &hitlMockModel{} + + a, err := llmagent.New(llmagent.Config{ + Name: "hitl_tester", + Description: "HITL tester agent", + Instruction: "You are a secure helper.", + Model: mockModel, + Tools: []tool.Tool{ + secureTool, + }, + }) + if err != nil { + t.Fatal(err) + } + + sessionService := session.InMemoryService() + _, err = sessionService.Create(t.Context(), &session.CreateRequest{ + AppName: "testApp", + UserID: "testUser", + SessionID: "testSession", + }) + if err != nil { + t.Fatal(err) + } + + r, err := runner.New(runner.Config{ + Agent: a, + SessionService: sessionService, + AppName: "testApp", + }) + if err != nil { + t.Fatal(err) + } + + // --- TURN 1: Requesting parallel tools requiring confirmation --- + it := r.Run(t.Context(), "testUser", "testSession", &genai.Content{ + Parts: []*genai.Part{ + genai.NewPartFromText("Please run prod deploy and db delete"), + }, + Role: "user", + }, agent.RunConfig{StreamingMode: agent.StreamingModeSSE}) + + var turn1Events []*session.Event + for ev, err := range it { + if err != nil { + t.Fatal(err) + } + turn1Events = append(turn1Events, ev) + } + + if len(turn1Events) != 3 { + t.Fatalf("Expected 3 events in Turn 1, got %d", len(turn1Events)) + } + + confirmEvent := turn1Events[1] + var confirmCallID1, confirmCallID2 string + for _, p := range confirmEvent.Content.Parts { + origCall, err := toolconfirmation.OriginalCallFrom(p.FunctionCall) + if err != nil { + t.Fatalf("Failed to extract original call: %v", err) + } + switch origCall.ID { + case "secure_call_1": + confirmCallID1 = p.FunctionCall.ID + case "secure_call_2": + confirmCallID2 = p.FunctionCall.ID + } + } + + if confirmCallID1 == "" || confirmCallID2 == "" { + t.Fatalf("Failed to retrieve both confirmation function call IDs from turn 1 events") + } + + // --- TURN 2: User partially confirms the actions --- + // Only confirm secure_call_2 (secure_call_1 remains pending/unconfirmed in this turn). + confirmedJSON, _ := json.Marshal(toolconfirmation.ToolConfirmation{Confirmed: true}) + + it2 := r.Run(t.Context(), "testUser", "testSession", &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + { + FunctionResponse: &genai.FunctionResponse{ + Name: toolconfirmation.FunctionCallName, + ID: confirmCallID2, + Response: map[string]any{"response": string(confirmedJSON)}, + }, + }, + }, + }, agent.RunConfig{StreamingMode: agent.StreamingModeSSE}) + + var turn2Events []*session.Event + for ev, err := range it2 { + if err != nil { + t.Fatal(err) + } + turn2Events = append(turn2Events, ev) + } + + if len(turn2Events) != 2 { + t.Fatalf("Expected 2 events in Turn 2, got %d", len(turn2Events)) + } + + funcRespEvent := turn2Events[0] + if len(funcRespEvent.Content.Parts) != 1 { + t.Errorf("Expected 1 function response in Turn 2, got %d", len(funcRespEvent.Content.Parts)) + } + + ignoreFields := []cmp.Option{ + cmpopts.IgnoreFields(genai.FunctionResponse{}, "ID"), + } + + expectedResponses := []*genai.Part{ + { + FunctionResponse: &genai.FunctionResponse{ + Name: "secure_action", + Response: map[string]any{"executed": true}, + }, + }, + } + + if diff := cmp.Diff(expectedResponses, funcRespEvent.Content.Parts, ignoreFields...); diff != "" { + t.Errorf("Mismatch in executed tool responses (-want +got):\n%s", diff) + } + + finalModelEvent := turn2Events[1] + if finalModelEvent.Content.Parts[0].Text != "Successfully processed your request after confirmation." { + t.Errorf("Expected summarizing model event, got: %v", finalModelEvent.Content.Parts[0]) + } +} From 992c1c2381f55538ef9b3ccb7f31689a6d79e277 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Wed, 13 May 2026 15:13:50 +0200 Subject: [PATCH 38/86] bump a2a-go version to have nil part fix (#827) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index dc1e73095..7af19d7d5 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( require github.com/hashicorp/golang-lru/v2 v2.0.7 require ( - github.com/a2aproject/a2a-go/v2 v2.2.1 + github.com/a2aproject/a2a-go/v2 v2.3.1 golang.org/x/mod v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index a46f5354c..1785402c9 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI= github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= -github.com/a2aproject/a2a-go/v2 v2.2.1 h1:NAfUoceWAStJ7FnF8TTfWuHejf9mzKgc9QmaKV1hyXw= -github.com/a2aproject/a2a-go/v2 v2.2.1/go.mod h1:mkZr8y2bUgAVQsjs/5fHK7xrRlAHDybMEyxWh2tKRC8= +github.com/a2aproject/a2a-go/v2 v2.3.1 h1:QWMdOX2UsJ8BJmjs952eo1FRyGsOVl0gFCKeM76AgGE= +github.com/a2aproject/a2a-go/v2 v2.3.1/go.mod h1:mkZr8y2bUgAVQsjs/5fHK7xrRlAHDybMEyxWh2tKRC8= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= From a242cb68199abb9ef48161f13eaa71db71d66d90 Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Fri, 15 May 2026 10:15:01 +0200 Subject: [PATCH 39/86] Changed model for gemini API to gemini-3.1-flash-lite (in examples) (#839) --- examples/a2a/main.go | 2 +- examples/mcp/main.go | 2 +- examples/quickstart/main.go | 2 +- examples/rest/main.go | 2 +- examples/skills/main.go | 2 +- examples/telemetry/main.go | 2 +- examples/toolconfirmation/main.go | 2 +- examples/tools/loadartifacts/main.go | 2 +- examples/tools/loadmemory/main.go | 2 +- examples/tools/multipletools/main.go | 2 +- examples/web/main.go | 2 +- examples/workflowagents/sequentialCode/main.go | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/a2a/main.go b/examples/a2a/main.go index 824f3311a..239272942 100644 --- a/examples/a2a/main.go +++ b/examples/a2a/main.go @@ -42,7 +42,7 @@ import ( // newWeatherAgent creates a simple LLM-agent as in the quickstart example. func newWeatherAgent(ctx context.Context) agent.Agent { - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) if err != nil { diff --git a/examples/mcp/main.go b/examples/mcp/main.go index 61de3e7cc..49c14362d 100644 --- a/examples/mcp/main.go +++ b/examples/mcp/main.go @@ -90,7 +90,7 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) if err != nil { diff --git a/examples/quickstart/main.go b/examples/quickstart/main.go index 8c1be10ad..85fd3c187 100644 --- a/examples/quickstart/main.go +++ b/examples/quickstart/main.go @@ -34,7 +34,7 @@ import ( func main() { ctx := context.Background() - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) if err != nil { diff --git a/examples/rest/main.go b/examples/rest/main.go index 84b57d2a6..e7fa1f859 100644 --- a/examples/rest/main.go +++ b/examples/rest/main.go @@ -37,7 +37,7 @@ func main() { ctx := context.Background() // Create a Gemini model - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) if err != nil { diff --git a/examples/skills/main.go b/examples/skills/main.go index f5980046c..9841ff33d 100644 --- a/examples/skills/main.go +++ b/examples/skills/main.go @@ -47,7 +47,7 @@ import ( func main() { ctx := context.Background() - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) if err != nil { diff --git a/examples/telemetry/main.go b/examples/telemetry/main.go index 88bdb4880..698a9b1d2 100644 --- a/examples/telemetry/main.go +++ b/examples/telemetry/main.go @@ -44,7 +44,7 @@ func main() { func run() error { ctx := context.Background() - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) if err != nil { diff --git a/examples/toolconfirmation/main.go b/examples/toolconfirmation/main.go index 16f49c8f7..fc3a0333f 100644 --- a/examples/toolconfirmation/main.go +++ b/examples/toolconfirmation/main.go @@ -79,7 +79,7 @@ var ( func main() { ctx := context.Background() - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{}) + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{}) if err != nil { log.Fatalf("Failed to create model: %v", err) } diff --git a/examples/tools/loadartifacts/main.go b/examples/tools/loadartifacts/main.go index 4f55fb33b..debb23e1e 100644 --- a/examples/tools/loadartifacts/main.go +++ b/examples/tools/loadartifacts/main.go @@ -39,7 +39,7 @@ import ( func main() { ctx := context.Background() - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) if err != nil { diff --git a/examples/tools/loadmemory/main.go b/examples/tools/loadmemory/main.go index c7e42b1d3..9c3905372 100644 --- a/examples/tools/loadmemory/main.go +++ b/examples/tools/loadmemory/main.go @@ -40,7 +40,7 @@ import ( func main() { ctx := context.Background() - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) if err != nil { diff --git a/examples/tools/multipletools/main.go b/examples/tools/multipletools/main.go index 993647b39..ef318bdb0 100644 --- a/examples/tools/multipletools/main.go +++ b/examples/tools/multipletools/main.go @@ -42,7 +42,7 @@ import ( func main() { ctx := context.Background() - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) if err != nil { diff --git a/examples/web/main.go b/examples/web/main.go index 6af33f4d4..431a0673b 100644 --- a/examples/web/main.go +++ b/examples/web/main.go @@ -66,7 +66,7 @@ func main() { ctx := context.Background() apiKey := os.Getenv("GOOGLE_API_KEY") - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{ + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{ APIKey: apiKey, }) if err != nil { diff --git a/examples/workflowagents/sequentialCode/main.go b/examples/workflowagents/sequentialCode/main.go index 70f3bf9ca..7ecc98639 100644 --- a/examples/workflowagents/sequentialCode/main.go +++ b/examples/workflowagents/sequentialCode/main.go @@ -33,7 +33,7 @@ import ( func main() { ctx := context.Background() - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{}) + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-lite", &genai.ClientConfig{}) if err != nil { log.Fatalf("failed to create model: %s", err) } From 3f835cd3ba3ed801d36a68bfac16e35e125aea17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Fri, 15 May 2026 16:03:37 +0100 Subject: [PATCH 40/86] feat(live): Add core bidirectional streaming support (#833) * feat(live): Add core bidirectional streaming support Brings over the core files for live agent execution, including RunLive implementation, base flow updates, and session management. * Change live run config type * lint fixes * lint fix * Add logging to runtime controller * Replace fmt prints with log --- agent/live.go | 49 +++ agent/llmagent/dynamic_events_test.go | 87 +++++ agent/llmagent/llmagent.go | 43 +++ go.mod | 52 +-- go.sum | 104 +++--- internal/agent/runconfig/run_config.go | 7 +- internal/llminternal/base_flow.go | 297 ++++++++++++++++- internal/llminternal/contents_processor.go | 53 ++- .../llminternal/contents_processor_test.go | 40 +++ .../llminternal/googlellm/live_connection.go | 294 +++++++++++++++++ internal/toolinternal/tool.go | 8 + model/gemini/gemini.go | 4 + model/llm.go | 27 +- runner/live_runner_test.go | 301 ++++++++++++++++++ runner/runner.go | 275 ++++++++++++++++ server/adkrest/controllers/runtime.go | 176 +++++++++- server/adkrest/controllers/runtime_test.go | 3 +- server/adkrest/handler.go | 2 +- server/adkrest/internal/models/event.go | 94 +++--- server/adkrest/internal/models/runtime.go | 15 + server/adkrest/internal/routers/runtime.go | 6 + 21 files changed, 1782 insertions(+), 155 deletions(-) create mode 100644 agent/live.go create mode 100644 agent/llmagent/dynamic_events_test.go create mode 100644 internal/llminternal/googlellm/live_connection.go create mode 100644 runner/live_runner_test.go diff --git a/agent/live.go b/agent/live.go new file mode 100644 index 000000000..cdeaa2c86 --- /dev/null +++ b/agent/live.go @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agent + +import ( + "google.golang.org/genai" +) + +// LiveSession manages the bidirectional stream for a live session. +type LiveSession interface { + Send(req LiveRequest) error + Close() error +} + +// LiveRequest represents an incoming client event for a live session. +type LiveRequest struct { + // RealtimeInput can be *genai.Blob, *genai.ActivityStart, or *genai.ActivityEnd. + RealtimeInput any + + // Content represents standard text or multimodal content from the user. + // Can also represent a reply to a tool call if it contains a FunctionResponse part. + Content *genai.Content +} + +// LiveRunConfig contains options for configuring a live session. +type LiveRunConfig struct { + ResponseModalities []genai.Modality + SpeechConfig *genai.SpeechConfig + InputAudioTranscription *genai.AudioTranscriptionConfig + OutputAudioTranscription *genai.AudioTranscriptionConfig + RealtimeInputConfig *genai.RealtimeInputConfig + EnableAffectiveDialog bool + Proactivity *genai.ProactivityConfig + SessionResumption *genai.SessionResumptionConfig + SaveLiveBlob bool + MaxLLMCalls int +} diff --git a/agent/llmagent/dynamic_events_test.go b/agent/llmagent/dynamic_events_test.go new file mode 100644 index 000000000..91a99073e --- /dev/null +++ b/agent/llmagent/dynamic_events_test.go @@ -0,0 +1,87 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package llmagent_test + +import ( + "iter" + "slices" + "testing" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/internal/testutil" + "google.golang.org/adk/session" +) + +func TestSessionEvents_YieldedPresence(t *testing.T) { + // Create a custom agent that yields an event and then checks the session events. + customAgent, err := agent.New(agent.Config{ + Name: "test_agent", + Run: func(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + // 1. Yield a test event + testEvent := session.NewEvent(ctx.InvocationID()) + testEvent.Content = genai.NewContentFromText("Initial test event", genai.RoleModel) + if !yield(testEvent, nil) { + return + } + + // 2. Check if the event is in the session + var found bool + if ctx.Session() != nil { + for e := range ctx.Session().Events().All() { + if e.Content != nil && len(e.Content.Parts) > 0 { + if e.Content.Parts[0].Text == "Initial test event" { + found = true + break + } + } + } + } + + // 3. Yield the result of the check + resultEvent := session.NewEvent(ctx.InvocationID()) + if found { + resultEvent.Content = genai.NewContentFromText("Found initial event in session", genai.RoleModel) + } else { + resultEvent.Content = genai.NewContentFromText("Did NOT find initial event in session", genai.RoleModel) + } + yield(resultEvent, nil) + } + }, + }) + if err != nil { + t.Fatalf("failed to create custom agent: %v", err) + } + + runner := testutil.NewTestAgentRunner(t, customAgent) + + // Run the agent + var results []string + for ev, err := range runner.Run(t, "test_session", "Hello") { + if err != nil { + t.Fatalf("run failed: %v", err) + } + if ev.Content != nil && len(ev.Content.Parts) > 0 { + results = append(results, ev.Content.Parts[0].Text) + } + } + + // Verify the result + if !slices.Contains(results, "Found initial event in session") { + t.Errorf("Expected to find initial event in session, but results were: %v", results) + } +} diff --git a/agent/llmagent/llmagent.go b/agent/llmagent/llmagent.go index fab84e6ca..634ded2da 100644 --- a/agent/llmagent/llmagent.go +++ b/agent/llmagent/llmagent.go @@ -393,6 +393,49 @@ func (a *llmAgent) run(ctx agent.InvocationContext) iter.Seq2[*session.Event, er } } +func (a *llmAgent) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + ctx = icontext.NewInvocationContext(ctx, icontext.InvocationContextParams{ + Artifacts: ctx.Artifacts(), + Memory: ctx.Memory(), + Session: ctx.Session(), + Branch: ctx.Branch(), + Agent: a, + UserContent: ctx.UserContent(), + RunConfig: ctx.RunConfig(), + InvocationID: ctx.InvocationID(), + }) + + f := &llminternal.Flow{ + Model: a.model, + RequestProcessors: llminternal.DefaultRequestProcessors, + ResponseProcessors: llminternal.DefaultResponseProcessors, + BeforeModelCallbacks: a.beforeModelCallbacks, + AfterModelCallbacks: a.afterModelCallbacks, + OnModelErrorCallbacks: a.onModelErrorCallbacks, + BeforeToolCallbacks: a.beforeToolCallbacks, + AfterToolCallbacks: a.afterToolCallbacks, + OnToolErrorCallbacks: a.onToolErrorCallbacks, + } + + sess, innerIter, err := f.RunLive(ctx) + if err != nil { + return nil, nil, err + } + + wrappedIter := func(yield func(*session.Event, error) bool) { + for ev, err := range innerIter { + if err == nil { + a.maybeSaveOutputToState(ev) + } + if !yield(ev, err) { + return + } + } + } + + return sess, wrappedIter, nil +} + // maybeSaveOutputToState saves the model output to state if needed. skip if the event // was authored by some other agent (e.g. current agent transferred to another agent) func (a *llmAgent) maybeSaveOutputToState(event *session.Event) { diff --git a/go.mod b/go.mod index 7af19d7d5..da474d43b 100644 --- a/go.mod +++ b/go.mod @@ -17,18 +17,18 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/spf13/cobra v1.10.2 - go.opentelemetry.io/contrib/detectors/gcp v1.40.0 - go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 + go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 go.opentelemetry.io/otel/log v0.16.0 - go.opentelemetry.io/otel/sdk v1.40.0 - go.opentelemetry.io/otel/trace v1.40.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 - google.golang.org/api v0.272.0 - google.golang.org/genai v1.40.0 - google.golang.org/grpc v1.80.0 + google.golang.org/api v0.279.0 + google.golang.org/genai v1.57.0 + google.golang.org/grpc v1.81.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 gorm.io/gorm v1.31.0 @@ -40,12 +40,12 @@ require github.com/hashicorp/golang-lru/v2 v2.0.7 require ( github.com/a2aproject/a2a-go/v2 v2.3.1 - golang.org/x/mod v0.33.0 // indirect + golang.org/x/mod v0.35.0 // indirect ) require ( cel.dev/expr v0.25.1 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect @@ -56,19 +56,19 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/glebarez/go-sqlite v1.21.1 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.18.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/gorilla/websocket v1.5.3 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect @@ -82,21 +82,21 @@ require ( github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk/log v0.16.0 - go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect modernc.org/libc v1.22.3 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 1785402c9..a8e7b4930 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/aiplatform v1.121.0 h1:8y8sNfVAW1DVhFbSbI7d8rrqBGGJFk6EoV6atidlyQc= cloud.google.com/go/aiplatform v1.121.0/go.mod h1:juMdDWeNphHV40KhWdN+563zNCOKNmLJjk5D2TA43ls= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -40,8 +40,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -49,20 +49,20 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/glebarez/go-sqlite v1.21.1 h1:7MZyUPh2XTrHS7xNEHQbrhfMZuPSzhkm2A1qgg0y5NY= github.com/glebarez/go-sqlite v1.21.1/go.mod h1:ISs8MF6yk5cL4n/43rSOmVMGJJjHYr7L2MbZZ5Q4E2E= github.com/glebarez/sqlite v1.8.0 h1:02X12E2I/4C1n+v90yTqrjRa8yuo7c3KeHI3FRznCvc= github.com/glebarez/sqlite v1.8.0/go.mod h1:bpET16h1za2KOOMb8+jCp6UBP/iahDpfPQqSaYLTLx8= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -86,10 +86,10 @@ github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8 github.com/google/safehtml v0.1.0/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= -github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -141,14 +141,14 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= @@ -159,58 +159,58 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1x go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4= go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= -google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= -google.golang.org/genai v1.40.0 h1:kYxyQSH+vsib8dvsgyLJzsVEIv5k3ZmHJyVqdvGncmc= -google.golang.org/genai v1.40.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= -google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= +google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= +google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= +google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM= +google.golang.org/genai v1.57.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/agent/runconfig/run_config.go b/internal/agent/runconfig/run_config.go index ecf748a5b..325451d57 100644 --- a/internal/agent/runconfig/run_config.go +++ b/internal/agent/runconfig/run_config.go @@ -14,7 +14,11 @@ package runconfig -import "context" +import ( + "context" + + "google.golang.org/adk/agent" +) type StreamingMode string @@ -26,6 +30,7 @@ const ( type RunConfig struct { StreamingMode StreamingMode + Live *agent.LiveRunConfig } func ToContext(ctx context.Context, cfg *RunConfig) context.Context { diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index 1a5dfa021..e9c200652 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -18,11 +18,14 @@ import ( "context" "errors" "fmt" + "io" "iter" + "log" "maps" "slices" "strings" "sync" + "time" "github.com/google/uuid" "google.golang.org/genai" @@ -123,6 +126,296 @@ func (f *Flow) Run(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] } } +type liveSessionImpl struct { + inputCh chan agent.LiveRequest + outputCh chan eventOrError + done chan struct{} + closeOnce sync.Once +} + +type eventOrError struct { + event *session.Event + err error +} + +func newLiveSessionImpl() *liveSessionImpl { + return &liveSessionImpl{ + inputCh: make(chan agent.LiveRequest), + outputCh: make(chan eventOrError), + done: make(chan struct{}), + } +} + +func (s *liveSessionImpl) Send(req agent.LiveRequest) error { + select { + case s.inputCh <- req: + return nil + case <-s.done: + return io.EOF + } +} + +func (s *liveSessionImpl) recvIter() iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + for { + select { + case res := <-s.outputCh: + if !yield(res.event, res.err) { + return + } + case <-s.done: + return + } + } + } +} + +func (s *liveSessionImpl) Close() error { + s.closeOnce.Do(func() { + close(s.done) + }) + return nil +} + +func (s *liveSessionImpl) pushEvent(ev *session.Event) bool { + select { + case s.outputCh <- eventOrError{event: ev}: + return true + case <-s.done: + return false + } +} + +func (s *liveSessionImpl) pushError(err error) bool { + select { + case s.outputCh <- eventOrError{err: err}: + return true + case <-s.done: + return false + } +} + +func (f *Flow) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + clientProvider, ok := f.Model.(interface { + Client() *genai.Client + }) + if !ok { + return nil, nil, fmt.Errorf("model does not support live connection") + } + client := clientProvider.Client() + + runCfg := runconfig.FromContext(ctx) + if runCfg == nil || runCfg.Live == nil { + return nil, nil, fmt.Errorf("live run config not found") + } + + sess := newLiveSessionImpl() + + go func() { + defer func() { + _ = sess.Close() + }() + + nreq := &model.LLMRequest{ + Model: f.Model.Name(), + } + for ev, err := range f.preprocess(ctx, nreq) { + if err != nil { + sess.pushError(err) + return + } + if ev != nil { + if !sess.pushEvent(ev) { + return + } + } + } + + liveConnectConfig := &genai.LiveConnectConfig{ + ResponseModalities: runCfg.Live.ResponseModalities, + SpeechConfig: runCfg.Live.SpeechConfig, + SystemInstruction: nreq.Config.SystemInstruction, + Tools: nreq.Config.Tools, + SessionResumption: runCfg.Live.SessionResumption, + InputAudioTranscription: runCfg.Live.InputAudioTranscription, + OutputAudioTranscription: runCfg.Live.OutputAudioTranscription, + } + + isResumable := func(err error) bool { + if err == nil { + return false + } + if err == io.EOF { + return true + } + errStr := err.Error() + return strings.Contains(errStr, "broken pipe") || + strings.Contains(errStr, "connection reset") || + strings.Contains(errStr, "EOF") || + strings.Contains(errStr, "1008") || + strings.Contains(errStr, "GoAway") + } + + for { + connCtx, cancelConn := context.WithCancel(ctx) + + if liveConnectConfig.SessionResumption != nil { + log.Printf("connecting with %s\n", liveConnectConfig.SessionResumption.Handle) + } + liveSession, err := client.Live.Connect(connCtx, f.Model.Name(), liveConnectConfig) + if err != nil { + cancelConn() + log.Printf("failed to connect live session: %v\n", err) + sess.pushError(fmt.Errorf("failed to connect live session: %w", err)) + return + } + + liveConn := googlellm.NewLiveConnection(liveSession, f.Model.Name(), googlellm.GetGoogleLLMVariant(f.Model)) + + cleanup := func() { + cancelConn() + _ = liveConn.Close() + } + + eventsChan := make(chan *session.Event) + errChan := make(chan error) + + // Send preprocessed content directly to model if any exists after early preprocessing + if len(nreq.Contents) > 0 { + if err := liveConn.SendHistory(ctx, nreq.Contents); err != nil { + log.Printf("failed to send history: %v\n", err) + sess.pushError(err) + return + } + } + + // Reading from model loop + go func() { + for { + resp, err := liveConn.Recv(connCtx) + if err != nil { + errChan <- err + return + } + if resp != nil { + ev := session.NewEvent(ctx.InvocationID()) + ev.Author = ctx.Agent().Name() + ev.LLMResponse = *resp + select { + case eventsChan <- ev: + case <-connCtx.Done(): + return + } + } + } + }() + + // Sending to model loop + go func() { + for { + select { + case <-connCtx.Done(): + return + case req, ok := <-sess.inputCh: + if !ok { + return + } + if req.Content != nil { + if err := liveConn.SendContent(connCtx, req.Content); err != nil { + errChan <- err + return + } + } + if req.RealtimeInput != nil { + if err := liveConn.SendRealtime(connCtx, req.RealtimeInput); err != nil { + errChan <- err + return + } + } + } + } + }() + + reconnect := false + for !reconnect { + select { + case ev := <-eventsChan: + if !sess.pushEvent(ev) { + cleanup() + return + } + // Handle function calls if present in the event + fnCalls := utils.FunctionCalls(ev.LLMResponse.Content) + if len(fnCalls) > 0 { + tools := make(map[string]tool.Tool) + for _, t := range f.Tools { + tools[t.Name()] = t + } + respEv, err := f.handleFunctionCalls(ctx, tools, &ev.LLMResponse, nil) + if err != nil { + sess.pushError(err) + cleanup() + return + } + if respEv != nil { + if !sess.pushEvent(respEv) { + cleanup() + return + } + // Check if task_completed was invoked. + var isTaskCompleted bool + if respEv.LLMResponse.Content != nil { + for _, part := range respEv.LLMResponse.Content.Parts { + if part.FunctionResponse != nil && part.FunctionResponse.Name == "task_completed" { + isTaskCompleted = true + break + } + } + } + if isTaskCompleted { + time.Sleep(100 * time.Millisecond) + cleanup() + return + } + // Send function response back to model + if err := liveConn.SendContent(connCtx, respEv.LLMResponse.Content); err != nil { + sess.pushError(err) + cleanup() + return + } + } + } + case err := <-errChan: + if isResumable(err) { + log.Printf("Connection error, attempting to resume: %v\n", err) + reconnect = true + break // Break the select + } + sess.pushError(err) + cleanup() + return + case <-ctx.Done(): + sess.pushError(ctx.Err()) + cleanup() + return + } + if reconnect { + break // Break the for loop + } + } + + // Cleanup before reconnecting + cleanup() + + if !reconnect { + break + } + } + }() + + return sess, sess.recvIter(), nil +} + func (f *Flow) runOneStep(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] { return func(yield func(*session.Event, error) bool) { if f.Model == nil { @@ -617,7 +910,9 @@ func (f *Flow) handleFunctionCalls(ctx agent.InvocationContext, toolsDict map[st toolCtx := toolinternal.NewToolContext(toolCallCtx, fnCall.ID, &session.EventActions{StateDelta: make(map[string]any)}, confirmation) var result map[string]any - curTool, found := toolsDict[fnCall.Name] + var curTool tool.Tool + var found bool + curTool, found = toolsDict[fnCall.Name] if !found { err := newToolNotFoundError(fnCall.Name, toolNames) result, err = f.runOnToolErrorCallbacks(toolCtx, &fakeTool{name: fnCall.Name}, fnCall.Args, err) diff --git a/internal/llminternal/contents_processor.go b/internal/llminternal/contents_processor.go index 360f953e7..d2c498676 100644 --- a/internal/llminternal/contents_processor.go +++ b/internal/llminternal/contents_processor.go @@ -70,9 +70,9 @@ func buildContentsDefault(agentName, invocationBranch string, events []*session. for _, ev := range events { content := utils.Content(ev) // Skip events without content or generated neither by user nor - // by model. - // e.g. events purely for mutating session states. - if content == nil || content.Role == "" || len(content.Parts) == 0 { + // by model, UNLESS they have transcriptions. + if (content == nil || content.Role == "" || len(content.Parts) == 0) && + ev.LLMResponse.InputTranscription == nil && ev.LLMResponse.OutputTranscription == nil { // TODO: log a bad event with content but no Role is skipped // Note: python checks here if content.Parts[0] is an empty string and skip if so. // But unlike python that distinguishes None vs empty string, two cases are indistinguishable in Go. @@ -93,6 +93,53 @@ func buildContentsDefault(agentName, invocationBranch string, events []*session. } } + // Aggregate transcription events (convert to text parts on the fly) + var processedEvents []*session.Event + var accumulatedInputTranscription string + var accumulatedOutputTranscription string + + for i := 0; i < len(filtered); i++ { + ev := filtered[i] + content := utils.Content(ev) + if content == nil || len(content.Parts) == 0 { + if ev.LLMResponse.InputTranscription != nil && ev.LLMResponse.InputTranscription.Text != "" { + accumulatedInputTranscription += ev.LLMResponse.InputTranscription.Text + if i != len(filtered)-1 && + filtered[i+1].LLMResponse.InputTranscription != nil && + filtered[i+1].LLMResponse.InputTranscription.Text != "" { + continue + } + // Create a new event with content + newEv := cloneEvent(ev) + newEv.LLMResponse.InputTranscription = nil + newEv.LLMResponse.Content = &genai.Content{ + Role: genai.RoleUser, + Parts: []*genai.Part{{Text: accumulatedInputTranscription}}, + } + ev = newEv + accumulatedInputTranscription = "" + } else if ev.LLMResponse.OutputTranscription != nil && ev.LLMResponse.OutputTranscription.Text != "" { + accumulatedOutputTranscription += ev.LLMResponse.OutputTranscription.Text + if i != len(filtered)-1 && + filtered[i+1].LLMResponse.OutputTranscription != nil && + filtered[i+1].LLMResponse.OutputTranscription.Text != "" { + continue + } + // Create a new event with content + newEv := cloneEvent(ev) + newEv.LLMResponse.OutputTranscription = nil + newEv.LLMResponse.Content = &genai.Content{ + Role: "model", + Parts: []*genai.Part{{Text: accumulatedOutputTranscription}}, + } + ev = newEv + accumulatedOutputTranscription = "" + } + } + processedEvents = append(processedEvents, ev) + } + filtered = processedEvents + // src/google/adk/flows/llm_flows/contents.py // - _rearrange_events_for_async_function_response filtered, err := rearrangeEventsForLatestFunctionResponse(filtered) diff --git a/internal/llminternal/contents_processor_test.go b/internal/llminternal/contents_processor_test.go index ac5dbbdb3..d827283c6 100644 --- a/internal/llminternal/contents_processor_test.go +++ b/internal/llminternal/contents_processor_test.go @@ -418,6 +418,46 @@ func TestContentsRequestProcessor(t *testing.T) { }, want: nil, }, + { + name: "TranscriptionAggregation", + events: []*session.Event{ + { + Author: "user", + LLMResponse: model.LLMResponse{ + InputTranscription: &genai.Transcription{Text: "hello ", Finished: false}, + }, + }, + { + Author: "user", + LLMResponse: model.LLMResponse{ + InputTranscription: &genai.Transcription{Text: "world", Finished: true}, + }, + }, + { + Author: "testAgent", + LLMResponse: model.LLMResponse{ + OutputTranscription: &genai.Transcription{Text: "hi ", Finished: false}, + }, + }, + { + Author: "testAgent", + LLMResponse: model.LLMResponse{ + OutputTranscription: &genai.Transcription{Text: "there", Finished: true}, + }, + }, + { + Author: "user", + LLMResponse: model.LLMResponse{ + InputTranscription: &genai.Transcription{Text: "ok", Finished: true}, + }, + }, + }, + want: []*genai.Content{ + genai.NewContentFromText("hello world", "user"), + genai.NewContentFromText("hi there", "model"), + genai.NewContentFromText("ok", "user"), + }, + }, } for _, tc := range testCases { diff --git a/internal/llminternal/googlellm/live_connection.go b/internal/llminternal/googlellm/live_connection.go new file mode 100644 index 000000000..73325e67d --- /dev/null +++ b/internal/llminternal/googlellm/live_connection.go @@ -0,0 +1,294 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package googlellm + +import ( + "context" + "fmt" + "log" + "strings" + + "google.golang.org/genai" + + "google.golang.org/adk/model" +) + +// LiveConnection wraps the underlying GenAI SDK live session. +type LiveConnection struct { + // Using the correct Session type from the GenAI SDK. + sdkSession *genai.Session + + modelName string + backend genai.Backend + inputTranscriptionText string + outputTranscriptionText string + bufferedResponses []*model.LLMResponse +} + +// NewLiveConnection creates a new LiveConnection. +func NewLiveConnection(session *genai.Session, modelName string, backend genai.Backend) *LiveConnection { + return &LiveConnection{ + sdkSession: session, + modelName: modelName, + backend: backend, + } +} + +// SendHistory sends the conversation history to prime the session. +func (c *LiveConnection) SendHistory(ctx context.Context, history []*genai.Content) error { + // TODO: genai seems to be missing initial_history_in_client_content flag + isGemini31 := strings.Contains(c.modelName, "gemini-3.1") + if isGemini31 { + log.Printf("skipping sending history for gemini 3.1\n") + return nil + } + log.Printf("sending preprocessed content %d\n", len(history)) + + var filteredHistory []*genai.Content + for _, content := range history { + if content == nil { + continue + } + var filteredParts []*genai.Part + for _, part := range content.Parts { + if part == nil { + continue + } + if part.InlineData != nil && strings.HasPrefix(part.InlineData.MIMEType, "audio/") { + continue + } + filteredParts = append(filteredParts, part) + } + if len(filteredParts) > 0 { + filteredHistory = append(filteredHistory, &genai.Content{ + Parts: filteredParts, + Role: content.Role, + }) + } + } + log.Printf("sending history: of size %d\n", len(filteredHistory)) + turnComplete := len(filteredHistory) > 0 && filteredHistory[len(filteredHistory)-1].Role == "user" + if len(filteredHistory) > 0 { + err := c.sdkSession.SendClientContent(genai.LiveClientContentInput{ + Turns: filteredHistory, + TurnComplete: &turnComplete, + }) + if err != nil { + return fmt.Errorf("failed to send history: %w", err) + } + } + + return nil +} + +// SendContent sends unary content or function responses to the model. +func (c *LiveConnection) SendContent(ctx context.Context, content *genai.Content) error { + if content == nil || len(content.Parts) == 0 { + return fmt.Errorf("empty content") + } + + if content.Parts[0].FunctionResponse != nil { + var functionResponses []*genai.FunctionResponse + for _, part := range content.Parts { + if part.FunctionResponse != nil { + functionResponses = append(functionResponses, part.FunctionResponse) + } + } + err := c.sdkSession.SendToolResponse(genai.LiveToolResponseInput{ + FunctionResponses: functionResponses, + }) + if err != nil { + return fmt.Errorf("failed to send tool response: %w", err) + } + log.Printf("sending tool response\n") + } else { + isGemini31 := strings.Contains(c.modelName, "gemini-3.1") + isGeminiAPI := c.backend == genai.BackendGeminiAPI + if isGemini31 && isGeminiAPI && len(content.Parts) == 1 && content.Parts[0].Text != "" { + log.Printf("Attempting to send text via SendRealtimeInput\n") + err := c.sdkSession.SendRealtimeInput(genai.LiveRealtimeInput{ + Text: content.Parts[0].Text, + }) + if err != nil { + return fmt.Errorf("failed to send realtime text: %w", err) + } + return nil + } + + turnComplete := true + err := c.sdkSession.SendClientContent(genai.LiveClientContentInput{ + Turns: []*genai.Content{content}, + TurnComplete: &turnComplete, + }) + if err != nil { + return fmt.Errorf("failed to send content: %w", err) + } + } + + return nil +} + +// SendRealtime sends real-time input (audio/video). +func (c *LiveConnection) SendRealtime(ctx context.Context, input any) error { + switch v := input.(type) { + case *genai.Blob: + if v.MIMEType == "" { + // Detect PNG by signature: \x89PNG\r\n\x1a\n + isPNG := len(v.Data) >= 8 && + v.Data[0] == 0x89 && v.Data[1] == 0x50 && v.Data[2] == 0x4E && v.Data[3] == 0x47 && + v.Data[4] == 0x0D && v.Data[5] == 0x0A && v.Data[6] == 0x1A && v.Data[7] == 0x0A + + if isPNG { + v.MIMEType = "image/png" + } else { + v.MIMEType = "audio/pcm" + } + } + + isGemini31 := strings.Contains(c.modelName, "gemini-3.1") + isGeminiAPI := c.backend == genai.BackendGeminiAPI + if isGemini31 && isGeminiAPI { + if strings.HasPrefix(v.MIMEType, "image/") { + return c.sdkSession.SendRealtimeInput(genai.LiveRealtimeInput{ + Video: v, + }) + } + return c.sdkSession.SendRealtimeInput(genai.LiveRealtimeInput{ + Audio: v, + }) + } + + return c.sdkSession.SendRealtimeInput(genai.LiveRealtimeInput{ + Media: v, + }) + case *genai.ActivityStart: + log.Printf("sending activity start\n") + return c.sdkSession.SendRealtimeInput(genai.LiveRealtimeInput{ + ActivityStart: v, + }) + case *genai.ActivityEnd: + log.Printf("sending activity end\n") + return c.sdkSession.SendRealtimeInput(genai.LiveRealtimeInput{ + ActivityEnd: v, + }) + default: + return fmt.Errorf("unsupported real-time input type: %T", input) + } +} + +// Recv receives a response from the live server connection. +func (c *LiveConnection) Recv(ctx context.Context) (*model.LLMResponse, error) { + if len(c.bufferedResponses) > 0 { + resp := c.bufferedResponses[0] + c.bufferedResponses = c.bufferedResponses[1:] + return resp, nil + } + + msg, err := c.sdkSession.Receive() + if err != nil { + return nil, fmt.Errorf("failed to receive message: %w", err) + } + + if msg == nil { + return nil, nil + } + + resp := &model.LLMResponse{} + + if msg.SessionResumptionUpdate != nil { + resp.SessionResumptionHandle = msg.SessionResumptionUpdate.NewHandle + return resp, nil + } + + if msg.ServerContent != nil { + content := msg.ServerContent + if content.ModelTurn != nil { + resp.Content = content.ModelTurn + } + resp.TurnComplete = content.TurnComplete + resp.Interrupted = content.Interrupted + + if content.InputTranscription != nil { + resp.InputTranscription = content.InputTranscription + c.inputTranscriptionText += content.InputTranscription.Text + resp.Partial = true // Mark chunks as partial so they are not saved to session + } + if content.OutputTranscription != nil { + resp.OutputTranscription = content.OutputTranscription + c.outputTranscriptionText += content.OutputTranscription.Text + resp.Partial = true // Mark chunks as partial so they are not saved to session + } + + // Handle transcription finalization on completion signals + if content.TurnComplete || content.Interrupted { + if c.inputTranscriptionText != "" || c.outputTranscriptionText != "" { + if c.inputTranscriptionText != "" { + inputResp := &model.LLMResponse{ + Partial: false, + InputTranscription: &genai.Transcription{ + Text: c.inputTranscriptionText, + Finished: true, + }, + } + c.inputTranscriptionText = "" + c.bufferedResponses = append(c.bufferedResponses, inputResp) + } + if c.outputTranscriptionText != "" { + outputResp := &model.LLMResponse{ + Partial: false, + OutputTranscription: &genai.Transcription{ + Text: c.outputTranscriptionText, + Finished: true, + }, + } + c.outputTranscriptionText = "" + c.bufferedResponses = append(c.bufferedResponses, outputResp) + } + + // Append the current response (which has TurnComplete or Interrupted) to the buffer + // so it is delivered AFTER the transcriptions + c.bufferedResponses = append(c.bufferedResponses, resp) + + // Return the first one from buffer + first := c.bufferedResponses[0] + c.bufferedResponses = c.bufferedResponses[1:] + return first, nil + } + } + } + + if msg.ToolCall != nil { + if resp.Content == nil { + resp.Content = &genai.Content{Role: "model"} + } + for _, call := range msg.ToolCall.FunctionCalls { + if call != nil { + resp.Content.Parts = append(resp.Content.Parts, &genai.Part{ + FunctionCall: call, + }) + } + } + } + + return resp, nil +} + +// Close closes the live server connection. +func (c *LiveConnection) Close() error { + if c.sdkSession != nil { + return c.sdkSession.Close() + } + return nil +} diff --git a/internal/toolinternal/tool.go b/internal/toolinternal/tool.go index 40aa63fd2..64835e742 100644 --- a/internal/toolinternal/tool.go +++ b/internal/toolinternal/tool.go @@ -16,6 +16,8 @@ package toolinternal import ( + "iter" + "google.golang.org/genai" "google.golang.org/adk/model" @@ -28,6 +30,12 @@ type FunctionTool interface { Run(ctx tool.Context, args any) (result map[string]any, err error) } +type StreamingFunctionTool interface { + tool.Tool + Declaration() *genai.FunctionDeclaration + RunStream(ctx tool.Context, args any) iter.Seq2[string, error] +} + type RequestProcessor interface { ProcessRequest(ctx tool.Context, req *model.LLMRequest) error } diff --git a/model/gemini/gemini.go b/model/gemini/gemini.go index cb1e79ec0..39ce3ba8d 100644 --- a/model/gemini/gemini.go +++ b/model/gemini/gemini.go @@ -196,4 +196,8 @@ func (m *geminiModel) GetGoogleLLMVariant() genai.Backend { return m.client.ClientConfig().Backend } +func (m *geminiModel) Client() *genai.Client { + return m.client +} + var _ googlellm.GoogleLLM = &geminiModel{} diff --git a/model/llm.go b/model/llm.go index b9b95cce9..587c81fa6 100644 --- a/model/llm.go +++ b/model/llm.go @@ -40,13 +40,15 @@ type LLMRequest struct { // LLMResponse is the raw LLM response. // It provides the first candidate response from the model if available. type LLMResponse struct { - Content *genai.Content - CitationMetadata *genai.CitationMetadata - GroundingMetadata *genai.GroundingMetadata - UsageMetadata *genai.GenerateContentResponseUsageMetadata - CustomMetadata map[string]any - LogprobsResult *genai.LogprobsResult - ModelVersion string + Content *genai.Content + CitationMetadata *genai.CitationMetadata + GroundingMetadata *genai.GroundingMetadata + UsageMetadata *genai.GenerateContentResponseUsageMetadata + CustomMetadata map[string]any + LogprobsResult *genai.LogprobsResult + InputTranscription *genai.Transcription + OutputTranscription *genai.Transcription + ModelVersion string // Partial indicates whether the content is part of a unfinished content stream. // Only used for streaming mode and when the content is plain text. // The Runner fully processes only the final non-partial event, partial @@ -57,9 +59,10 @@ type LLMResponse struct { TurnComplete bool // Flag indicating that LLM was interrupted when generating the content. // Usually it is due to user interruption during a bidi streaming. - Interrupted bool - ErrorCode string - ErrorMessage string - FinishReason genai.FinishReason - AvgLogprobs float64 + Interrupted bool + SessionResumptionHandle string + ErrorCode string + ErrorMessage string + FinishReason genai.FinishReason + AvgLogprobs float64 } diff --git a/runner/live_runner_test.go b/runner/live_runner_test.go new file mode 100644 index 000000000..0d2bd50d7 --- /dev/null +++ b/runner/live_runner_test.go @@ -0,0 +1,301 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runner + +import ( + "context" + "iter" + "strings" + "testing" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/plugin" + "google.golang.org/adk/session" +) + +type mockLiveAgent struct { + agent.Agent + runLiveFn func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) +} + +func (m *mockLiveAgent) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + return m.runLiveFn(ctx) +} + +type dummyLiveSession struct{} + +func (d *dummyLiveSession) Send(req agent.LiveRequest) error { return nil } +func (d *dummyLiveSession) Close() error { return nil } + +func TestRunner_RunLive_Callbacks(t *testing.T) { + ctx := context.Background() + appName, userID, sessionID := "testApp", "testUser", "testSession" + + sessionService := session.InMemoryService() + _, err := sessionService.Create(ctx, &session.CreateRequest{ + AppName: appName, + UserID: userID, + SessionID: sessionID, + }) + if err != nil { + t.Fatal(err) + } + + var beforeRunCalled, afterRunCalled bool + + p, err := plugin.New(plugin.Config{ + Name: "test_plugin", + BeforeRunCallback: func(ctx agent.InvocationContext) (*genai.Content, error) { + beforeRunCalled = true + return nil, nil + }, + AfterRunCallback: func(ctx agent.InvocationContext) { + afterRunCalled = true + }, + }) + if err != nil { + t.Fatal(err) + } + + testAgent := must(agent.New(agent.Config{Name: "test_agent"})) + mockLive := &mockLiveAgent{ + Agent: testAgent, + runLiveFn: func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + return &dummyLiveSession{}, func(yield func(*session.Event, error) bool) { + yield(session.NewEvent(ctx.InvocationID()), nil) + }, nil + }, + } + + r, err := New(Config{ + AppName: appName, + Agent: mockLive, + SessionService: sessionService, + PluginConfig: PluginConfig{ + Plugins: []*plugin.Plugin{p}, + }, + }) + if err != nil { + t.Fatal(err) + } + + sess, iter, err := r.RunLive(ctx, userID, sessionID, agent.LiveRunConfig{}) + if err != nil { + t.Fatalf("RunLive failed: %v", err) + } + if sess == nil { + t.Fatal("expected LiveSession to be returned") + } + + if !beforeRunCalled { + t.Error("BeforeRunCallback was not called before starting RunLive") + } + + if afterRunCalled { + t.Error("AfterRunCallback should not be called until iterator is consumed") + } + + for _, err := range iter { + if err != nil { + t.Fatal(err) + } + } + + if !afterRunCalled { + t.Error("AfterRunCallback was not called after iterator was consumed") + } +} + +func TestRunner_RunLive_EarlyExit(t *testing.T) { + ctx := context.Background() + appName, userID, sessionID := "testApp", "testUser", "testSession2" + + sessionService := session.InMemoryService() + _, err := sessionService.Create(ctx, &session.CreateRequest{ + AppName: appName, + UserID: userID, + SessionID: sessionID, + }) + if err != nil { + t.Fatal(err) + } + + expectedContent := genai.NewContentFromText("early exit content", "") + + p, err := plugin.New(plugin.Config{ + Name: "test_plugin", + BeforeRunCallback: func(ctx agent.InvocationContext) (*genai.Content, error) { + return expectedContent, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + testAgent := must(agent.New(agent.Config{Name: "test_agent"})) + var runLiveCalled bool + mockLive := &mockLiveAgent{ + Agent: testAgent, + runLiveFn: func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + runLiveCalled = true + return &dummyLiveSession{}, func(yield func(*session.Event, error) bool) {}, nil + }, + } + + r, err := New(Config{ + AppName: appName, + Agent: mockLive, + SessionService: sessionService, + PluginConfig: PluginConfig{ + Plugins: []*plugin.Plugin{p}, + }, + }) + if err != nil { + t.Fatal(err) + } + + sess, iter, err := r.RunLive(ctx, userID, sessionID, agent.LiveRunConfig{}) + if err != nil { + t.Fatalf("RunLive failed: %v", err) + } + if runLiveCalled { + t.Error("RunLive should not have been called on the agent due to early exit") + } + + var events []*session.Event + for ev, err := range iter { + if err != nil { + t.Fatal(err) + } + events = append(events, ev) + } + + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + + if events[0].LLMResponse.Content != expectedContent { + t.Errorf("expected content %v, got %v", expectedContent, events[0].LLMResponse.Content) + } + + err = sess.Send(agent.LiveRequest{}) + if err == nil || !strings.Contains(err.Error(), "session is closed") { + t.Errorf("expected error 'session is closed' when sending to early exited session, got %v", err) + } + if err := sess.Close(); err != nil { + t.Errorf("Close() failed: %v", err) + } +} + +func TestRunner_RunLive_ChronologicalBuffering(t *testing.T) { + ctx := context.Background() + appName, userID, sessionID := "testApp", "testUser", "testSession3" + + sessionService := session.InMemoryService() + _, err := sessionService.Create(ctx, &session.CreateRequest{ + AppName: appName, + UserID: userID, + SessionID: sessionID, + }) + if err != nil { + t.Fatal(err) + } + + testAgent := must(agent.New(agent.Config{Name: "test_agent"})) + mockLive := &mockLiveAgent{ + Agent: testAgent, + runLiveFn: func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + return &dummyLiveSession{}, func(yield func(*session.Event, error) bool) { + // 1. Partial Transcription + ev1 := session.NewEvent(ctx.InvocationID()) + ev1.LLMResponse.Partial = true + ev1.LLMResponse.OutputTranscription = &genai.Transcription{Text: "Hello"} + if !yield(ev1, nil) { + return + } + + // 2. Function Call (happening during transcription) + ev2 := session.NewEvent(ctx.InvocationID()) + ev2.LLMResponse.Content = &genai.Content{ + Parts: []*genai.Part{{FunctionCall: &genai.FunctionCall{Name: "test_func"}}}, + } + if !yield(ev2, nil) { + return + } + + // 3. Final Transcription + ev3 := session.NewEvent(ctx.InvocationID()) + ev3.LLMResponse.OutputTranscription = &genai.Transcription{Text: "Hello there."} + if !yield(ev3, nil) { + return + } + }, nil + }, + } + + r, err := New(Config{ + AppName: appName, + Agent: mockLive, + SessionService: sessionService, + }) + if err != nil { + t.Fatal(err) + } + + _, iter, err := r.RunLive(ctx, userID, sessionID, agent.LiveRunConfig{}) + if err != nil { + t.Fatalf("RunLive failed: %v", err) + } + + // Consume iterator to execute everything + for _, err := range iter { + if err != nil { + t.Fatal(err) + } + } + + // Verify Session History + getResp, err := sessionService.Get(ctx, &session.GetRequest{ + AppName: appName, + UserID: userID, + SessionID: sessionID, + }) + if err != nil { + t.Fatal(err) + } + + events := getResp.Session.Events() + // We expect 2 saved events: Final Transcription first, Function Call second. + // (Partial Transcription is not saved). + if events.Len() != 2 { + t.Fatalf("expected 2 saved events in session, got %d", events.Len()) + } + + // First saved event should be the final transcription + if events.At(0).LLMResponse.OutputTranscription == nil { + t.Errorf("expected first saved event to be transcription, but got %v", events.At(0)) + } + + if events.At(0).LLMResponse.OutputTranscription.Text != "Hello there." { + t.Errorf("expected first saved event to be transcription with text: %q, got: %q", "Hello there.", events.At(0).LLMResponse.OutputTranscription.Text) + } + + // Second saved event should be the function call + if events.At(1).LLMResponse.Content == nil || events.At(1).LLMResponse.Content.Parts[0].FunctionCall == nil { + t.Errorf("expected second saved event to be function call, but got %v", events.At(1)) + } +} diff --git a/runner/runner.go b/runner/runner.go index 3e29a7568..fd910ee4f 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -267,6 +267,269 @@ func (r *Runner) Run(ctx context.Context, userID, sessionID string, msg *genai.C } } +type liveAgent interface { + RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) +} + +// RunLive runs a live session for the agent, supporting bidirectional streaming. +type runnerLiveSession struct { + sess agent.LiveSession + r *Runner + iCtx agent.InvocationContext + storedSession session.Session +} + +func (s *runnerLiveSession) Send(req agent.LiveRequest) error { + err := s.sess.Send(req) + if err != nil { + return err + } + + // Save user text content to session history + if req.Content != nil && len(req.Content.Parts) > 0 { + // Skip function responses - they are handled separately + isFunctionResponse := false + for _, part := range req.Content.Parts { + if part.FunctionResponse != nil { + isFunctionResponse = true + break + } + } + + if !isFunctionResponse { + event := session.NewEvent(s.iCtx.InvocationID()) + event.Author = "user" + event.LLMResponse = model.LLMResponse{ + Content: req.Content, + } + if err := s.r.sessionService.AppendEvent(s.iCtx, s.storedSession, event); err != nil { + return fmt.Errorf("failed to add user event to session: %w", err) + } + } + } + + return nil +} + +func (s *runnerLiveSession) Close() error { + return s.sess.Close() +} + +type closedLiveSession struct{} + +func (s *closedLiveSession) Send(req agent.LiveRequest) error { + return fmt.Errorf("session is closed") +} + +func (s *closedLiveSession) Close() error { + return nil +} + +func (r *Runner) RunLive(ctx context.Context, userID, sessionID string, cfg agent.LiveRunConfig, opts ...RunOption) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + options := runOptions{} + for _, opt := range opts { + opt(&options) + } + + var storedSession session.Session + getResp, err := r.sessionService.Get(ctx, &session.GetRequest{ + AppName: r.appName, + UserID: userID, + SessionID: sessionID, + }) + if err != nil { + if !r.autoCreateSession { + return nil, nil, err + } + createResp, err := r.sessionService.Create(ctx, &session.CreateRequest{ + AppName: r.appName, + UserID: userID, + SessionID: sessionID, + }) + if err != nil { + return nil, nil, err + } + storedSession = createResp.Session + } else { + storedSession = getResp.Session + } + + // msg is nil for Live run as it's streaming + agentToRun, err := r.findAgentToRun(storedSession, nil) + if err != nil { + return nil, nil, err + } + + lAgent, ok := agentToRun.(liveAgent) + if !ok { + return nil, nil, fmt.Errorf("agent %s does not support Live Run", agentToRun.Name()) + } + + ctx = parentmap.ToContext(ctx, r.parents) + ctx = runconfig.ToContext(ctx, &runconfig.RunConfig{ + StreamingMode: runconfig.StreamingModeBidi, // Live is always bidirectional streaming + Live: &cfg, + }) + ctx = plugininternal.ToContext(ctx, r.pluginManager) + + var artifacts agent.Artifacts + if r.artifactService != nil { + artifacts = &artifactinternal.Artifacts{ + Service: r.artifactService, + SessionID: storedSession.ID(), + AppName: storedSession.AppName(), + UserID: storedSession.UserID(), + } + } + + var memoryImpl agent.Memory = nil + if r.memoryService != nil { + memoryImpl = &imemory.Memory{ + Service: r.memoryService, + SessionID: storedSession.ID(), + UserID: storedSession.UserID(), + AppName: storedSession.AppName(), + } + } + + iCtx := icontext.NewInvocationContext(ctx, icontext.InvocationContextParams{ + Artifacts: artifacts, + Memory: memoryImpl, + Session: storedSession, + Agent: agentToRun, + UserContent: nil, + }) + + if r.pluginManager != nil { + earlyExitResult, err := r.pluginManager.RunBeforeRunCallback(iCtx) + if err != nil { + return nil, nil, err + } + if earlyExitResult != nil { + earlyExitEvent := session.NewEvent(iCtx.InvocationID()) + earlyExitEvent.Author = agentToRun.Name() + earlyExitEvent.LLMResponse = model.LLMResponse{ + Content: earlyExitResult, + } + if err := r.sessionService.AppendEvent(iCtx, storedSession, earlyExitEvent); err != nil { + return nil, nil, fmt.Errorf("failed to add event to session: %w", err) + } + + earlyExitIter := func(yield func(*session.Event, error) bool) { + yield(earlyExitEvent, nil) + } + return &closedLiveSession{}, earlyExitIter, nil + } + } + + agentSess, innerIter, err := lAgent.RunLive(iCtx) + if err != nil { + return nil, nil, err + } + + wrappedIter := func(yield func(*session.Event, error) bool) { + if r.pluginManager != nil { + defer r.pluginManager.RunAfterRunCallback(iCtx) + } + + var bufferedEvents []*session.Event + isTranscribing := false + + for event, err := range innerIter { + if err != nil { + if !yield(nil, err) { + return + } + continue + } + + if r.pluginManager != nil { + modifiedEvent, pluginErr := r.pluginManager.RunOnEventCallback(iCtx, event) + if pluginErr != nil { + if !yield(nil, pluginErr) { + return + } + continue + } + if modifiedEvent != nil { + event = modifiedEvent + } + } + + // Chronological event buffering logic for Live streaming. + // Holds back tool calls/responses if they arrive before the transcription finishes. + if event.LLMResponse.Partial && (event.LLMResponse.InputTranscription != nil || event.LLMResponse.OutputTranscription != nil) { + isTranscribing = true + } + + isToolCallOrResp := false + if event.LLMResponse.Content != nil { + for _, part := range event.LLMResponse.Content.Parts { + if part.FunctionCall != nil || part.FunctionResponse != nil { + isToolCallOrResp = true + break + } + } + } + + if isTranscribing && isToolCallOrResp { + bufferedEvents = append(bufferedEvents, event) + continue + } + + if !event.LLMResponse.Partial { + if event.LLMResponse.InputTranscription != nil || event.LLMResponse.OutputTranscription != nil { + isTranscribing = false + + if err := r.sessionService.AppendEvent(iCtx, storedSession, event); err != nil { + if !yield(nil, fmt.Errorf("failed to add event to session: %w", err)) { + return + } + continue + } + if !yield(event, nil) { + return + } + + for _, bufferedEvent := range bufferedEvents { + if err := r.sessionService.AppendEvent(iCtx, storedSession, bufferedEvent); err != nil { + if !yield(nil, fmt.Errorf("failed to add event to session: %w", err)) { + return + } + continue + } + if !yield(bufferedEvent, nil) { + return + } + } + bufferedEvents = nil + continue + } + } + + if !event.LLMResponse.Partial && !hasInlineData(event) { + if err := r.sessionService.AppendEvent(iCtx, storedSession, event); err != nil { + if !yield(nil, fmt.Errorf("failed to add event to session: %w", err)) { + return + } + continue + } + } + + if !yield(event, nil) { + return + } + } + } + + return &runnerLiveSession{ + sess: agentSess, + r: r, + iCtx: iCtx, + storedSession: storedSession, + }, wrappedIter, nil +} + func (r *Runner) appendMessageToSession(ctx agent.InvocationContext, storedSession session.Session, msg *genai.Content, saveInputBlobsAsArtifacts bool, pluginManager *plugininternal.PluginManager, stateDelta map[string]any) (agent.InvocationContext, error) { if msg == nil { return ctx, nil @@ -401,3 +664,15 @@ func (r *Runner) isTransferableAcrossAgentTree(agentToRun agent.Agent) bool { return true } + +func hasInlineData(event *session.Event) bool { + if event.LLMResponse.Content == nil { + return false + } + for _, part := range event.LLMResponse.Content.Parts { + if part.InlineData != nil { + return true + } + } + return false +} diff --git a/server/adkrest/controllers/runtime.go b/server/adkrest/controllers/runtime.go index 11085eeba..e97940637 100644 --- a/server/adkrest/controllers/runtime.go +++ b/server/adkrest/controllers/runtime.go @@ -22,6 +22,9 @@ import ( "net/http" "time" + "github.com/gorilla/websocket" + "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/artifact" "google.golang.org/adk/memory" @@ -32,17 +35,18 @@ import ( // RuntimeAPIController is the controller for the Runtime API. type RuntimeAPIController struct { - sseTimeout time.Duration - sessionService session.Service - memoryService memory.Service - artifactService artifact.Service - agentLoader agent.Loader - pluginConfig runner.PluginConfig + sseTimeout time.Duration + sessionService session.Service + memoryService memory.Service + artifactService artifact.Service + agentLoader agent.Loader + pluginConfig runner.PluginConfig + autoCreateSession bool } // NewRuntimeAPIController creates the controller for the Runtime API. -func NewRuntimeAPIController(sessionService session.Service, memoryService memory.Service, agentLoader agent.Loader, artifactService artifact.Service, sseTimeout time.Duration, pluginConfig runner.PluginConfig) *RuntimeAPIController { - return &RuntimeAPIController{sessionService: sessionService, memoryService: memoryService, agentLoader: agentLoader, artifactService: artifactService, sseTimeout: sseTimeout, pluginConfig: pluginConfig} +func NewRuntimeAPIController(sessionService session.Service, memoryService memory.Service, agentLoader agent.Loader, artifactService artifact.Service, sseTimeout time.Duration, pluginConfig runner.PluginConfig, autoCreateSession bool) *RuntimeAPIController { + return &RuntimeAPIController{sessionService: sessionService, memoryService: memoryService, agentLoader: agentLoader, artifactService: artifactService, sseTimeout: sseTimeout, pluginConfig: pluginConfig, autoCreateSession: autoCreateSession} } // RunAgent executes a non-streaming agent run for a given session and message. @@ -204,12 +208,13 @@ func (c *RuntimeAPIController) getRunner(req models.RunAgentRequest) (*runner.Ru } r, err := runner.New(runner.Config{ - AppName: req.AppName, - Agent: curAgent, - SessionService: c.sessionService, - MemoryService: c.memoryService, - ArtifactService: c.artifactService, - PluginConfig: c.pluginConfig, + AppName: req.AppName, + Agent: curAgent, + SessionService: c.sessionService, + MemoryService: c.memoryService, + ArtifactService: c.artifactService, + PluginConfig: c.pluginConfig, + AutoCreateSession: c.autoCreateSession, }, ) if err != nil { @@ -237,3 +242,146 @@ func decodeRequestBody(req *http.Request) (decodedReq models.RunAgentRequest, er } return runAgentRequest, nil } + +func (c *RuntimeAPIController) RunLiveHandler(rw http.ResponseWriter, req *http.Request) error { + upgrader := websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + } + + q := req.URL.Query() + appName := q.Get("appName") + if appName == "" { + appName = q.Get("app_name") + } + userID := q.Get("userId") + if userID == "" { + userID = q.Get("user_id") + } + sessionID := q.Get("sessionId") + if sessionID == "" { + sessionID = q.Get("session_id") + } + + if appName == "" || userID == "" || sessionID == "" { + return fmt.Errorf("appName, userId, and sessionId are required") + } + + ws, err := upgrader.Upgrade(rw, req, nil) + if err != nil { + return fmt.Errorf("failed to upgrade to websocket: %w", err) + } + defer func() { + _ = ws.Close() + }() + + sendClose := func(code int, reason string) { + _ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason)) + _ = ws.SetReadDeadline(time.Now().Add(time.Second)) + for { + if _, _, err := ws.ReadMessage(); err != nil { + break + } + } + } + + r, _, err := c.getRunner(models.RunAgentRequest{AppName: appName, UserId: userID, SessionId: sessionID}) + if err != nil { + closeReason := err.Error() + if _, loadErr := c.agentLoader.LoadAgent(appName); loadErr != nil { + closeReason = fmt.Sprintf("agent %s not found for original error: %v", appName, err) + } + log.Printf("Failed to get runner for app %s: %v", appName, err) + sendClose(websocket.CloseInternalServerErr, closeReason) + return nil + } + + // Read from Runner and write back to client over the WebSocket + liveSession, eventIter, err := r.RunLive(req.Context(), userID, sessionID, agent.LiveRunConfig{ + MaxLLMCalls: 100, // Reasonable default + ResponseModalities: []genai.Modality{genai.ModalityAudio}, + InputAudioTranscription: &genai.AudioTranscriptionConfig{}, + OutputAudioTranscription: &genai.AudioTranscriptionConfig{}, + }) + if err != nil { + log.Printf("RunLive failed for app %s: %v", appName, err) + sendClose(websocket.CloseInternalServerErr, err.Error()) + return nil + } + defer func() { + _ = liveSession.Close() + }() + + // Spawning goroutine for reading from the client over WebSocket and pushing it to Runner + go func() { + defer func() { + _ = liveSession.Close() + }() + for { + messageType, p, err := ws.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + log.Printf("WebSocket read error for app %s: %v", appName, err) + } + break + } + + if messageType == websocket.BinaryMessage { + if err := liveSession.Send(agent.LiveRequest{ + RealtimeInput: &genai.Blob{ + MIMEType: "audio/pcm;rate=16000", + Data: p, + }, + }); err != nil { + log.Printf("Failed to send binary data to Gemini for app %s: %v", appName, err) + break + } + } else if messageType == websocket.TextMessage { + var apiReq models.LiveRequest + if err := json.Unmarshal(p, &apiReq); err != nil { + log.Printf("Failed to unmarshal client message for app %s: %v", appName, err) + continue + } + + if apiReq.Close { + break + } + + liveReq := agent.LiveRequest{ + Content: apiReq.Content, + } + + if apiReq.ActivityStart != nil { + liveReq.RealtimeInput = apiReq.ActivityStart + } else if apiReq.ActivityEnd != nil { + liveReq.RealtimeInput = apiReq.ActivityEnd + } else if apiReq.Blob != nil { + liveReq.RealtimeInput = &genai.Blob{ + MIMEType: apiReq.Blob.MIMEType, + Data: apiReq.Blob.Data, + } + } + + if err := liveSession.Send(liveReq); err != nil { + log.Printf("Failed to send message to Gemini for app %s: %v", appName, err) + break + } + } + } + }() + + for event, err := range eventIter { + if err != nil { + log.Printf("RunLive failed: %v\n", err) + _ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, err.Error())) + break + } + + err = ws.WriteJSON(models.FromSessionEvent(*event)) + if err != nil { + break + } + } + + return nil +} diff --git a/server/adkrest/controllers/runtime_test.go b/server/adkrest/controllers/runtime_test.go index a6672fb83..eecf58192 100644 --- a/server/adkrest/controllers/runtime_test.go +++ b/server/adkrest/controllers/runtime_test.go @@ -77,7 +77,7 @@ func TestNewRuntimeAPIController_PluginsAssignment(t *testing.T) { t.Run(tt.name, func(t *testing.T) { controller := NewRuntimeAPIController(nil, nil, nil, nil, 10*time.Second, runner.PluginConfig{ Plugins: tt.plugins, - }) + }, false) if controller == nil { t.Fatal("NewRuntimeAPIController returned nil") @@ -201,6 +201,7 @@ func TestRunSSEHandler(t *testing.T) { nil, 10*time.Second, runner.PluginConfig{}, + false, ) // Create request diff --git a/server/adkrest/handler.go b/server/adkrest/handler.go index 1bc32d331..e96e9a67e 100644 --- a/server/adkrest/handler.go +++ b/server/adkrest/handler.go @@ -47,7 +47,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { // where the ADK REST API will be served. setupRouter(router, routers.NewSessionsAPIRouter(controllers.NewSessionsAPIController(cfg.SessionService)), - routers.NewRuntimeAPIRouter(controllers.NewRuntimeAPIController(cfg.SessionService, cfg.MemoryService, cfg.AgentLoader, cfg.ArtifactService, cfg.SSEWriteTimeout, cfg.PluginConfig)), + routers.NewRuntimeAPIRouter(controllers.NewRuntimeAPIController(cfg.SessionService, cfg.MemoryService, cfg.AgentLoader, cfg.ArtifactService, cfg.SSEWriteTimeout, cfg.PluginConfig, false)), routers.NewAppsAPIRouter(controllers.NewAppsAPIController(cfg.AgentLoader)), routers.NewDebugAPIRouter(controllers.NewDebugAPIController(cfg.SessionService, cfg.AgentLoader, debugTelemetry)), routers.NewArtifactsAPIRouter(controllers.NewArtifactsAPIController(cfg.ArtifactService)), diff --git a/server/adkrest/internal/models/event.go b/server/adkrest/internal/models/event.go index 79497ff03..6fb42cd61 100644 --- a/server/adkrest/internal/models/event.go +++ b/server/adkrest/internal/models/event.go @@ -34,23 +34,25 @@ type EventActions struct { // Event represents a single event in a session. type Event struct { - ID string `json:"id"` - InvocationID string `json:"invocationId"` - Branch string `json:"branch,omitempty"` - Author string `json:"author"` - Partial bool `json:"partial,omitempty"` - LongRunningToolIDs []string `json:"longRunningToolIds,omitempty"` - Content *genai.Content `json:"content"` - GroundingMetadata *genai.GroundingMetadata `json:"groundingMetadata"` - UsageMetadata *genai.GenerateContentResponseUsageMetadata `json:"usageMetadata"` - TurnComplete bool `json:"turnComplete,omitempty"` - Interrupted bool `json:"interrupted,omitempty"` - ErrorCode string `json:"errorCode,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` - AvgLogprobs float64 `json:"avgLogprobs,omitempty"` - FinishReason genai.FinishReason `json:"finishReason,omitempty"` - ModelVersion string `json:"modelVersion,omitempty"` - Actions EventActions `json:"actions"` + ID string `json:"id"` + InvocationID string `json:"invocationId"` + Branch string `json:"branch,omitempty"` + Author string `json:"author"` + Partial bool `json:"partial,omitempty"` + LongRunningToolIDs []string `json:"longRunningToolIds,omitempty"` + Content *genai.Content `json:"content"` + GroundingMetadata *genai.GroundingMetadata `json:"groundingMetadata"` + UsageMetadata *genai.GenerateContentResponseUsageMetadata `json:"usageMetadata"` + TurnComplete bool `json:"turnComplete,omitempty"` + Interrupted bool `json:"interrupted,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + AvgLogprobs float64 `json:"avgLogprobs,omitempty"` + FinishReason genai.FinishReason `json:"finishReason,omitempty"` + ModelVersion string `json:"modelVersion,omitempty"` + InputTranscription *genai.Transcription `json:"inputTranscription,omitempty"` + OutputTranscription *genai.Transcription `json:"outputTranscription,omitempty"` + Actions EventActions `json:"actions"` } // ToSessionEvent maps Event data struct to session.Event @@ -62,17 +64,19 @@ func ToSessionEvent(event Event) *session.Event { Author: event.Author, LongRunningToolIDs: event.LongRunningToolIDs, LLMResponse: model.LLMResponse{ - AvgLogprobs: event.AvgLogprobs, - Content: event.Content, - GroundingMetadata: event.GroundingMetadata, - UsageMetadata: event.UsageMetadata, - Partial: event.Partial, - TurnComplete: event.TurnComplete, - Interrupted: event.Interrupted, - ErrorCode: event.ErrorCode, - ErrorMessage: event.ErrorMessage, - FinishReason: event.FinishReason, - ModelVersion: event.ModelVersion, + AvgLogprobs: event.AvgLogprobs, + Content: event.Content, + GroundingMetadata: event.GroundingMetadata, + UsageMetadata: event.UsageMetadata, + Partial: event.Partial, + TurnComplete: event.TurnComplete, + Interrupted: event.Interrupted, + ErrorCode: event.ErrorCode, + ErrorMessage: event.ErrorMessage, + FinishReason: event.FinishReason, + ModelVersion: event.ModelVersion, + InputTranscription: event.InputTranscription, + OutputTranscription: event.OutputTranscription, }, Actions: session.EventActions{ StateDelta: event.Actions.StateDelta, @@ -87,22 +91,24 @@ func ToSessionEvent(event Event) *session.Event { // FromSessionEvent maps session.Event to Event data struct func FromSessionEvent(event session.Event) Event { return Event{ - ID: event.ID, - InvocationID: event.InvocationID, - Branch: event.Branch, - Author: event.Author, - Partial: event.Partial, - LongRunningToolIDs: event.LongRunningToolIDs, - AvgLogprobs: event.LLMResponse.AvgLogprobs, - Content: event.LLMResponse.Content, - GroundingMetadata: event.LLMResponse.GroundingMetadata, - UsageMetadata: event.LLMResponse.UsageMetadata, - TurnComplete: event.LLMResponse.TurnComplete, - Interrupted: event.LLMResponse.Interrupted, - ErrorCode: event.LLMResponse.ErrorCode, - ErrorMessage: event.LLMResponse.ErrorMessage, - FinishReason: event.LLMResponse.FinishReason, - ModelVersion: event.LLMResponse.ModelVersion, + ID: event.ID, + InvocationID: event.InvocationID, + Branch: event.Branch, + Author: event.Author, + Partial: event.Partial, + LongRunningToolIDs: event.LongRunningToolIDs, + AvgLogprobs: event.LLMResponse.AvgLogprobs, + Content: event.LLMResponse.Content, + GroundingMetadata: event.LLMResponse.GroundingMetadata, + UsageMetadata: event.LLMResponse.UsageMetadata, + TurnComplete: event.LLMResponse.TurnComplete, + Interrupted: event.LLMResponse.Interrupted, + ErrorCode: event.LLMResponse.ErrorCode, + ErrorMessage: event.LLMResponse.ErrorMessage, + FinishReason: event.LLMResponse.FinishReason, + ModelVersion: event.LLMResponse.ModelVersion, + InputTranscription: event.LLMResponse.InputTranscription, + OutputTranscription: event.LLMResponse.OutputTranscription, Actions: EventActions{ StateDelta: event.Actions.StateDelta, ArtifactDelta: event.Actions.ArtifactDelta, diff --git a/server/adkrest/internal/models/runtime.go b/server/adkrest/internal/models/runtime.go index 59c82a552..e4888c84c 100644 --- a/server/adkrest/internal/models/runtime.go +++ b/server/adkrest/internal/models/runtime.go @@ -50,3 +50,18 @@ func (req RunAgentRequest) AssertRunAgentRequestRequired() error { return nil } + +// blob represents a genai.blob sent by the client, explicitly mapping mime_type. +type blob struct { + MIMEType string `json:"mime_type,omitempty"` + Data []byte `json:"data,omitempty"` +} + +// LiveRequest represents the client request format for real-time interactions over WebSocket. +type LiveRequest struct { + Content *genai.Content `json:"content,omitempty"` + Blob *blob `json:"blob,omitempty"` + ActivityStart *genai.ActivityStart `json:"activityStart,omitempty"` + ActivityEnd *genai.ActivityEnd `json:"activityEnd,omitempty"` + Close bool `json:"close,omitempty"` +} diff --git a/server/adkrest/internal/routers/runtime.go b/server/adkrest/internal/routers/runtime.go index 3819ce64a..1bb3f0a5d 100644 --- a/server/adkrest/internal/routers/runtime.go +++ b/server/adkrest/internal/routers/runtime.go @@ -45,5 +45,11 @@ func (r *RuntimeAPIRouter) Routes() Routes { Pattern: "/run_sse", HandlerFunc: r.runtimeController.RunSSEHandler, }, + Route{ + Name: "RunAgentLive", + Methods: []string{http.MethodGet, http.MethodOptions}, + Pattern: "/run_live", + HandlerFunc: controllers.NewErrorHandler(r.runtimeController.RunLiveHandler), + }, } } From 402259c640638af37aa3d61822782a6fd15b825a Mon Sep 17 00:00:00 2001 From: Karol Piotrowicz Date: Sat, 16 May 2026 18:07:31 +0000 Subject: [PATCH 41/86] chore: add Dependabot config for automated genai dependency updates (#843) Add file-based Dependabot configuration to enable weekly scheduled dependency updates for google.golang.org/genai. The config uses an allow-list approach so only the genai SDK is updated automatically, with the list easily extensible to more dependencies in the future. Configuration: - Ecosystem: gomod - Schedule: weekly on Mondays at 02:00 UTC - Scope: google.golang.org/genai only (via allow-list) - Reviewer: wolo-lab - Commit prefix: chore(deps) - Labels: dependencies --- .github/dependabot.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..a0544629c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "02:00" + commit-message: + prefix: "chore" + include: "scope" + labels: + - "dependencies" + reviewers: + - "wolo-lab" + allow: + - dependency-name: "google.golang.org/genai" + open-pull-requests-limit: 10 From 8fb171b6af36169b40611d817233b4b0093a8e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Mon, 18 May 2026 09:50:32 +0100 Subject: [PATCH 42/86] feat(live): Add sequential agent live run (#835) * feat(live): Add sequential agent live run * Change sample static folder to dynamic path * Add log to session close --- agent/workflowagents/sequentialagent/agent.go | 133 +++++++++- .../sequentialagent/agent_test.go | 230 ++++++++++++++++++ examples/bidi/sequential/main.go | 117 +++++++++ 3 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 examples/bidi/sequential/main.go diff --git a/agent/workflowagents/sequentialagent/agent.go b/agent/workflowagents/sequentialagent/agent.go index b307aa6b3..008d1a9b2 100644 --- a/agent/workflowagents/sequentialagent/agent.go +++ b/agent/workflowagents/sequentialagent/agent.go @@ -18,16 +18,30 @@ package sequentialagent import ( "fmt" "iter" + "log" + "sync" "google.golang.org/adk/agent" agentinternal "google.golang.org/adk/internal/agent" + "google.golang.org/adk/internal/llminternal" "google.golang.org/adk/session" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" ) // New creates a SequentialAgent. // // SequentialAgent executes its sub-agents once, in the order they are listed. -// +type seqAgent struct { + agent.Agent + *agentinternal.State + impl *sequentialAgent +} + +func (s *seqAgent) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + return s.impl.RunLive(ctx) +} + // Use the SequentialAgent when you want the execution to occur in a fixed, // strict order. func New(cfg Config) (agent.Agent, error) { @@ -51,7 +65,7 @@ func New(cfg Config) (agent.Agent, error) { state.AgentType = agentinternal.TypeSequentialAgent state.Config = cfg - return sequentialAgent, nil + return &seqAgent{Agent: sequentialAgent, State: state, impl: sequentialAgentImpl}, nil } // Config defines the configuration for a SequentialAgent. @@ -74,3 +88,118 @@ func (a *sequentialAgent) Run(ctx agent.InvocationContext) iter.Seq2[*session.Ev } } } + +type sequentialLiveSession struct { + mu sync.Mutex + activeSess agent.LiveSession + closed bool +} + +func (s *sequentialLiveSession) Send(req agent.LiveRequest) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return fmt.Errorf("session is closed") + } + if s.activeSess == nil { + return fmt.Errorf("no active sub-agent live session") + } + return s.activeSess.Send(req) +} + +func (s *sequentialLiveSession) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = true + if s.activeSess != nil { + return s.activeSess.Close() + } + return nil +} + +func (s *sequentialLiveSession) setActiveSession(sess agent.LiveSession) { + s.mu.Lock() + defer s.mu.Unlock() + s.activeSess = sess +} + +func (a *sequentialAgent) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + subAgents := ctx.Agent().SubAgents() + if len(subAgents) == 0 { + return nil, nil, fmt.Errorf("sequential agent has no sub-agents") + } + + // Inject task_completed tool into sub LLM agents + type taskCompletedArgs struct{} + type taskCompletedResults struct { + Result string `json:"result"` + } + + taskCompletedTool, err := functiontool.New(functiontool.Config{ + Name: "task_completed", + Description: "Signals that the agent has successfully completed the user's question or task.", + }, func(ctx tool.Context, args taskCompletedArgs) (taskCompletedResults, error) { + return taskCompletedResults{Result: "Task completion signaled."}, nil + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to create task_completed tool: %w", err) + } + + for _, subAgent := range subAgents { + if llmAgent, ok := subAgent.(llminternal.Agent); ok { + state := llminternal.Reveal(llmAgent) + hasTaskCompleted := false + for _, t := range state.Tools { + if t.Name() == "task_completed" { + hasTaskCompleted = true + break + } + } + if !hasTaskCompleted { + state.Tools = append(state.Tools, taskCompletedTool) + instructionSuffix := "\nIf you finished the user's request according to its description, call the task_completed function to exit so the next agents can take over. When calling this function, do not generate any text other than the function call." + state.Instruction += instructionSuffix + } + } + } + + seqSess := &sequentialLiveSession{} + + wrappedIter := func(yield func(*session.Event, error) bool) { + for _, subAgent := range subAgents { + liveAgent, ok := subAgent.(interface { + RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) + }) + if !ok { + if !yield(nil, fmt.Errorf("sub-agent %s does not support Live Run", subAgent.Name())) { + return + } + return + } + + subSess, innerIter, err := liveAgent.RunLive(ctx) + if err != nil { + if !yield(nil, fmt.Errorf("sub-agent %s RunLive failed: %w", subAgent.Name(), err)) { + return + } + return + } + + seqSess.setActiveSession(subSess) + + for ev, err := range innerIter { + if !yield(ev, err) { + if err := subSess.Close(); err != nil { + log.Printf("error closing sub-session: %v\n", err) + } + return + } + } + if err := subSess.Close(); err != nil { + log.Printf("error closing sub-session: %v\n", err) + } + } + } + + return seqSess, wrappedIter, nil +} diff --git a/agent/workflowagents/sequentialagent/agent_test.go b/agent/workflowagents/sequentialagent/agent_test.go index 59a805535..203ce15b8 100644 --- a/agent/workflowagents/sequentialagent/agent_test.go +++ b/agent/workflowagents/sequentialagent/agent_test.go @@ -27,6 +27,7 @@ import ( "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" "google.golang.org/adk/agent/workflowagents/sequentialagent" + "google.golang.org/adk/internal/llminternal" "google.golang.org/adk/model" "google.golang.org/adk/runner" "google.golang.org/adk/session" @@ -335,3 +336,232 @@ func (f *FakeLLM) GenerateContent(ctx context.Context, req *model.LLMRequest, st }, nil) } } + +type mockLiveAgent struct { + agent.Agent + runLiveFn func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) +} + +func (m *mockLiveAgent) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + return m.runLiveFn(ctx) +} + +type dummyLiveSession struct { + sendChan chan agent.LiveRequest + closed bool +} + +func (d *dummyLiveSession) Send(req agent.LiveRequest) error { + d.sendChan <- req + return nil +} + +func (d *dummyLiveSession) Close() error { + d.closed = true + return nil +} + +func mustAgent(a agent.Agent, err error) agent.Agent { + if err != nil { + panic(err) + } + return a +} + +type mockInvocationContext struct { + agent.InvocationContext + agent agent.Agent + invocationID string + ctx context.Context +} + +func (m *mockInvocationContext) Agent() agent.Agent { + return m.agent +} + +func (m *mockInvocationContext) InvocationID() string { + return m.invocationID +} + +func (m *mockInvocationContext) Context() context.Context { + return m.ctx +} + +func TestSequentialAgent_RunLive_Injection(t *testing.T) { + subAgent1 := newCustomAgent(t, 1) + subAgent2 := newCustomAgent(t, 2) + + sequentialAgent, err := sequentialagent.New(sequentialagent.Config{ + AgentConfig: agent.Config{ + Name: "seq_agent", + SubAgents: []agent.Agent{subAgent1, subAgent2}, + }, + }) + if err != nil { + t.Fatalf("failed to create sequential agent: %v", err) + } + + // Before RunLive, sub-agents do not have the task_completed tool + if llmAgent1, ok := subAgent1.(llminternal.Agent); ok { + state := llminternal.Reveal(llmAgent1) + for _, tool := range state.Tools { + if tool.Name() == "task_completed" { + t.Errorf("sub-agent 1 already has task_completed tool before RunLive") + } + } + } + + // Call RunLive (it will prepare/inject but will fail/return error when executing due to nil/mock context, + // which is perfectly fine since the injection happens beforehand). + // Let's pass a mock context that returns seqAgent as the Agent. + invCtx := &mockInvocationContext{ + agent: sequentialAgent, + invocationID: "test_id", + ctx: t.Context(), + } + + liveAgent, ok := sequentialAgent.(interface { + RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) + }) + if !ok { + t.Fatalf("sequential agent does not implement RunLive") + } + + _, _, _ = liveAgent.RunLive(invCtx) + + // After RunLive initiation, the sub-agents MUST have the task_completed tool injected! + if llmAgent1, ok := subAgent1.(llminternal.Agent); ok { + state := llminternal.Reveal(llmAgent1) + hasTaskCompleted := false + for _, tool := range state.Tools { + if tool.Name() == "task_completed" { + hasTaskCompleted = true + break + } + } + if !hasTaskCompleted { + t.Errorf("sub-agent 1 does not have task_completed tool injected after RunLive") + } + } +} + +func TestSequentialAgent_RunLive_SequentialOrchestration(t *testing.T) { + ctx := t.Context() + + sendChan1 := make(chan agent.LiveRequest, 10) + sendChan2 := make(chan agent.LiveRequest, 10) + + subSess1 := &dummyLiveSession{sendChan: sendChan1} + subSess2 := &dummyLiveSession{sendChan: sendChan2} + + agent1 := mustAgent(agent.New(agent.Config{Name: "sub_agent_1"})) + liveAgent1 := &mockLiveAgent{ + Agent: agent1, + runLiveFn: func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + iterFn := func(yield func(*session.Event, error) bool) { + ev := session.NewEvent(ctx.InvocationID()) + ev.Author = "sub_agent_1" + yield(ev, nil) + } + return subSess1, iterFn, nil + }, + } + + agent2 := mustAgent(agent.New(agent.Config{Name: "sub_agent_2"})) + liveAgent2 := &mockLiveAgent{ + Agent: agent2, + runLiveFn: func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + iterFn := func(yield func(*session.Event, error) bool) { + ev := session.NewEvent(ctx.InvocationID()) + ev.Author = "sub_agent_2" + yield(ev, nil) + } + return subSess2, iterFn, nil + }, + } + + seqAgent, err := sequentialagent.New(sequentialagent.Config{ + AgentConfig: agent.Config{ + Name: "seq_agent", + SubAgents: []agent.Agent{liveAgent1, liveAgent2}, + }, + }) + if err != nil { + t.Fatalf("failed to create sequential agent: %v", err) + } + + invCtx := &mockInvocationContext{ + agent: seqAgent, + invocationID: "test_inv_id", + ctx: ctx, + } + + liveAgent, ok := seqAgent.(interface { + RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) + }) + if !ok { + t.Fatalf("sequential agent does not implement RunLive") + } + + sess, seqIter, err := liveAgent.RunLive(invCtx) + if err != nil { + t.Fatalf("RunLive failed: %v", err) + } + + next, stop := iter.Pull2(seqIter) + defer stop() + + // Consume first sub-agent event + ev1, err1, ok := next() + if !ok || err1 != nil { + t.Fatalf("expected first event, got ok=%v, err=%v", ok, err1) + } + if ev1.Author != "sub_agent_1" { + t.Errorf("expected event from sub_agent_1, got %s", ev1.Author) + } + + // Now seqSess should route to subSess1 + req1 := agent.LiveRequest{Content: genai.NewContentFromText("to agent 1", "")} + if err := sess.Send(req1); err != nil { + t.Fatalf("failed to Send to sess: %v", err) + } + gotReq1 := <-sendChan1 + if gotReq1.Content.Parts[0].Text != "to agent 1" { + t.Errorf("expected request to subSess1, got: %v", gotReq1) + } + + // The subSess1 completes, transitioning to agent2 + ev2, err2, ok := next() + if !ok || err2 != nil { + t.Fatalf("expected second event, got ok=%v, err=%v", ok, err2) + } + if ev2.Author != "sub_agent_2" { + t.Errorf("expected event from sub_agent_2, got %s", ev2.Author) + } + + // Now seqSess should route to subSess2 + req2 := agent.LiveRequest{Content: genai.NewContentFromText("to agent 2", "")} + if err := sess.Send(req2); err != nil { + t.Fatalf("failed to Send to sess: %v", err) + } + gotReq2 := <-sendChan2 + if gotReq2.Content.Parts[0].Text != "to agent 2" { + t.Errorf("expected request to subSess2, got: %v", gotReq2) + } + + // Verify that subSess1 is closed + if !subSess1.closed { + t.Errorf("expected sub_agent_1 session to be closed after transition") + } + + // The subSess2 completes + _, _, ok = next() + if ok { + t.Errorf("expected iterator to be exhausted") + } + + // Verify subSess2 is closed + if !subSess2.closed { + t.Errorf("expected sub_agent_2 session to be closed at the end") + } +} diff --git a/examples/bidi/sequential/main.go b/examples/bidi/sequential/main.go new file mode 100644 index 000000000..af025ad66 --- /dev/null +++ b/examples/bidi/sequential/main.go @@ -0,0 +1,117 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main provides an example of using sequential agents with real-time bidirectional streaming. +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/agent/workflowagents/sequentialagent" + "google.golang.org/adk/model/gemini" + "google.golang.org/adk/runner" + "google.golang.org/adk/server/adkrest/controllers" + "google.golang.org/adk/session" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/geminitool" +) + +func main() { + log.SetOutput(os.Stdout) + ctx := context.Background() + + // gemini-3.1-flash-live-preview + // gemini-2.5-flash-native-audio-preview-12-2025 + model, err := gemini.NewModel(ctx, "gemini-2.5-flash-native-audio-preview-12-2025", &genai.ClientConfig{ + APIKey: os.Getenv("GOOGLE_API_KEY"), + }) + if err != nil { + log.Fatalf("Failed to create model: %v", err) + } + + ideaGenerator, err := llmagent.New(llmagent.Config{ + Name: "idea_generator", + Model: model, + Description: "Brainstorms creative story ideas with the user.", + Instruction: "You are the Idea Generator. Ask the user for a topic they are interested in, and brainstorm 3 creative story ideas for them. Discuss the options and help them choose their favorite idea. Once the user confirms their choice, call the task_completed function so the Story Teller agent can take over to narrate the story.", + Tools: []tool.Tool{ + geminitool.GoogleSearch{}, + }, + }) + if err != nil { + log.Fatalf("Failed to create idea generator agent: %v", err) + } + + storyTeller, err := llmagent.New(llmagent.Config{ + Name: "story_teller", + Model: model, + Description: "Narrates an engaging story based on the chosen idea.", + // Note: Sending content history is currently not implemented for the Live API. + // Therefore, the agent asks the user to remind them of the chosen idea instead of reviewing history. + Instruction: "You are the Story Teller. The previous agent has just finalized a story idea with the user. Greet the user, ask them to remind you of the chosen idea, and then narrate an exciting, highly engaging short story about it using your voice.", + Tools: []tool.Tool{ + geminitool.GoogleSearch{}, + }, + }) + if err != nil { + log.Fatalf("Failed to create story teller agent: %v", err) + } + + seqAgent, err := sequentialagent.New(sequentialagent.Config{ + AgentConfig: agent.Config{ + Name: "bidi-demo", + SubAgents: []agent.Agent{ideaGenerator, storyTeller}, + }, + }) + if err != nil { + log.Fatalf("Failed to create sequential agent: %v", err) + } + + uiMode := true + + if uiMode { + ss := session.InMemoryService() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + log.Fatal("No caller information") + } + staticDir := filepath.Join(filepath.Dir(filename), "../static") + fs := http.FileServer(http.Dir(staticDir)) + http.Handle("/", fs) + http.Handle("/static/", http.StripPrefix("/static/", fs)) + + controller := controllers.NewRuntimeAPIController(ss, nil, agent.NewSingleLoader(seqAgent), nil, 0, runner.PluginConfig{}, true) + + http.HandleFunc("/run_live", func(w http.ResponseWriter, req *http.Request) { + err := controller.RunLiveHandler(w, req) + if err != nil { + log.Printf("RunLiveHandler failed: %v", err) + } + }) + + fmt.Println("Serving UI on http://localhost:8081") + log.Fatal(http.ListenAndServe(":8081", nil)) + } +} From 5fb72b9a4dd8dbf70a3da9e5d44be3569832ae47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Mon, 18 May 2026 09:54:54 +0100 Subject: [PATCH 43/86] feat(live): Add live example (#834) * feat(live): Add live example * Add README, fix null element on querySelector --- examples/bidi/README.md | 114 ++ examples/bidi/assets/bidi-demo-screen.png | Bin 0 -> 823673 bytes examples/bidi/main.go | 110 ++ examples/bidi/static/css/style.css | 951 ++++++++++++ examples/bidi/static/index.html | 89 ++ examples/bidi/static/js/app.js | 1304 +++++++++++++++++ examples/bidi/static/js/audio-player.js | 24 + examples/bidi/static/js/audio-recorder.js | 58 + .../bidi/static/js/pcm-player-processor.js | 75 + .../bidi/static/js/pcm-recorder-processor.js | 18 + 10 files changed, 2743 insertions(+) create mode 100644 examples/bidi/README.md create mode 100644 examples/bidi/assets/bidi-demo-screen.png create mode 100644 examples/bidi/main.go create mode 100644 examples/bidi/static/css/style.css create mode 100644 examples/bidi/static/index.html create mode 100644 examples/bidi/static/js/app.js create mode 100644 examples/bidi/static/js/audio-player.js create mode 100644 examples/bidi/static/js/audio-recorder.js create mode 100644 examples/bidi/static/js/pcm-player-processor.js create mode 100644 examples/bidi/static/js/pcm-recorder-processor.js diff --git a/examples/bidi/README.md b/examples/bidi/README.md new file mode 100644 index 000000000..c53aecb42 --- /dev/null +++ b/examples/bidi/README.md @@ -0,0 +1,114 @@ +# ADK Go Bidirectional Streaming Demo + +This directory contains a working demonstration of real-time bidirectional streaming using the Agent Development Kit (ADK) for Go. It showcases how to set up an agent with tools and serve it over a WebSocket connection for real-time interaction. + +![bidi-demo-screen](assets/bidi-demo-screen.png) + +## Overview + +The example in `main.go` sets up: +1. An **LLM Agent** named `bidi-demo` using the `gemini-3.1-flash-live-preview` model. +2. **Tools**: The agent is equipped with Google Search and a custom `camera_toggle` function tool. +3. An **HTTP Server**: It serves a web interface and handles WebSocket connections for the bidirectional streaming API. + +## Features + +- **Bidirectional Streaming**: Real-time communication handled by ADK's `RuntimeAPIController`. +- **Function Calling**: Demonstrates how to register and use custom Go functions as tools. +- **Web UI**: A simple frontend to interact with the agent, located in the `static/` directory. + +## Architecture + +The application uses ADK's `RuntimeAPIController` to manage the bidirectional streaming session: + +``` +┌─────────────┐ ┌──────────────────────┐ ┌─────────────┐ +│ │ │ │ │ │ +│ WebSocket │────────▶│ RuntimeAPIController │────────▶│ Live API │ +│ Client │ │ (RunLiveHandler) │ │ Session │ +│ │◀────────│ │◀────────│ │ +└─────────────┘ └──────────────────────┘ └─────────────┘ +``` + +- **Upstream**: The client sends audio, text, or media messages over the WebSocket connection. +- **Downstream**: The controller streams model responses and events back to the client in real-time. + +## Prerequisites + +- **Go**: Ensure you have Go installed (Go 1.23+ recommended). +- **API Key**: You need a Google API Key to access the Gemini models. + +## Getting Started + +### 1. Set up your API Key + +Set the `GOOGLE_API_KEY` environment variable: + +```bash +export GOOGLE_API_KEY="your_api_key_here" +``` + +### 2. Run the Server + +You can run the server from the project root or directly from this directory: + +**From the project root:** +```bash +go run examples/bidi/main.go +``` + +**From the `examples/bidi` directory:** +```bash +go run main.go +``` + +The server will start and serve the UI on `http://localhost:8081`. + +### 3. Access the UI + +Open your browser and navigate to `http://localhost:8081`. You should see a chat interface where you can interact with the agent. + +## Project Structure + +- `main.go`: The main entry point that configures a simple agent and starts the server. +- `static/`: Contains the frontend files (HTML, CSS, JS) shared by all examples. +- `streaming_tool/`: Demonstrates a **streaming tool** that yields data over time (counting with delays) and how to handle the `stop_streaming` control signal. + - **Run**: `go run examples/bidi/streaming_tool/main.go` +- `sequential/`: Demonstrates a **Sequential Agent** flow where control is passed from an 'Idea Generator' agent to a 'Story Teller' agent. + - **Run**: `go run examples/bidi/sequential/main.go` + +## Code Overview + +The core setup in `main.go` involves: + +- Creating the model: + ```go + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-live-preview", &genai.ClientConfig{ + APIKey: os.Getenv("GOOGLE_API_KEY"), + }) + ``` +- Defining a custom tool: + ```go + cameraTool, err := functiontool.New(functiontool.Config{ + Name: "camera_toggle", + Description: "Turns the camera on or off.", + }, func(ctx tool.Context, args EmptyArgs) (MessageResult, error) { + // ... + }) + ``` +- Initializing the agent: + ```go + a, err := llmagent.New(llmagent.Config{ + Name: "bidi-demo", + Model: model, + Instruction: "You are a real-time voice assistant.", + Tools: []tool.Tool{geminitool.GoogleSearch{}, cameraTool}, + }) + ``` +- Serving the UI and the Live Handler: + ```go + controller := controllers.NewRuntimeAPIController(ss, nil, agent.NewSingleLoader(a), nil, 0, runner.PluginConfig{}, true) + http.HandleFunc("/run_live", func(w http.ResponseWriter, req *http.Request) { + controller.RunLiveHandler(w, req) + }) + ``` diff --git a/examples/bidi/assets/bidi-demo-screen.png b/examples/bidi/assets/bidi-demo-screen.png new file mode 100644 index 0000000000000000000000000000000000000000..31e2b79f32a742e97f4c190e9653967b0d6f68d6 GIT binary patch literal 823673 zcmeFY2T)Vd+BQn>5PA(H6h)N8>1s4%PGcXl%H8r84lx6>d;GN^*P>n!=6RxMiu&dIkm@Fpv( zRY;XuF_+|8CFNvLsMXKd1SaZAhFX-A`$uxE32RTICxn{wCQgYwJxLOh*eJz^SGbnc zPuWNiacb9ks6(zq*5k^9N?9u+&2_2xBkFWwX=|- zDjpsQ1i!lFjq^Sm@HyRmnO?@7-Nt+WP~1YVT-G6@wo6H_HPxJd!SCfk_m!B>q-vg3 z9)s4guPvS@QKYFp5l-^9?I&Sd(8o+G&M`obTdn zkLcS%Z)2GYxU-YBL0?3uSjl4q+rjw~ZXqXg)(FLVe9K0Bjc4M#V&gblHnTDffOd@L zZG`3T4()1VYn>2-643S3oP&z}a7*<&X49bKs0||?#|5fpQoqoB4-dFvjo9O_nnZZE~{8-OCmmbbE3k|km=nZ9z z@8YQ=m71d_Q@;{Jwg=|99X{jO;wwpB(+sKPP7RT*WGyF8v!NendeUs|L2^Be(}V1k zOfuxw9B&=DnIRW~G@ylVFcC~cn2IUQJ?J;>3`w>zrvpbGhFqY~<>ca3~Ad7XYs_ywEMqf4z45 z6SIU)dI5*ijc?ShYKFO7gHN4A@nqX=B=4eRWK4QV?d+_?>29^iO7nan5$pI_^`uQ+ z#Ex*5nwD&vsgxb5Z8v=T^6jm%nX2i-yhf|wbiC)GzE<0&EFFQ;8p8Vizu=!eEf9j@+k!8ul@`i^eg-60vozjjTJY=1$olKq1 zI_XTtT@Rv*Q$Coyt1&BnSZroyIi{^(?DxejZHMKL!R5|Rk1!cudpUZ^dpCOlcJZAa zoiUx_{3fs!Oh#>h-QnqhRzOj}$-&3<;6cZA>W$U4ieK)-O^zXa<2>!$y|Uvj9+SC_ z<^ARrRbf>{c7XT>*BIVOo=)EW8`Ey%4&#oGUD#bjZcN(v{3_|naNcsva;+c5mU!0E zwzYR@xm1rXW&H4dB^lTD)84ewpeo=F;~mZ09Pg%XPu)4`EAM0NecpTY`j1@ko0>kk z-my2eZ;l^im}{B$)|AxLx$0Lvwsf%gSPQYkjsBdx_G81cOsC9oG+-36sj;cN`Ca}x zA3XE5{D{1nYwQ=7Dd5zdFX>xGDrVoszhCy;-ZV{&PRe4!?+vA&@dZ5NyL_TdF+nLW7Z%#!s zCHPytOz!KR^X>0qE3CF2YDs23bo(Z2C2ZB?>*`BbTG_emUFNs>tk%QOdv*;O{-G_w zLCP-Y5RAYEiXS-bA$HeSUhn!h?*pW$D}WV$tOqVz7_#1(lvz)%eXTGXnCVvx=n?I6 zEHf;wD3zM6ojlIV`Id*%5#rvm~V?0qJTxv2#Sb5HtlB2W-)9oTRx2X7Tpg}<#;E+$#tLSNP1e* zigN}SsuQ789$Uxf%yTu?C7#T2rTp6~SlLTdx&$rVr}= zc)BkT7R}mc^(*y_L_S^q#b(0xlx_AhrN{?^jVuTfbNYkpCF9GEA9t=6rnaZPy&7yn zR$QL>D{bS=gO>rp?+=G3Oa|`AWxP_^?)Jg^oE?NaM|>*$^v4;5Gd_KSgeyx0+XYmF zh?z?v1P|Cgay*Nx*DljGl&hD!?+EER=(kFlc)Ofwnmr=C@?5N=qFS@M_Hk2R?9Yib zxune0?^20b>973E^Cf;HXT7n7*d4_)l{S3RnpFDjz4GmKL$&mTfs#P3S@F%OtJDZb=<4l?>M=WDXdBA68!5^^Vz2a^e zBCZD?{5BY5yVv?ryCv=krwP}p_}S;~QYYUb_^p?(u53`-ldfXL9b0L{3p8X!|Yd^a?rsY$K*Y7uWBniEUWx4c@{lh zJ~VACdvgEj2T3b})1CG=QJ>2{7d<_Hpb-4$t5-TkVzYFUV^c+}RqTztxr_PNc{}_2 z%b%~EzYpePRFM3GeU{?!d$|vpxmnBYdUXn8TZY)|b8VU8egENoPc2(5mkX*|*t6?3 zXdE`YbA8<6%l>c4A0PYQjK4AYyz}|wOkF6&8Dr-w;@{m)|mBwm< zP1B?Eeb8@YJrca7wyA#9g3l30O(J4EOFof0XZ#+V$`~1+NSz+`R6Ou_T4T1Km&1>m z8F@5$*H`Pf`+31|L5D7N0*An+ilpj{x6poj!RY!2KLZ1(|KYvf(xczD#RF%o2R%RJ zvgEq@19DuIR{eL-{zte@dN(fKNvkO)!Uldb?^*3pRNM|%rlh~slbwuV&x7jSWi~Ui zkEdQ~euu4}yPxupv3;DaPYx*9e}`VFvHV(V{YSFy56W@qVe6k?fp3m~?()r4ob?^m z2ETQCt1CbOaSG%WCe%P*LhlDv6NXQfw)X#o)`yZ_g?ECc>1@>eKm8z?8+b@E_>$D7*<0B6`9_eU zIrGTtXUw7J5vGpaEo6R@haQHY-{gJ@3MAuA;CI=v+N_U55$^uYt>3O$aHGY$$k%obF_SV`;tg7p4*%2JKVWLa*eo8 zLqb8yOF~K9BPD*JqL!}{`oyh?otM46o41p@kDgHO5%B=6hn|Hu2?^(= zi!Z7ET@f_#`s2>`%ze!7+*Y=8ca^k#?Ec7J65;A`aUBu}LYcVhYVTtUM!34Tc`GAS zh5nXMChlJxmJ$O0E#mW3Rml8~5m?jR%N{H*DJ>~21f>Im!4R*<4$616bpBbL_@Anf zlaG&wvXqpcpP!_koTR&#qm+!2l9H6Ptdy**1hIsKx4)Z@EkeT0TlgOr`PX%{?7i*0 zoIQM;-QB+Tmqm6Atb3>Tp z{YDK_7ng5j6Pd}QyW!0|Oc6PC`}0>0puymxxuhX--MCs6Nj3lteb3k1KS})6yR4 zWQp|ecXTw@VKOopS3weOvCaFI#gu4H#Yum1b!6W40_CORz0y#s)CHG-TImV=#Uy z^HlIFK_r+$kgPF$ToUYNK{u=JlD_eI5%EKC=$xu)>1jyZ@U{Gd$n_T>28uwSh*pC+ z8Lt%zo^9u%D))%`hi!~s>O`&AAfl0FJ~eYLbmyuj zYTkdl@8XY*ebHHO5z-gcIwqDx;pYJcDu{=|)q^bBGy)K;H1J!b=BEq>%TGzA!UXc; z{jp-6Zz}KANZBOQtT43rF;7JWz8m!vtpu#$0*05uC3{(W3iE~F ztm6@SBfO|X8?Nmj_{J23e$l#X|M#l8(G+NuCwTx*$G5GKnI(9fJ03L&z^pBP-Y&%$ zS08&FEOH6#rR30YAa^G!g9 zle+}&GtFo_0q?cc8g<$S?{$1>MTHefQ}lUguEhp|vOVJ4G7+qm>uU<(-J!|vi#-ZnF}^J(-ie7i-H%Qm$tkT=YpR(OHbwHrkp0eELamFPnEZk}!p6 zVA=)PNRf6HmrypUf3=lmGSm}TVnmU@lzuSBzXYjW{z*M!VUWX^JeZies>K~9(9EVZ z+)?QvX4M<0rf&|Ok12z`$<$>vB0IL<6Z1W>Sqcq5<#guvsrbK3K~SF?%( zo<9{&t+V{$ZyXnSySbKppuf6@WKzq6>SxhsI+!pAJ2?Q)^}_{&TP^~Uw{f~{_5$>; zM%yAm$EOsvN;ac>4tNM6rrIOD)>wGr>6=E!>~Gy4I;6-VgT8X#g<(nFT4MsJy=>Zp zOf8P=X%D9@9zG8k;_A^0zL%8Z{miPXFG&3STT|y*>6DN>{^?hP`#C}&9!&H-Wawxo z^}g+FVpX^9+vB2He4G^6W{V9|>>hx1LiHU5zl4vtFEv%c-y&aHvW+4nCij)I(n|?O zhRHz;Q9<0cO`Q|e10ykNw2=kTPVwH?Raf}>h1C7>a=b|qQ@ME+^gma zaN|9Pr{V}U+IEqP;7P8r^WbDwif>Ky>`1Z!EQN1Q4KBCQC+UQmmCW^*FF2j(73hT$uJolB)yV#XA)t8u|(dt8uz!Mjr(8 z?+;AhD@+Z(MJ{zJEuofYfg9iFjuQVK>VHjFTr<6krtyIzgnr>z%*qO1->nOZrPuL? zFZKdF^MXrm@x|@dKBzoCi3#8R{^B$m=UO2O+F(d5Im4d;Xdg`_H&GOgwcZRalJTST z+|@W(1T==v;@7@r?_3A%pDzq0DI14e?#lSq8u_#dh30?Bp9@Po#hP4G8zu$ikG|BS zN4;SUV=?S%>Wfh4U@ z{n;vY==QgOXqC=+-8vzfu3C!r3Eq$0ny>O0g3j+8yWaWfe*X?#^H&4AA$Ny&VfU@6BF_vtK%SOVmcRLzO^MJ92gE27sal?_h7@V zWz3pU#K9IZTjx>KYe)eR?DvnDb5~>4rmqhNCb7GnEk%izE%r|{<5_@|4lu~fl_ilN z!-N-$%rE}_ybS!+C*kgSE8d50{+@xKvlib2CXV-3fIg$JMj$S7bKI8+68|BfMq=x;hm3cH$>rT14KL{j?q8P(SWTg zQ_Bvk3a!90i=U2~;f?jKMXy##_7?CJx}aq+aU#_riIq@+&a=JmG1S{B z8(=})xk>3l%OZ3y?&eWxB4DMs@9X`Fp~j#yaIya->&0TpVLQ^IDyQFZ0ggBWO!CVC zXkN*grci-T(26xotfY-Ak;)kvy;#1ckcy65mKk8`PGm`3TJAcJH^~*e`GWRySdc<< zPV7TkEp0!KIj(<9{d?Ta#A&kRVw!|wZrD(NT{1sIlb-3|9^GSotQ5qu{k83Wos+y( z^!K8(N1gyfio+mP=|%}dszXZ6{6~4*(V{_W$F7p1lyg?awrdJjCt_>U_!foq8$(& z5Rm05jr$#4F0Xpn9otPv^T*QcWu6O8LktcoBA5K*mx>}*6ZfwG4gy7*xWI>Z`@b}4 z!-ot(OZ0aM4Y9k%?BZj$gpb-9RXg_R@rbXjNJ=rw?n$cZtgBe2N1LE4ozZ$=&I>RD zQ+v!pH=})rouD&ox@N#_)x`Ea_NFAG5ty3khszrUz~dkmDr5vrD>qbQbVi88(G~au ze9BBuePH`E=eE}i!O;bBFqhpDHS{A z$Y4$3rRXPuI={c5PC_u$oPqeJqe8lcu0!&mp9JQH(|ftPp~wa>4d(Lg*qg)UyBSgk zIT?#o?Qd7(-37y>!-xTfSRTw?ANOYM0JQt?XglsH47Lh`HP#mczXy8k6UtUUnj}aY ze0^p_iNCilio4<^CK)*TdwKOcWK^)Pt-vlph7R2aJABnDA`%=EY=KUt9qMK{({WkX zJr;HE^G*=GC%{(oB>R&mJ&M9FLKPf?UZR3^vT26Fl0YBbHPb-^9r6~jdmfJROSR4q zJwFMTH^JVXYk%M%MSY5b(3-g>v05(3b`DU(4^8Oo3LF&mNQez4j{fctsd)o1sCmCA zWQgC}=bXCDZwn7pKkcT;#J@Z7;;6fIy4_{k+nV@KW&M;3OAGLZ28t`HC;8pG_=d2y|gbWj!8B`dcHCm zV1rlUxdl`<4kK3-MstF(aw1Lq48cvG3=ddv#8`azk|gkLE2AIA9IGwy?VPTZe(x1N z4mKEmV7YWZ9ey|_zVj0~#W>mntzGb6|RyQ#Ia z*z6Mg?yGi{(zBEp@`}#KoX$HS+?Oenn0MW^She`-!>?;Oelx3ml4UfSoAjO878_3N z5e2ZVCna%q_s=zf8#nV@FiN@>az(A>`QB>=Z&E4GL={=1z=}1eTRGqvIluUjcVID!#P$ z`qYfSvm@4zm=9?@ooteGM%kIJo&Wj};r~&w^2M!*b{9p%M~N=SL{h05BI@<%p@z!5 z+T>bOo#ClK*uKzyQkMc?O6OJMDXJru?Uyk!M{7j-of%=|Ts7hxnK6|j|6$U^P0Y=> zWYnY%6P&agYBQexUh>zsc01hr(;tBxrSzNgQD&I0f=wgJb-wvWZNF2DBCBDivVcU& zgIO-}u}9v_CsLD0`R?91@$b#_CMPj&u$fi!Et}aXG|=qq)_$F%Y7ng-Mv<@m#Da_WSBd-xxxhIhZ;C#D&#IJPt9%vPGwbPwAOh&;J=v@E?Lt z!~GlUx{%(K#yl9v^ydOH`1PjSIrC`ZuIw*e=UWa% zCNBAbs*4+nTLwf0Hq*XhuF{P(Hm$Ud9#5G2W!+#7eo-176{LlwNp4Lc<$Bb~d`h=a z#*$DPPw|L?$Z5|=ie*3Nt~H^deFRRXO7knD1n`T)B$0T^`tWJcsn>wYN-3!0?{zpGmi^3l`xe9V|av&*}dlq`~8Vr|TqeQEssQq)#J$;Ua57Ymc zqzN8R?VeCk;)aEXGm!Vf)AYC8}`w$W)2jOR@p|%7f{N zb*@VU_Qic`ULn&tRmOADU)H6_J;KjAeEclEL_rY8l+E(HcSzvQ>h1!T^eZ4(OiQ1> zic_}lqU+3{(_@bZB&+}@>L-H1zo&W0 z_gi$TAA>4rCKKg9D<*3`TvNbzAM-83W3B;P;4$^NN-}dCH)}_Xaf7IWZ!cF2e}QN< ziEpD6c*lZ{h;N@P#4f%om}L;kSu3r$ZZnlj2{BV`e?{c;Xfbuxsb-;Mm1!cn($GJR z$>l#i!i;w(lv_7>B8$(yeGW-15&*h$hU`zpr-=r2G4dE+#tPR7#Ww?J9?%+&3*5&T z02k&WyZxc{uoCHV%XNich1nj_DbCqX9Mei`0a=9D3Q-IzUh3>Mb zhJ5AE&>XOEfKeCKdxv&s@E=B21#p+fd40m7Be)u|J@YqJ&Rl1{4m2Kz!rKwREyMRI z8@i3(OD22n1Y{p=cwPcw>RD0wnfN65l86<*rS>08&>E~?;jGgBxhcANc^c^jjW-&r z^?`v?913S>lVAVU}hxwwff~!k$`PY{>d1(Nv6hdg{9E5 zsiEaOWc7IxSm5!Ys{AZmX|>%i>=gF9Uiu;3k@OTAR!>wzy7$reaq!s(J(a74xJW;ldiSZ_32bAUDAJGx z4Zx_K%YQqGNUZ@}dP$0l;Q!@R{+nwITb%2RY?pCRJ*BbB-H4RimraJsLaP6WwhFLE05zJ^jp;wkYT&j*4T7~vRaxUb zv3#C)V^&DtK`vhiX|mWSU;H^cajpX|C=S2g|En%fIQO`GLKigfD>@#g;2ss+hFNu^ zNV{8Je=X|qxEa5n7LR;sIHbN6txK`$iI=rft4Cs_dxKwMMBS5KM4@c2z`sN*BvWbK z2#WB9rXf49CTIT2?9yoxho(S7$T45=E2HPzWS4ZGg}RKcC+=^IdVZ2R3!|6X)O;uj zKEw^HzBMR56a;O}3yWjcM`=-+*b$-;V&GD$+otf=bI4s(m6U3bbW_jaH4F}-0D;$c z&vA4@E#A+rAUlF6j#BzbWg>5_YGm|5D(G?2ZCvd#U3_Koh%G7cE!@=z0naSjv4ho+ zLoQvsj7p(FJfdF_;VS^6HBn!n35t2}RKrOT(Z05!FegSa+Y*Z5SNO{4C5+m_*$Om9 zh>~51FEn2f(W1lDfuv5MBjixE3)PRo6RrJe)!<89Pv#~~@GzX1debr=7-L-QZs7>x ziHo$-daqz9lhaA$jV|j&ED?R)5B)QE_R&l)<=y5J!MHgfao!N1X_t_kGh>F|Q4%E% z2@RJcuHX^PW#d<&pWYlk!c;KZwucizG-G@G)e8aPZUapO%Y`mQYGD6_bQJ+m>Uw=% zaNS{Qag}_4cK<}O(f@|hG4=A}qX|gbvKWC|F|BRiJm)%qi*v`5&(U~Y#xfU`(EejK zrWsZ43Iu=cxi9%4`Axgy4vXUya{SZv8&o!kmK6p+cju zjzmI7c-v-m7Y}u5BCh7erA2l@oyYZ&eXF5R(uR;t!>8 z;N;QGLTiQ7J8u8Z_B&SPVE7YU4zFF>W70xRGajZB2ubzCH%It-} zoe8^850&YB96k{_@ZZFV@G5eMO=sfuxtKv#$XAjz{(OAjuT&>XYWLJguiM6r)z1%$ zbNdS%&1U%~k7*Z9W*udbq@{5qw~#`jwspF&9RKbv_R`8zC}-r7nYVKM5OhEeb?98Z z-zeQ#)Ym&q%HcRISUM<;)=diXV7f@aFzaz!wGxFM21B4JleLZ&^uQ81*EkUPr8N3+ zH{{eXDTs!OSPnq3$jNmPFjK;XspeH;w!cdk7DE>3U!vg9)t|zdMTLj~**QP9GjUAg zaxeqP7AbYdFRec9aS8ky$bPEJ$iU5$X{aH^@S_J|*WNpOiv>5+vG=>9B81(C+|%F< z+HB4&>~7?sz(s0lGt&07U=4mgiYn=7W|fzxpV&*u{IFv+qk&(f=GH5H(+f-iEQ4x0)FvriOlNUZA6KYI;pZQ zN~(bKLC%lRkz(n>^LHmYXd+}C5C!zRGr1rlf4045#O1te5Z;Q45(O$<^k-1hos-_x zMWqB(%rGUbDO}$@A4U8I-v?6Qo{n`nC25N_{JRc8(ItXW4uH32@@A&Dy*A1il0&kO#c=TN>(W7;`P+dYd?}buJ3asi26m%T{hyxw1ViT9n~$%vF(mdJ_13#bx?#jb06dq3JC!e;Lb6?&%2D* zcB@snI?$_w$5Qs!_m8L;5`aS7yBd z+kM~>rMQM&{{@EKq@j(MA!ZI;m%4@@D!x?53UV^tMKLn&+&xRm5z4865*f`x?zrcl zWBQ%%s;CDE`p_j~!6I$NQxe+53v&6N8AL?Tk4AI8U4yF7LJ*do%~r`UzY#sO?1F zzUXWrds26@djG`ylEYc5+Q`Rv*mFtH)x4d}x;@3KLlc`xc29Wt4u_Hm9w1E2qt2%Z zHwupTww(9aUUYi(D|E6IS4yOMK1dYKL)knpZ90jO7HYkKaXWQ+0s@{rib1r76yi!q z2NthACc_dy58xf$(fWYdc@nqKd|Y>Eu#>fFdSeqrw3AbE#h7-2&ck)@3xo%Iwg|QZ zW>-ph-nWLKxVj9RtwM^9KM6I7AM3L+pPygfPL`GjhGxs1z1Tw;Zw4+n3Ok1vHe-wydfcWgF(1ZY(D9XI+Kp&$Dp~l z%Oj-gzmI{m(cII56r$)L^{t08U%Bnphk70^M5N1E0=RzZeCpvHV5*+UEgaCid@CTX z@Z2GIhO5!ZC}T!SIk=(3Qe)Z1^e&m`w*HLc8YY?bs3>t-jOncM4v2%0wxM<=H~oIB z4VTnqqSwHB4a5g?OZgrpB}#J{;eYT6dT>YAGjL1XN|x{$(J@=0mker5IdT2Mm@VJDDSy%t^l>(nr>wZ zH9iwjz}&jXN{l9WWWnycm~hoB--;aA;#cO8UExns9A>k74aq)D{&mlmaNTy3Yx{bp zr&!hcvzd0-?e{X)i6Zr9&<-q#DHH|tWRNJJv-zGIQMSpu$nkMWQ(#W5DQMPYRT~CB z{28nb>yzOsG?V&J3Ho-jix|%V0uo;GuGdy(ziocd?G<$a&0cuiwijU*zR)ZH%oY)0 ze#UGy)Q6k2|4s*DNRofP^WPZw#RQKC1qmj|L=iDtElslppROzoti6>qczm+>{h;gB zU2X{sZpoHl%1&aPm#R`DC~7?23TolEwN6^4CNpeh;}j`hm2YIVSqu5$2M#Wx!W^5S z`n?C{Jogjf3o>%>Mw0}7z4^Htose!vS_J`7*QVvGESy{yBFML!Os*<(QA=%8L_q@M z-xB=lTVa5HEuG%fa+6;~xxdYWTtQzFk&e3{r2r3+ zvH+W+4-2xlsm;YxSAjt=y{D>6pGZUmlE|Y;GFR3^#kSGD^sf?}=XhpV>$WMBdRvKf zV_ht%5tHTlD0uJsUOpg2V=26;Bz(!R_q~605#wAU5u-H?#8O8;6Rw9h`7y({u6%sY4%ZXzotkM&IiezAiW?6=P_K)*hD^kAP1&5Z zvUct+3Ic>Mf#wLlSY(KZ2PN$$-%O_=5OATtFuX{$Yl|*fz8__vY40wD^|5c_#dvnH zUuabm?zh84Lo0(xg+NzJ2d>-Tp0;2qn>Vjjw2j>*n4hx$eQ6a3qfV&qelhCGhg3;A zL%*Fb@SfK#ccsgrff*-lz^P)$8A$ubXGmIVN2qB0?vzwNf_(W_&(-}LaG=ICbTydo zKKA&-Dl`SZYl0b~@5a&zy?PPlK6i{N(YDEI{$3L6DcWsvZ!L%6Q9YeLyD^%{Yp}YY z(|*dL@w&m9E?R5+g$7OHEh3ib`JsuD5N4W*`N-UNc6TA(jL=z!x%MZ?A&4Ol6;30_ z`@eSUkgpzGTZsGC3J)c+ZuL0^>Q=*PoCo>w{#A-fRwCsPq9Bq<^1ozq*6%OlrU(=^ zU|j{SHh=8*UY3(_?}t5z5({%%gSDmz|F?Pe|Bh2^yCJdo*RD2#S{-1uGwzJ15bZ-0kLM+URQV<8QpjoT~d4*L~c?jaTG`V04 zp43uN;(fF|+{A``4%p_Fr;D!`1LL&RuMaYR=B(j-Ic4}ums#inR+25eDk2X0XQkFE z3F$yqf;;+FBUPQ=2cetrj2Uc;T4k5I#&FyvVv$#sXkjbb_(e;y1s6uMCBYses>asMo#8N+gZX){#QPohX3xi)EwE#I{${vizw?8Zz*nkH24@-K)YL|aBH%X5t4#U@|j2D#_-COn56~~mCU)(w; zYVxRz#tMf_N89O51QHLSd2-UGER3;$+ZH@@N|9RNT?&-ag(2rz zV7OAF#U}47u#IUA=P!~;9+PRRHJ&t^ZvSD`*+OnzYRA&v=ap0`sV8I&D$<0y=f(F_ z&&9b${!6SGWIKjJ=DDMZZL;}2u zJsU;94+(Ga9b|=pT}d_SrEdNq$}+NxS11X=VSBuLXCy}ya!_pvG~-Xk6iA&CGV!d#YQk2!_A2o_3DyYm2;k?o`)n*YYdiuZ3<-p1L=n?RE||z?gFfBmYDs z@Xqh!UK6gbpRwAxNxQ2;2e=OU!*Iah`lSj#FttvvS(-ikl~H8nH}=DPsaMJP67YSZ z|Dx`7;xhbr?8AxHEO4x>y9-}AH>o7qcmtXN@CPnxH68wS8)AwK8xdPM*LU%;dzSd; ztM*YTr|*$==kiBozYV>fwr=?g?UF_B%YFup#%cMrCIN(>{@br(23$Ls$XL}XZ;Wxz z9T<>$-FI^6y`$QytB8Ki^Y1e zdH2ynkPz9*1K6x4KUhr$p>9&fD9>pQKVWs9H#E`s8}(etJ=@rcv2ht3N=$#|7LeS! z_KHi8iyWa5>;yU>coLN_q5)Kw;HW3`z;S@z3PIE{+V~fV@sB0qe~5oXhWzCs_+7q8 zWVpQJlC4F|E^}&;^v@9yM|SSSlT4}BU-Co%-9_q3J#Z}C&$zQH5d3Tn zNxcHW1g8c1)mM}mo&FKFaZ$OX7RY2w*=<_zz)%o32ZFW+2X8t5v?M>6Dz4DWzzKHo z97bej=4R%7K6%Q?JZeWED`ZL12>oKziN(#8A#d9HgP#1qY}_%LPDv%f85g@lk@Szh zwpi`nvWMEe6^y~%`5x^l6&-tZ%l_$9sPFJwZ_Hmh(noEJ zQ8t*43#=X~UMHCF*JC23g{N~yQ!dT+czyVP+P*^+(Rr-rVCVQ_6!h(Vmw2d|Rq|

DV=aNXF-{ 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≫UOW%CVfVi`gag2(^r%R=4-%(@@_Tqd_owQ-)0 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≺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 Y%hDN&`*!~FSHO>z`MFaCzqs80FN!b^82|tP literal 0 HcmV?d00001 diff --git a/examples/bidi/main.go b/examples/bidi/main.go new file mode 100644 index 000000000..3710164a2 --- /dev/null +++ b/examples/bidi/main.go @@ -0,0 +1,110 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package provides a quickstart ADK agent. +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/model/gemini" + "google.golang.org/adk/runner" + "google.golang.org/adk/server/adkrest/controllers" + "google.golang.org/adk/session" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" + "google.golang.org/adk/tool/geminitool" +) + +func main() { + log.SetOutput(os.Stdout) + ctx := context.Background() + + // gemini-3.1-flash-live-preview + // gemini-2.5-flash-native-audio-preview-12-2025 + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-live-preview", &genai.ClientConfig{ + APIKey: os.Getenv("GOOGLE_API_KEY"), + }) + if err != nil { + log.Fatalf("Failed to create model: %v", err) + } + + type EmptyArgs struct{} + type MessageResult struct { + Message string `json:"message"` + } + + cameraTool, err := functiontool.New(functiontool.Config{ + Name: "camera_toggle", + Description: "Turns the camera on or off.", + }, func(ctx tool.Context, args EmptyArgs) (MessageResult, error) { + fmt.Println("Camera tool was called!") + return MessageResult{Message: "Camera tool called successfully!"}, nil + }) + if err != nil { + log.Fatalf("Failed to create camera tool: %v", err) + } + + a, err := llmagent.New(llmagent.Config{ + Name: "bidi-demo", + Model: model, + Description: "Agent optimized for real-time bidirectional streaming.", + Instruction: "You are a real-time voice assistant.", + Tools: []tool.Tool{ + geminitool.GoogleSearch{}, + cameraTool, + }, + }) + if err != nil { + log.Fatalf("Failed to create agent: %v", err) + } + + uiMode := true + + if uiMode { + // Create runner + ss := session.InMemoryService() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + log.Fatal("No caller information") + } + staticDir := filepath.Join(filepath.Dir(filename), "static") + fs := http.FileServer(http.Dir(staticDir)) + http.Handle("/", fs) + http.Handle("/static/", http.StripPrefix("/static/", fs)) + + controller := controllers.NewRuntimeAPIController(ss, nil, agent.NewSingleLoader(a), nil, 0, runner.PluginConfig{}, true) + + http.HandleFunc("/run_live", func(w http.ResponseWriter, req *http.Request) { + err := controller.RunLiveHandler(w, req) + if err != nil { + log.Printf("RunLiveHandler failed: %v", err) + } + }) + + fmt.Println("Serving UI on http://localhost:8081") + log.Fatal(http.ListenAndServe(":8081", nil)) + } +} diff --git a/examples/bidi/static/css/style.css b/examples/bidi/static/css/style.css new file mode 100644 index 000000000..4105b0739 --- /dev/null +++ b/examples/bidi/static/css/style.css @@ -0,0 +1,951 @@ +/** + * Modern Chat UI Styles for ADK Streaming Demo + */ + +:root { + --primary-color: #4285f4; + --user-bubble-bg: #4285f4; + --agent-bubble-bg: #f1f3f4; + --user-text-color: #ffffff; + --agent-text-color: #202124; + --bg-color: #ffffff; + --border-color: #e0e0e0; + --input-bg: #f8f9fa; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg-color); + color: #202124; + line-height: 1.6; + height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header */ +header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 1.5rem 2rem; + box-shadow: var(--shadow-lg); + position: relative; +} + +h1 { + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +.subtitle { + font-size: 0.875rem; + opacity: 0.9; + margin-top: 0.25rem; +} + +/* Header Options (Proactivity, Affective Dialog checkboxes) */ +.header-options { + display: flex; + align-items: center; + gap: 1.25rem; + margin-top: 0.75rem; +} + +.header-checkbox { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + cursor: pointer; + user-select: none; + opacity: 0.9; + transition: opacity 0.2s ease; +} + +.header-checkbox:hover { + opacity: 1; +} + +.header-checkbox input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: #ffffff; +} + +.header-checkbox span { + white-space: nowrap; +} + +.connection-status { + position: absolute; + top: 1.5rem; + right: 2rem; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #34a853; +} + +.status-indicator.disconnected { + background-color: #ea4335; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Main Layout: Split view for chat and console */ +.main-layout { + flex: 1; + display: flex; + max-width: 1800px; + width: 100%; + margin: 0 auto; + overflow: hidden; + gap: 0; +} + +/* Main Container: Chat area (2/3 of the layout) */ +.container { + flex: 2; + display: flex; + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--border-color); +} + +/* Messages Area */ +#messages { + flex: 1; + overflow-y: auto; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1rem; + background: linear-gradient(to bottom, #f8f9fa 0%, #ffffff 100%); +} + +/* Scroll styling */ +#messages::-webkit-scrollbar { + width: 8px; +} + +#messages::-webkit-scrollbar-track { + background: transparent; +} + +#messages::-webkit-scrollbar-thumb { + background: #dadce0; + border-radius: 4px; +} + +#messages::-webkit-scrollbar-thumb:hover { + background: #bdc1c6; +} + +/* Message Bubbles */ +.message { + display: flex; + margin-bottom: 0.5rem; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message.user { + justify-content: flex-end; +} + +.message.agent { + justify-content: flex-start; +} + +.bubble { + max-width: 70%; + padding: 0.75rem 1rem; + border-radius: 1.25rem; + word-wrap: break-word; + box-shadow: var(--shadow); + position: relative; +} + +.message.user .bubble { + background-color: var(--user-bubble-bg); + color: var(--user-text-color); + border-bottom-right-radius: 0.25rem; +} + +.message.agent .bubble { + background-color: var(--agent-bubble-bg); + color: var(--agent-text-color); + border-bottom-left-radius: 0.25rem; +} + +.bubble-text { + margin: 0; + line-height: 1.5; +} + +/* Interrupted message styling */ +.message.interrupted .bubble { + opacity: 0.6; + background-color: #e8eaed; + border-left: 3px solid #f4b400; +} + +.message.interrupted .bubble::after { + content: 'interrupted'; + display: block; + font-size: 0.75rem; + color: #5f6368; + font-style: italic; + margin-top: 0.25rem; +} + +/* Transcription message styling */ +.message.transcription.user .bubble { + opacity: 0.9; + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.message.transcription.user .bubble::before { + content: '🎤'; + opacity: 0.8; + margin-right: 0.25rem; +} + +/* Typing indicator */ +.typing-indicator { + display: inline-block; + margin-left: 0.25rem; + color: #5f6368; +} + +.typing-indicator::after { + content: '...'; + animation: ellipsis 1.5s infinite; +} + +@keyframes ellipsis { + 0%, 20% { + content: '.'; + } + 40% { + content: '..'; + } + 60%, 100% { + content: '...'; + } +} + +/* Image bubble styling */ +.bubble.image-bubble { + padding: 0.25rem; + max-width: 80%; +} + +.bubble-image { + max-width: 100%; + max-height: 300px; + width: auto; + height: auto; + border-radius: 0.75rem; + display: block; + object-fit: contain; +} + +/* Input Form */ +.input-container { + border-top: 1px solid var(--border-color); + background-color: white; + padding: 1.5rem 2rem; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05); +} + +#messageForm { + display: flex; + gap: 1rem; + align-items: center; +} + +.input-wrapper { + flex: 1; + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +#message { + flex: 1; + padding: 0.875rem 1rem; + border: 1px solid var(--border-color); + border-radius: 1.5rem; + font-size: 1rem; + font-family: inherit; + background-color: var(--input-bg); + transition: all 0.2s ease; + outline: none; +} + +#message:focus { + border-color: var(--primary-color); + background-color: white; + box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1); +} + +/* Buttons */ +button { + padding: 0.875rem 1.5rem; + border: none; + border-radius: 1.5rem; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + white-space: nowrap; +} + +#sendButton { + background-color: var(--primary-color); + color: white; +} + +#sendButton:hover:not(:disabled) { + background-color: #3367d6; + box-shadow: var(--shadow); + transform: translateY(-1px); +} + +#sendButton:disabled { + background-color: #dadce0; + color: #80868b; + cursor: not-allowed; + opacity: 0.6; +} + +#startAudioButton { + background-color: #34a853; + color: white; +} + +#startAudioButton:hover:not(:disabled) { + background-color: #2d8e47; + box-shadow: var(--shadow); + transform: translateY(-1px); +} + +#startAudioButton:disabled { + background-color: #dadce0; + color: #80868b; + cursor: not-allowed; + opacity: 0.6; +} + +#cameraButton { + background-color: #ea4335; + color: white; +} + +#cameraButton:hover:not(:disabled) { + background-color: #d33426; + box-shadow: var(--shadow); + transform: translateY(-1px); +} + +#cameraButton:disabled { + background-color: #dadce0; + color: #80868b; + cursor: not-allowed; + opacity: 0.6; +} + +#sendFileButton { + background-color: #a142f4; + color: white; +} + +#sendFileButton:hover:not(:disabled) { + background-color: #8b14f4; + box-shadow: var(--shadow); + transform: translateY(-1px); +} + +#sendFileButton:disabled { + background-color: #dadce0; + color: #80868b; + cursor: not-allowed; + opacity: 0.6; +} + +/* System Messages */ +.system-message { + text-align: center; + color: #5f6368; + font-size: 0.875rem; + padding: 0.5rem; + margin: 1rem 0; + font-style: italic; +} + +/* Console Panel (1/3 of the layout) */ +.console-panel { + flex: 1; + display: flex; + flex-direction: column; + background-color: #1e1e1e; + color: #d4d4d4; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + overflow: hidden; +} + +.console-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background-color: #2d2d2d; + border-bottom: 1px solid #3e3e3e; +} + +.console-header h2 { + font-size: 0.875rem; + font-weight: 600; + margin: 0; + color: #cccccc; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.console-controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.console-checkbox { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: #999999; + cursor: pointer; + user-select: none; +} + +.console-checkbox input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: #4285f4; +} + +.console-checkbox span { + white-space: nowrap; +} + +.console-clear-btn { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + background-color: #3e3e3e; + color: #cccccc; + border: 1px solid #4e4e4e; + border-radius: 0.25rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.console-clear-btn:hover { + background-color: #4e4e4e; + border-color: #5e5e5e; +} + +.console-content { + flex: 1; + overflow-y: auto; + padding: 0.75rem; + font-size: 0.75rem; + line-height: 1.5; +} + +/* Console entry */ +.console-entry { + margin-bottom: 0.75rem; + padding: 0.5rem; + border-left: 3px solid transparent; + background-color: rgba(255, 255, 255, 0.06); + border-radius: 0.25rem; + transition: background-color 0.2s ease; +} + +.console-entry.outgoing { + border-left-color: #4285f4; +} + +.console-entry.incoming { + border-left-color: #34a853; +} + +.console-entry.error { + border-left-color: #ea4335; + background-color: rgba(234, 67, 53, 0.15); +} + +/* Expandable console entries */ +.console-entry.expandable { + cursor: pointer; +} + +.console-entry.expandable:hover { + background-color: rgba(255, 255, 255, 0.10); +} + +.console-entry.expanded { + background-color: rgba(255, 255, 255, 0.08); +} + +.console-entry-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.375rem; +} + +.console-entry-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.console-entry-emoji { + font-size: 0.9rem; + line-height: 1; + display: inline-block; + user-select: none; + min-width: 16px; + text-align: center; +} + +.console-expand-icon { + font-size: 0.6rem; + color: #858585; + width: 12px; + display: inline-block; + transition: transform 0.2s ease; + user-select: none; +} + +.console-entry-type { + font-weight: 600; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.console-entry.outgoing .console-entry-type { + color: #4285f4; +} + +.console-entry.incoming .console-entry-type { + color: #34a853; +} + +.console-entry.error .console-entry-type { + color: #ea4335; +} + +.console-entry-author { + font-size: 0.65rem; + font-weight: 500; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + text-transform: lowercase; + letter-spacing: 0.3px; + border: 1px solid; +} + +/* Default style (for agents) */ +.console-entry-author { + background-color: rgba(156, 220, 254, 0.15); + color: #9cdcfe; + border-color: rgba(156, 220, 254, 0.3); +} + +/* User author badge */ +.console-entry-author[data-author="user"] { + background-color: rgba(66, 133, 244, 0.2); + color: #80b3ff; + border-color: rgba(66, 133, 244, 0.4); +} + +/* System author badge */ +.console-entry-author[data-author="system"] { + background-color: rgba(133, 133, 133, 0.2); + color: #b0b0b0; + border-color: rgba(133, 133, 133, 0.3); +} + +.console-entry-timestamp { + color: #858585; + font-size: 0.65rem; +} + +.console-entry-content { + color: #d4d4d4; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.7rem; + line-height: 1.4; + padding-left: 2.5rem; /* Indent to align with header after emoji and icon */ +} + +.console-entry-content:empty { + display: none; +} + +/* Style for quoted text in summaries (transcriptions, text responses) */ +.console-entry-content::first-line { + color: #e0e0e0; +} + +.console-entry-json { + background-color: #252526; + padding: 0.5rem; + border-radius: 0.25rem; + margin-top: 0.5rem; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; + transition: all 0.3s ease; +} + +.console-entry-json.collapsed { + display: none; +} + +.console-entry-json pre { + margin: 0; + color: #9cdcfe; +} + +/* Highlight key fields in JSON */ +.json-key { + color: #9cdcfe; +} + +.json-string { + color: #ce9178; +} + +.json-number { + color: #b5cea8; +} + +.json-boolean { + color: #569cd6; +} + +.json-null { + color: #569cd6; +} + +/* Console scrollbar */ +.console-content::-webkit-scrollbar { + width: 8px; +} + +.console-content::-webkit-scrollbar-track { + background: #1e1e1e; +} + +.console-content::-webkit-scrollbar-thumb { + background: #3e3e3e; + border-radius: 4px; +} + +.console-content::-webkit-scrollbar-thumb:hover { + background: #4e4e4e; +} + +/* JSON scrollbar */ +.console-entry-json::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.console-entry-json::-webkit-scrollbar-track { + background: #1e1e1e; +} + +.console-entry-json::-webkit-scrollbar-thumb { + background: #3e3e3e; + border-radius: 3px; +} + +.console-entry-json::-webkit-scrollbar-thumb:hover { + background: #4e4e4e; +} + +/* Responsive Design */ +@media (max-width: 768px) { + header { + padding: 1rem 1.5rem; + } + + h1 { + font-size: 1.25rem; + } + + .connection-status { + position: static; + margin-top: 0.5rem; + } + + /* Stack console panel below chat on mobile */ + .main-layout { + flex-direction: column; + } + + .console-panel { + max-height: 300px; + border-top: 1px solid var(--border-color); + } + + .container { + border-right: none; + } + + #messages { + padding: 1rem; + } + + .bubble { + max-width: 85%; + } + + .input-container { + padding: 1rem; + } + + #messageForm { + flex-direction: column; + gap: 0.75rem; + } + + .input-wrapper { + width: 100%; + flex-direction: column; + } + + button { + width: 100%; + } +} + +/* Loading state */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Video bubble styling */ +.bubble.video-bubble { + padding: 0.25rem; + max-width: 80%; + background-color: #000; +} + +.bubble-video { + max-width: 100%; + max-height: 300px; + width: auto; + height: auto; + border-radius: 0.75rem; + display: block; + object-fit: contain; +} + +/* Camera Preview Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} + +.modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + background-color: white; + border-radius: 1rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + max-width: 90%; + max-height: 90%; + width: 640px; + display: flex; + flex-direction: column; + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #202124; +} + +.close-btn { + background: none; + border: none; + font-size: 2rem; + line-height: 1; + color: #5f6368; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s ease; +} + +.close-btn:hover { + background-color: #f1f3f4; + color: #202124; +} + +.modal-body { + padding: 1.5rem; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background-color: #000; + border-radius: 0 0 1rem 1rem; +} + +#cameraPreview { + width: 100%; + max-height: 480px; + border-radius: 0.5rem; + object-fit: contain; +} + +.modal-footer { + padding: 1.5rem; + border-top: 1px solid var(--border-color); + display: flex; + gap: 1rem; + justify-content: flex-end; + background-color: white; + border-radius: 0 0 1rem 1rem; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; + padding: 0.875rem 1.5rem; + border: none; + border-radius: 1.5rem; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.btn-primary:hover { + background-color: #3367d6; + box-shadow: var(--shadow); + transform: translateY(-1px); +} + +.btn-secondary { + background-color: white; + color: #5f6368; + padding: 0.875rem 1.5rem; + border: 1px solid var(--border-color); + border-radius: 1.5rem; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.btn-secondary:hover { + background-color: #f8f9fa; + border-color: #bdc1c6; +} \ No newline at end of file diff --git a/examples/bidi/static/index.html b/examples/bidi/static/index.html new file mode 100644 index 000000000..5ac581073 --- /dev/null +++ b/examples/bidi/static/index.html @@ -0,0 +1,89 @@ + + + + + + ADK Gemini Live API Toolkit Demo + + + + + +

+

ADK Gemini Live API Toolkit Demo

+
Real-time bidirectional streaming with Google ADK
+
+ + +
+
+ + Connecting... +
+
+ +
+
+
+ + +
+
+
+ + + + + + + +
+
+
+
+ +
+
+

Event Console

+
+ + +
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/examples/bidi/static/js/app.js b/examples/bidi/static/js/app.js new file mode 100644 index 000000000..52b09716c --- /dev/null +++ b/examples/bidi/static/js/app.js @@ -0,0 +1,1304 @@ +/** + * app.js: JS code for the ADK Gemini Live API Toolkit demo app. + */ + +/** + * WebSocket handling + */ + +// Connect the server with a WebSocket connection +const userId = "demo-user"; +//let sessionId = "demo-session-" + Math.random().toString(36).substring(7); +let sessionId = "fixed-demo-session"; +let websocket = null; +let is_audio = false; +let audioBuffer = []; + +// Get checkbox elements for RunConfig options +const enableProactivityCheckbox = document.getElementById("enableProactivity"); +const enableAffectiveDialogCheckbox = document.getElementById("enableAffectiveDialog"); + +// Reconnect WebSocket when RunConfig options change +function handleRunConfigChange() { + if (websocket && websocket.readyState === WebSocket.OPEN) { + addSystemMessage("Reconnecting with updated settings..."); + addConsoleEntry('outgoing', 'Reconnecting due to settings change', { + proactivity: enableProactivityCheckbox.checked, + affective_dialog: enableAffectiveDialogCheckbox.checked + }, '🔄', 'system'); + + // Keep same session ID for testing history + // sessionId = "demo-session-" + Math.random().toString(36).substring(7); + + websocket.close(); + // connectWebsocket() will be called by onclose handler after delay + } +} + +// Add change listeners to RunConfig checkboxes +enableProactivityCheckbox.addEventListener("change", handleRunConfigChange); +enableAffectiveDialogCheckbox.addEventListener("change", handleRunConfigChange); + +// Build WebSocket URL with RunConfig options as query parameters +function getWebSocketUrl() { + // Use wss:// for HTTPS pages, ws:// for HTTP (localhost development) + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const baseUrl = wsProtocol + "//" + window.location.host + "/run_live"; + const params = new URLSearchParams(); + + // Add required parameters for RunLiveHandler + params.append("appName", "bidi-demo"); + params.append("userId", userId); + params.append("sessionId", sessionId); + + // Add proactivity option if checked + if (enableProactivityCheckbox && enableProactivityCheckbox.checked) { + params.append("proactivity", "true"); + } + + // Add affective dialog option if checked + if (enableAffectiveDialogCheckbox && enableAffectiveDialogCheckbox.checked) { + params.append("affective_dialog", "true"); + } + + const queryString = params.toString(); + return queryString ? baseUrl + "?" + queryString : baseUrl; +} + +// Get DOM elements +const messageForm = document.getElementById("messageForm"); +const messageInput = document.getElementById("message"); +const messagesDiv = document.getElementById("messages"); +const statusIndicator = document.getElementById("statusIndicator"); +const statusText = document.getElementById("statusText"); +const consoleContent = document.getElementById("consoleContent"); +const clearConsoleBtn = document.getElementById("clearConsole"); +const showAudioEventsCheckbox = document.getElementById("showAudioEvents"); +let currentMessageId = null; +let currentBubbleElement = null; +let currentInputTranscriptionId = null; +let currentInputTranscriptionElement = null; +let currentOutputTranscriptionId = null; +let currentOutputTranscriptionElement = null; +let lastAgentBubbleElement = null; +let inputTranscriptionFinished = false; // Track if input transcription is complete for this turn +let hasOutputTranscriptionInTurn = false; // Track if output transcription delivered the response + +// Helper function to clean spaces between CJK characters +// Removes spaces between Japanese/Chinese/Korean characters while preserving spaces around Latin text +function cleanCJKSpaces(text) { + // CJK Unicode ranges: Hiragana, Katakana, Kanji, CJK Unified Ideographs, Fullwidth forms + const cjkPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf\uff00-\uffef]/; + + // Remove spaces between two CJK characters + return text.replace(/(\S)\s+(?=\S)/g, (match, char1) => { + // Get the character after the space(s) + const nextCharMatch = text.match(new RegExp(char1 + '\\s+(.)', 'g')); + if (nextCharMatch && nextCharMatch.length > 0) { + const char2 = nextCharMatch[0].slice(-1); + // If both characters are CJK, remove the space + if (cjkPattern.test(char1) && cjkPattern.test(char2)) { + return char1; + } + } + return match; + }); +} + +// Console logging functionality +function formatTimestamp() { + const now = new Date(); + return now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 }); +} + +function addConsoleEntry(type, content, data = null, emoji = null, author = null, isAudio = false) { + // Skip audio events if checkbox is unchecked + if (isAudio && !showAudioEventsCheckbox.checked) { + return; + } + + const entry = document.createElement("div"); + entry.className = `console-entry ${type}`; + + const header = document.createElement("div"); + header.className = "console-entry-header"; + + const leftSection = document.createElement("div"); + leftSection.className = "console-entry-left"; + + // Add emoji icon if provided + if (emoji) { + const emojiIcon = document.createElement("span"); + emojiIcon.className = "console-entry-emoji"; + emojiIcon.textContent = emoji; + leftSection.appendChild(emojiIcon); + } + + // Add expand/collapse icon + const expandIcon = document.createElement("span"); + expandIcon.className = "console-expand-icon"; + expandIcon.textContent = data ? "▶" : ""; + + const typeLabel = document.createElement("span"); + typeLabel.className = "console-entry-type"; + typeLabel.textContent = type === 'outgoing' ? '↑ Upstream' : type === 'incoming' ? '↓ Downstream' : '⚠ Error'; + + leftSection.appendChild(expandIcon); + leftSection.appendChild(typeLabel); + + // Add author badge if provided + if (author) { + const authorBadge = document.createElement("span"); + authorBadge.className = "console-entry-author"; + authorBadge.textContent = author; + authorBadge.setAttribute('data-author', author); + leftSection.appendChild(authorBadge); + } + + const timestamp = document.createElement("span"); + timestamp.className = "console-entry-timestamp"; + timestamp.textContent = formatTimestamp(); + + header.appendChild(leftSection); + header.appendChild(timestamp); + + const contentDiv = document.createElement("div"); + contentDiv.className = "console-entry-content"; + contentDiv.textContent = content; + + entry.appendChild(header); + entry.appendChild(contentDiv); + + // JSON details (hidden by default) + let jsonDiv = null; + if (data) { + jsonDiv = document.createElement("div"); + jsonDiv.className = "console-entry-json collapsed"; + const pre = document.createElement("pre"); + pre.textContent = JSON.stringify(data, null, 2); + jsonDiv.appendChild(pre); + entry.appendChild(jsonDiv); + + // Make entry clickable if it has data + entry.classList.add("expandable"); + + // Toggle expand/collapse on click + entry.addEventListener("click", () => { + const isExpanded = !jsonDiv.classList.contains("collapsed"); + + if (isExpanded) { + // Collapse + jsonDiv.classList.add("collapsed"); + expandIcon.textContent = "▶"; + entry.classList.remove("expanded"); + } else { + // Expand + jsonDiv.classList.remove("collapsed"); + expandIcon.textContent = "▼"; + entry.classList.add("expanded"); + } + }); + } + + consoleContent.appendChild(entry); + consoleContent.scrollTop = consoleContent.scrollHeight; +} + +function clearConsole() { + consoleContent.innerHTML = ''; +} + +// Clear console button handler +clearConsoleBtn.addEventListener('click', clearConsole); + +// Update connection status UI +function updateConnectionStatus(connected) { + if (connected) { + statusIndicator.classList.remove("disconnected"); + statusText.textContent = "Connected"; + } else { + statusIndicator.classList.add("disconnected"); + statusText.textContent = "Disconnected"; + } +} + +// Create a message bubble element +function createMessageBubble(text, isUser, isPartial = false) { + const messageDiv = document.createElement("div"); + messageDiv.className = `message ${isUser ? "user" : "agent"}`; + + const bubbleDiv = document.createElement("div"); + bubbleDiv.className = "bubble"; + + const textP = document.createElement("p"); + textP.className = "bubble-text"; + textP.textContent = text; + + // Add typing indicator for partial messages + if (isPartial && !isUser) { + const typingSpan = document.createElement("span"); + typingSpan.className = "typing-indicator"; + textP.appendChild(typingSpan); + } + + bubbleDiv.appendChild(textP); + messageDiv.appendChild(bubbleDiv); + + return messageDiv; +} + +// Create an image message bubble element +function createImageBubble(imageDataUrl, isUser) { + const messageDiv = document.createElement("div"); + messageDiv.className = `message ${isUser ? "user" : "agent"}`; + + const bubbleDiv = document.createElement("div"); + bubbleDiv.className = "bubble image-bubble"; + + const img = document.createElement("img"); + img.src = imageDataUrl; + img.className = "bubble-image"; + img.alt = "Captured image"; + + bubbleDiv.appendChild(img); + messageDiv.appendChild(bubbleDiv); + + return messageDiv; +} + +// Update existing message bubble text +function updateMessageBubble(element, text, isPartial = false) { + const textElement = element.querySelector(".bubble-text"); + + // Remove existing typing indicator + const existingIndicator = textElement.querySelector(".typing-indicator"); + if (existingIndicator) { + existingIndicator.remove(); + } + + textElement.textContent = text; + + // Add typing indicator for partial messages + if (isPartial) { + const typingSpan = document.createElement("span"); + typingSpan.className = "typing-indicator"; + textElement.appendChild(typingSpan); + } +} + +// Add a system message +function addSystemMessage(text) { + const messageDiv = document.createElement("div"); + messageDiv.className = "system-message"; + messageDiv.textContent = text; + appendMessage(messageDiv); + scrollToBottom(); +} + +// Scroll to bottom of messages +function scrollToBottom() { + messagesDiv.scrollTop = messagesDiv.scrollHeight; +} + +// Append message to messagesDiv, inserting before stream bubble if active +function appendMessage(element) { + const streamBubble = document.getElementById("streamPreviewBubble"); + if (streamBubble) { + messagesDiv.insertBefore(element, streamBubble); + } else { + messagesDiv.appendChild(element); + } +} + +// Sanitize event data for console display (replace large audio data with summary) +function sanitizeEventForDisplay(event) { + // Deep clone the event object + const sanitized = JSON.parse(JSON.stringify(event)); + + // Check for audio data in content.parts + if (sanitized.content && sanitized.content.parts) { + sanitized.content.parts = sanitized.content.parts.map(part => { + if (part.inlineData && part.inlineData.data) { + // Calculate byte size (base64 string length / 4 * 3, roughly) + const byteSize = Math.floor(part.inlineData.data.length * 0.75); + return { + ...part, + inlineData: { + ...part.inlineData, + data: `(${byteSize.toLocaleString()} bytes)` + } + }; + } + return part; + }); + } + + return sanitized; +} + +// WebSocket handlers +function connectWebsocket() { + // Connect websocket + const ws_url = getWebSocketUrl(); + websocket = new WebSocket(ws_url); + + // Handle connection open + websocket.onopen = function () { + console.log("WebSocket connection opened."); + updateConnectionStatus(true); + addSystemMessage("Connected to ADK streaming server"); + + // Log to console + addConsoleEntry('incoming', 'WebSocket Connected', { + userId: userId, + sessionId: sessionId, + url: ws_url + }, '🔌', 'system'); + + // Enable the Send button + document.getElementById("sendButton").disabled = false; + addSubmitHandler(); + }; + + // Handle incoming messages + websocket.onmessage = function (event) { + // Parse the incoming ADK Event + const adkEvent = JSON.parse(event.data); + console.log("[AGENT TO CLIENT] ", adkEvent); + + // Log to console panel + let eventSummary = 'Event'; + let eventEmoji = '📨'; // Default emoji + const author = adkEvent.author || 'system'; + + if (adkEvent.turnComplete) { + eventSummary = 'Turn Complete'; + eventEmoji = '✅'; + } else if (adkEvent.interrupted) { + eventSummary = 'Interrupted'; + eventEmoji = '⏸️'; + } else if (adkEvent.inputTranscription) { + // Show transcription text in summary + const transcriptionText = adkEvent.inputTranscription.text || ''; + const truncated = transcriptionText.length > 60 + ? transcriptionText.substring(0, 60) + '...' + : transcriptionText; + eventSummary = `Input Transcription: "${truncated}"`; + eventEmoji = '📝'; + } else if (adkEvent.outputTranscription) { + // Show transcription text in summary + const transcriptionText = adkEvent.outputTranscription.text || ''; + const truncated = transcriptionText.length > 60 + ? transcriptionText.substring(0, 60) + '...' + : transcriptionText; + eventSummary = `Output Transcription: "${truncated}"`; + eventEmoji = '📝'; + } else if (adkEvent.usageMetadata) { + // Show token usage information + const usage = adkEvent.usageMetadata; + const promptTokens = usage.promptTokenCount || 0; + const responseTokens = usage.candidatesTokenCount || 0; + const totalTokens = usage.totalTokenCount || 0; + eventSummary = `Token Usage: ${totalTokens.toLocaleString()} total (${promptTokens.toLocaleString()} prompt + ${responseTokens.toLocaleString()} response)`; + eventEmoji = '📊'; + } else if (adkEvent.content && adkEvent.content.parts) { + const hasText = adkEvent.content.parts.some(p => p.text); + const hasAudio = adkEvent.content.parts.some(p => p.inlineData); + const hasExecutableCode = adkEvent.content.parts.some(p => p.executableCode); + const hasCodeExecutionResult = adkEvent.content.parts.some(p => p.codeExecutionResult); + + if (hasExecutableCode) { + // Show executable code + const codePart = adkEvent.content.parts.find(p => p.executableCode); + if (codePart && codePart.executableCode) { + const code = codePart.executableCode.code || ''; + const language = codePart.executableCode.language || 'unknown'; + const truncated = code.length > 60 + ? code.substring(0, 60).replace(/\n/g, ' ') + '...' + : code.replace(/\n/g, ' '); + eventSummary = `Executable Code (${language}): ${truncated}`; + eventEmoji = '💻'; + } + } + + if (hasCodeExecutionResult) { + // Show code execution result + const resultPart = adkEvent.content.parts.find(p => p.codeExecutionResult); + if (resultPart && resultPart.codeExecutionResult) { + const outcome = resultPart.codeExecutionResult.outcome || 'UNKNOWN'; + const output = resultPart.codeExecutionResult.output || ''; + const truncatedOutput = output.length > 60 + ? output.substring(0, 60).replace(/\n/g, ' ') + '...' + : output.replace(/\n/g, ' '); + eventSummary = `Code Execution Result (${outcome}): ${truncatedOutput}`; + eventEmoji = outcome === 'OUTCOME_OK' ? '✅' : '❌'; + } + } + + if (hasText) { + // Show text preview in summary + const textPart = adkEvent.content.parts.find(p => p.text); + if (textPart && textPart.text) { + const text = textPart.text; + const truncated = text.length > 80 + ? text.substring(0, 80) + '...' + : text; + eventSummary = `Text: "${truncated}"`; + eventEmoji = '💭'; + } else { + eventSummary = 'Text Response'; + eventEmoji = '💭'; + } + } + + if (hasAudio) { + // Extract audio info for summary + const audioPart = adkEvent.content.parts.find(p => p.inlineData); + if (audioPart && audioPart.inlineData) { + const mimeType = audioPart.inlineData.mimeType || 'unknown'; + const dataLength = audioPart.inlineData.data ? audioPart.inlineData.data.length : 0; + // Base64 string length / 4 * 3 gives approximate bytes + const byteSize = Math.floor(dataLength * 0.75); + eventSummary = `Audio Response: ${mimeType} (${byteSize.toLocaleString()} bytes)`; + eventEmoji = '🔊'; + } else { + eventSummary = 'Audio Response'; + eventEmoji = '🔊'; + } + + // Log audio event with isAudio flag (filtered by checkbox) + const sanitizedEvent = sanitizeEventForDisplay(adkEvent); + addConsoleEntry('incoming', eventSummary, sanitizedEvent, eventEmoji, author, true); + } + } + + // Create a sanitized version for console display (replace large audio data with summary) + // Skip if already logged as audio event above + const isAudioOnlyEvent = adkEvent.content && adkEvent.content.parts && + adkEvent.content.parts.some(p => p.inlineData) && + !adkEvent.content.parts.some(p => p.text); + if (!isAudioOnlyEvent) { + const sanitizedEvent = sanitizeEventForDisplay(adkEvent); + addConsoleEntry('incoming', eventSummary, sanitizedEvent, eventEmoji, author); + } + + // Handle turn complete event + if (adkEvent.turnComplete === true) { + // Remove typing indicator from current message + if (currentBubbleElement) { + const textElement = currentBubbleElement.querySelector(".bubble-text"); + const typingIndicator = textElement.querySelector(".typing-indicator"); + if (typingIndicator) { + typingIndicator.remove(); + } + } + // Remove typing indicator from current output transcription + if (currentOutputTranscriptionElement) { + const textElement = currentOutputTranscriptionElement.querySelector(".bubble-text"); + const typingIndicator = textElement.querySelector(".typing-indicator"); + if (typingIndicator) { + typingIndicator.remove(); + } + } + currentMessageId = null; + currentBubbleElement = null; + currentOutputTranscriptionId = null; + currentOutputTranscriptionElement = null; + inputTranscriptionFinished = false; // Reset for next turn + hasOutputTranscriptionInTurn = false; // Reset for next turn + return; + } + + // Handle interrupted event + if (adkEvent.interrupted === true) { + // Stop audio playback if it's playing + if (audioPlayerNode) { + audioPlayerNode.port.postMessage({ command: "endOfAudio" }); + } + + // Keep the partial message but mark it as interrupted + let markedInterrupted = false; + if (currentBubbleElement) { + const textElement = currentBubbleElement.querySelector(".bubble-text"); + + // Remove typing indicator + const typingIndicator = textElement.querySelector(".typing-indicator"); + if (typingIndicator) { + typingIndicator.remove(); + } + + // Add interrupted marker + currentBubbleElement.classList.add("interrupted"); + markedInterrupted = true; + } + + // Keep the partial output transcription but mark it as interrupted + if (currentOutputTranscriptionElement) { + const textElement = currentOutputTranscriptionElement.querySelector(".bubble-text"); + + // Remove typing indicator + const typingIndicator = textElement.querySelector(".typing-indicator"); + if (typingIndicator) { + typingIndicator.remove(); + } + + // Add interrupted marker + currentOutputTranscriptionElement.classList.add("interrupted"); + markedInterrupted = true; + } + + // Fallback to the last agent bubble element if the active trackers were already finalized + if (!markedInterrupted && lastAgentBubbleElement) { + lastAgentBubbleElement.classList.add("interrupted"); + } + + // Reset state so new content creates a new bubble + currentMessageId = null; + currentBubbleElement = null; + currentOutputTranscriptionId = null; + currentOutputTranscriptionElement = null; + inputTranscriptionFinished = false; // Reset for next turn + hasOutputTranscriptionInTurn = false; // Reset for next turn + return; + } + + // Handle input transcription (user's spoken words) + if (adkEvent.inputTranscription && adkEvent.inputTranscription.text) { + const transcriptionText = adkEvent.inputTranscription.text; + const isFinished = adkEvent.inputTranscription.finished; + + if (transcriptionText) { + // Ignore late-arriving transcriptions after we've finished for this turn + if (inputTranscriptionFinished) { + return; + } + + if (currentInputTranscriptionId == null) { + // Create new transcription bubble + currentInputTranscriptionId = Math.random().toString(36).substring(7); + // Clean spaces between CJK characters + const cleanedText = cleanCJKSpaces(transcriptionText); + currentInputTranscriptionElement = createMessageBubble(cleanedText, true, !isFinished); + currentInputTranscriptionElement.id = currentInputTranscriptionId; + + // Add a special class to indicate it's a transcription + currentInputTranscriptionElement.classList.add("transcription"); + + appendMessage(currentInputTranscriptionElement); + lastAgentBubbleElement = null; + } else { + // Update existing transcription bubble only if model hasn't started responding + // This prevents late partial transcriptions from overwriting complete ones + if (currentOutputTranscriptionId == null && currentMessageId == null) { + if (isFinished) { + // Final transcription contains the complete text, replace entirely + const cleanedText = cleanCJKSpaces(transcriptionText); + updateMessageBubble(currentInputTranscriptionElement, cleanedText, false); + } else { + // Partial transcription - append to existing text + if (currentInputTranscriptionElement) { + const existingText = currentInputTranscriptionElement.querySelector(".bubble-text").textContent; + // Remove typing indicator if present + const cleanText = existingText.replace(/\.\.\.$/, ''); + // Clean spaces between CJK characters before updating + const accumulatedText = cleanCJKSpaces(cleanText + transcriptionText); + updateMessageBubble(currentInputTranscriptionElement, accumulatedText, true); + } else { + console.log("fixed: currentInputTranscriptionElement was null in transcription handler"); + // Fallback: create a new bubble if it's null for some reason + currentInputTranscriptionId = Math.random().toString(36).substring(7); + const cleanedText = cleanCJKSpaces(transcriptionText); + currentInputTranscriptionElement = createMessageBubble(cleanedText, true, true); + currentInputTranscriptionElement.id = currentInputTranscriptionId; + currentInputTranscriptionElement.classList.add("transcription"); + appendMessage(currentInputTranscriptionElement); + } + } + } + } + + // If transcription is finished, reset the state and mark as complete + if (isFinished) { + currentInputTranscriptionId = null; + currentInputTranscriptionElement = null; + inputTranscriptionFinished = true; // Prevent duplicate bubbles from late events + } + + scrollToBottom(); + } + } + + // Handle output transcription (model's spoken words) + if (adkEvent.outputTranscription && adkEvent.outputTranscription.text) { + const transcriptionText = adkEvent.outputTranscription.text; + const isFinished = adkEvent.outputTranscription.finished; + hasOutputTranscriptionInTurn = true; + + if (transcriptionText) { + // Finalize any active input transcription when server starts responding + if (currentInputTranscriptionId != null && currentOutputTranscriptionId == null) { + // This is the first output transcription - finalize input transcription + if (currentInputTranscriptionElement) { + const textElement = currentInputTranscriptionElement.querySelector(".bubble-text"); + const typingIndicator = textElement.querySelector(".typing-indicator"); + if (typingIndicator) { + typingIndicator.remove(); + } + } else { + console.log("fixed: currentInputTranscriptionElement was null in content handler"); + } + // Reset input transcription state so next user input creates new balloon + currentInputTranscriptionId = null; + currentInputTranscriptionElement = null; + inputTranscriptionFinished = true; // Prevent duplicate bubbles from late events + } + + if (currentOutputTranscriptionId == null) { + // Create new transcription bubble for agent + currentOutputTranscriptionId = Math.random().toString(36).substring(7); + currentOutputTranscriptionElement = createMessageBubble(transcriptionText, false, !isFinished); + currentOutputTranscriptionElement.id = currentOutputTranscriptionId; + + // Add a special class to indicate it's a transcription + currentOutputTranscriptionElement.classList.add("transcription"); + + appendMessage(currentOutputTranscriptionElement); + lastAgentBubbleElement = currentOutputTranscriptionElement; + } else { + // Update existing transcription bubble + if (isFinished) { + // Final transcription contains the complete text, replace entirely + updateMessageBubble(currentOutputTranscriptionElement, transcriptionText, false); + } else { + // Partial transcription - append to existing text + const existingText = currentOutputTranscriptionElement.querySelector(".bubble-text").textContent; + // Remove typing indicator if present + const cleanText = existingText.replace(/\.\.\.$/, ''); + updateMessageBubble(currentOutputTranscriptionElement, cleanText + transcriptionText, true); + } + } + + // If transcription is finished, reset the state + if (isFinished) { + currentOutputTranscriptionId = null; + currentOutputTranscriptionElement = null; + } + + scrollToBottom(); + } + } + + // Handle content events (text or audio) + if (adkEvent.content && adkEvent.content.parts) { + const parts = adkEvent.content.parts; + + // Finalize any active input transcription when server starts responding with content + if (currentInputTranscriptionId != null && currentMessageId == null && currentOutputTranscriptionId == null) { + // This is the first content event - finalize input transcription + if (currentInputTranscriptionElement) { + const textElement = currentInputTranscriptionElement.querySelector(".bubble-text"); + const typingIndicator = textElement.querySelector(".typing-indicator"); + if (typingIndicator) { + typingIndicator.remove(); + } + } + // Reset input transcription state so next user input creates new balloon + currentInputTranscriptionId = null; + currentInputTranscriptionElement = null; + inputTranscriptionFinished = true; // Prevent duplicate bubbles from late events + } + + for (const part of parts) { + // Handle function response + if (part.functionResponse) { + console.log("Received function response:", part.functionResponse); + if (part.functionResponse.name === "camera_toggle") { + toggleVideoStreaming(); + } + } + + // Handle inline data (audio) + if (part.inlineData) { + const mimeType = part.inlineData.mimeType; + const data = part.inlineData.data; + + if (mimeType && mimeType.startsWith("audio/pcm")) { + const arrayBuffer = base64ToArray(data); + if (audioPlayerNode) { + audioPlayerNode.port.postMessage(arrayBuffer); + } else { + audioBuffer.push(arrayBuffer); + } + } + } + + // Handle text + if (part.text) { + // Skip thinking/reasoning text from chat bubbles (shown in event console) + if (part.thought) { + continue; + } + + // Skip final aggregated content when output transcription already + // delivered the response (prevents duplicate thinking text replay) + if (!adkEvent.partial && hasOutputTranscriptionInTurn) { + continue; + } + + // Handle system messages separately to avoid hijacking chat bubbles + if (adkEvent.author === "system") { + addSystemMessage(part.text); + continue; + } + + const isUser = (adkEvent.content.role === "user" || adkEvent.author === "user"); + + // Add a new message bubble for a new turn, or if role changed + if (currentMessageId == null || currentBubbleElement == null || currentBubbleElement.classList.contains("user") !== isUser) { + // Finalize previous bubble if role changed + if (currentBubbleElement && currentBubbleElement.classList.contains("user") !== isUser) { + const textElement = currentBubbleElement.querySelector(".bubble-text"); + const typingIndicator = textElement.querySelector(".typing-indicator"); + if (typingIndicator) { + typingIndicator.remove(); + } + } + + currentMessageId = Math.random().toString(36).substring(7); + currentBubbleElement = createMessageBubble(part.text, isUser, true); + currentBubbleElement.id = currentMessageId; + appendMessage(currentBubbleElement); + if (!isUser) { + lastAgentBubbleElement = currentBubbleElement; + } + } else { + // Update the existing message bubble with accumulated text + const existingText = currentBubbleElement.querySelector(".bubble-text").textContent; + // Remove the "..." if present + const cleanText = existingText.replace(/\.\.\.$/, ''); + updateMessageBubble(currentBubbleElement, cleanText + part.text, true); + } + + // Scroll down to the bottom of the messagesDiv + scrollToBottom(); + } + } + } + }; + + // Handle connection close + websocket.onclose = function (e) { + console.log("WebSocket connection closed.", e); + updateConnectionStatus(false); + document.getElementById("sendButton").disabled = true; + const reason = e && e.reason ? e.reason + " - " : ""; + addSystemMessage(`${reason}Connection closed. Reconnecting in 5 seconds...`); + + // Log to console + addConsoleEntry('error', 'WebSocket Disconnected', { + status: e && e.reason ? e.reason : "Connection closed", + code: e ? e.code : null, + reconnecting: true, + reconnectDelay: '5 seconds' + }, '🔌', 'system'); + + setTimeout(function () { + console.log("Reconnecting..."); + + // Log reconnection attempt to console + addConsoleEntry('outgoing', 'Reconnecting to ADK server...', { + userId: userId, + sessionId: sessionId + }, '🔄', 'system'); + + connectWebsocket(); + }, 5000); + }; + + websocket.onerror = function (e) { + console.log("WebSocket error: ", e); + updateConnectionStatus(false); + + // Log to console + addConsoleEntry('error', 'WebSocket Error', { + error: e.type, + message: 'Connection error occurred' + }, '⚠️', 'system'); + }; +} +connectWebsocket(); + +// Add submit handler to the form +function addSubmitHandler() { + messageForm.onsubmit = function (e) { + e.preventDefault(); + const message = messageInput.value.trim(); + if (message) { + // Add user message bubble + const userBubble = createMessageBubble(message, true, false); + appendMessage(userBubble); + scrollToBottom(); + + // Clear input + messageInput.value = ""; + + // Send message to server + sendMessage(message); + console.log("[CLIENT TO AGENT] " + message); + } + return false; + }; +} + +// Send a message to the server as JSON +function sendMessage(message) { + ensureAudioPlayerStarted(); + if (websocket && websocket.readyState == WebSocket.OPEN) { + lastAgentBubbleElement = null; + const jsonMessage = JSON.stringify({ + content: { + role: "user", + parts: [{ text: message }] + } + }); + websocket.send(jsonMessage); + + // Log to console panel + addConsoleEntry('outgoing', 'User Message: ' + message, null, '💬', 'user'); + } +} + +// Decode Base64 data to Array +// Handles both standard base64 and base64url encoding +function base64ToArray(base64) { + // Convert base64url to standard base64 + // Replace URL-safe characters: - with +, _ with / + let standardBase64 = base64.replace(/-/g, '+').replace(/_/g, '/'); + + // Add padding if needed + while (standardBase64.length % 4) { + standardBase64 += '='; + } + + const binaryString = window.atob(standardBase64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; +} + +/** + * Camera handling + */ + +const cameraButton = document.getElementById("cameraButton"); +const streamVideoButton = document.getElementById("streamVideoButton"); +const cameraModal = document.getElementById("cameraModal"); +const cameraPreview = document.getElementById("cameraPreview"); +const closeCameraModal = document.getElementById("closeCameraModal"); +const cancelCamera = document.getElementById("cancelCamera"); +const captureImageBtn = document.getElementById("captureImage"); +const sendFileButton = document.getElementById("sendFileButton"); +const fileInput = document.getElementById("fileInput"); + +let cameraStream = null; +let isVideoStreaming = false; +let videoStreamInterval = null; + +// Open camera modal and start preview +async function openCameraPreview() { + try { + // Request access to the user's webcam + cameraStream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 768 }, + height: { ideal: 768 }, + facingMode: 'user' + } + }); + + // Set the stream to the video element + cameraPreview.srcObject = cameraStream; + + // Show the modal + cameraModal.classList.add('show'); + + } catch (error) { + console.error('Error accessing camera:', error); + addSystemMessage(`Failed to access camera: ${error.message}`); + + // Log to console + addConsoleEntry('error', 'Camera access failed', { + error: error.message, + name: error.name + }, '⚠️', 'system'); + } +} + +// Start video stream for preview in element +async function startVideoStream(videoElement) { + try { + cameraStream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 768 }, + height: { ideal: 768 }, + facingMode: 'user' + } + }); + + videoElement.srcObject = cameraStream; + + } catch (error) { + console.error('Error accessing camera for stream:', error); + addSystemMessage(`Failed to access camera: ${error.message}`); + addConsoleEntry('error', 'Camera access failed', { + error: error.message, + name: error.name + }, '⚠️', 'system'); + } +} + +// Close camera modal and stop preview +function closeCameraPreview() { + // Stop the camera stream + if (cameraStream) { + cameraStream.getTracks().forEach(track => track.stop()); + cameraStream = null; + } + + // Stop streaming if active + if (isVideoStreaming) { + clearInterval(videoStreamInterval); + isVideoStreaming = false; + streamVideoButton.textContent = "📹 Stream Video"; + streamVideoButton.classList.remove("active"); + addSystemMessage("Video streaming stopped"); + } + + // Clear the video source + cameraPreview.srcObject = null; + + // Hide the modal + cameraModal.classList.remove('show'); +} + +// Capture image from the live preview +function captureImageFromPreview() { + if (!cameraStream) { + addSystemMessage('No camera stream available'); + return; + } + + try { + // Create canvas to capture the frame + const canvas = document.createElement('canvas'); + canvas.width = cameraPreview.videoWidth; + canvas.height = cameraPreview.videoHeight; + const context = canvas.getContext('2d'); + + // Draw current video frame to canvas + context.drawImage(cameraPreview, 0, 0, canvas.width, canvas.height); + + // Convert canvas to data URL for display + const imageDataUrl = canvas.toDataURL('image/jpeg', 0.85); + + // Display the captured image in the chat + const imageBubble = createImageBubble(imageDataUrl, true); + appendMessage(imageBubble); + scrollToBottom(); + + // Convert canvas to blob for sending to server + canvas.toBlob((blob) => { + // Convert blob to base64 for sending to server + const reader = new FileReader(); + reader.onloadend = () => { + const base64data = reader.result.split(',')[1]; // Remove data:image/jpeg;base64, prefix + sendImage(base64data); + }; + reader.readAsDataURL(blob); + + // Log to console + addConsoleEntry('outgoing', `Image captured: ${blob.size} bytes (JPEG)`, { + size: blob.size, + type: 'image/jpeg', + dimensions: `${canvas.width}x${canvas.height}` + }, '📷', 'user'); + }, 'image/jpeg', 0.85); + + // Close the camera modal + closeCameraPreview(); + + } catch (error) { + console.error('Error capturing image:', error); + addSystemMessage(`Failed to capture image: ${error.message}`); + + // Log to console + addConsoleEntry('error', 'Image capture failed', { + error: error.message, + name: error.name + }, '⚠️', 'system'); + } +} + +// Send image to server +function sendImage(base64Image, mimeType = "image/jpeg") { + if (websocket && websocket.readyState === WebSocket.OPEN) { + const jsonMessage = JSON.stringify({ + blob: { + mime_type: mimeType, + data: base64Image + } + }); + websocket.send(jsonMessage); + console.log(`[CLIENT TO AGENT] Sent image (${mimeType})`); + } +} + +// Capture and send a single frame +function captureAndSendFrame() { + if (!cameraStream) return; + + try { + const canvas = document.createElement('canvas'); + // Use video in stream bubble if active, fallback to cameraPreview + const streamBubble = document.getElementById("streamPreviewBubble"); + const videoElement = streamBubble ? streamBubble.querySelector("video") : cameraPreview; + + canvas.width = videoElement.videoWidth; + canvas.height = videoElement.videoHeight; + const context = canvas.getContext('2d'); + context.drawImage(videoElement, 0, 0, canvas.width, canvas.height); + + canvas.toBlob((blob) => { + const reader = new FileReader(); + reader.onloadend = () => { + const base64data = reader.result.split(',')[1]; + sendImage(base64data); + }; + reader.readAsDataURL(blob); + }, 'image/jpeg', 0.6); // Lower quality for stream to save bandwidth + } catch (error) { + console.error('Error capturing video frame:', error); + } +} + +// Toggle video streaming +function toggleVideoStreaming() { + if (isVideoStreaming) { + // Stop streaming + clearInterval(videoStreamInterval); + isVideoStreaming = false; + streamVideoButton.textContent = "📹 Stream Video"; + streamVideoButton.classList.remove("active"); + addSystemMessage("Video streaming stopped"); + + // Keep the bubble in chat but stop tracks + const streamBubble = document.getElementById("streamPreviewBubble"); + if (streamBubble) { + // Remove ID so new messages don't get inserted before it anymore + streamBubble.removeAttribute("id"); + } + + if (cameraStream) { + cameraStream.getTracks().forEach(track => track.stop()); + cameraStream = null; + } + } else { + // Start streaming + // Create video bubble + const messageDiv = document.createElement("div"); + messageDiv.className = "message user"; + messageDiv.id = "streamPreviewBubble"; + + const bubbleDiv = document.createElement("div"); + bubbleDiv.className = "bubble video-bubble"; + + const video = document.createElement("video"); + video.className = "bubble-video"; + video.autoplay = true; + video.playsInline = true; + + bubbleDiv.appendChild(video); + messageDiv.appendChild(bubbleDiv); + + // Append to chat (always at bottom for now) + messagesDiv.appendChild(messageDiv); + scrollToBottom(); + + if (!cameraStream) { + startVideoStream(video).then(() => { + if (cameraStream) { + startStreamingLoop(); + } + }); + } else { + // Reuse existing stream if available + video.srcObject = cameraStream; + startStreamingLoop(); + } + } +} + +function startStreamingLoop() { + isVideoStreaming = true; + streamVideoButton.textContent = "⏹️ Stop Video"; + streamVideoButton.classList.add("active"); + addSystemMessage("Video streaming started"); + + // 1 FPS = 1000ms interval + videoStreamInterval = setInterval(captureAndSendFrame, 1000); +} + +// Event listeners +cameraButton.addEventListener("click", openCameraPreview); +streamVideoButton.addEventListener("click", toggleVideoStreaming); +closeCameraModal.addEventListener("click", closeCameraPreview); +cancelCamera.addEventListener("click", closeCameraPreview); +captureImageBtn.addEventListener("click", captureImageFromPreview); + +sendFileButton.addEventListener("click", () => { + fileInput.click(); +}); + +fileInput.addEventListener("change", (event) => { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onloadend = () => { + const base64data = reader.result.split(',')[1]; + const mimeType = file.type; + + // Display the image in the chat + const imageBubble = createImageBubble(reader.result, true); + appendMessage(imageBubble); + scrollToBottom(); + + // Send to server + sendImage(base64data, mimeType); + + // Reset file input so the same file can be selected again + fileInput.value = ''; + + // Log to console + addConsoleEntry('outgoing', `File sent: ${file.name} (${file.size} bytes)`, { + name: file.name, + size: file.size, + type: mimeType + }, '📁', 'user'); + }; + reader.readAsDataURL(file); +}); + +// Close modal when clicking outside of it +cameraModal.addEventListener("click", (event) => { + if (event.target === cameraModal) { + closeCameraPreview(); + } +}); + +/** + * Audio handling + */ + +let audioPlayerNode; +let audioPlayerContext; +let audioRecorderNode; +let audioRecorderContext; +let micStream; + +// Import the audio worklets +import { startAudioPlayerWorklet } from "./audio-player.js"; +import { startAudioRecorderWorklet } from "./audio-recorder.js"; + +// Ensure audio player is started +function ensureAudioPlayerStarted() { + if (!audioPlayerContext) { + startAudioPlayerWorklet().then(([node, ctx]) => { + audioPlayerNode = node; + audioPlayerContext = ctx; + // Drain audio buffer + while (audioBuffer.length > 0) { + audioPlayerNode.port.postMessage(audioBuffer.shift()); + } + }); + } else if (audioPlayerContext.state === 'suspended') { + audioPlayerContext.resume(); + } +} + +// Start audio +function startAudio() { + // Start audio output + ensureAudioPlayerStarted(); + // Start audio input + return startAudioRecorderWorklet(audioRecorderHandler).then( + ([node, ctx, stream]) => { + audioRecorderNode = node; + audioRecorderContext = ctx; + micStream = stream; + } + ); +} + +// Toggle audio streaming +async function toggleAudioStreaming() { + if (is_audio) { + // Stop audio + is_audio = false; + if (micStream) { + micStream.getTracks().forEach(track => track.stop()); + micStream = null; + } + if (audioPlayerNode) { + audioPlayerNode.port.postMessage({ command: "endOfAudio" }); + } + if (audioRecorderContext) { + audioRecorderContext.close(); + audioRecorderContext = null; + } + // Keep audioPlayerContext open so we can still hear the agent + startAudioButton.textContent = "🎤 Start Voice"; + startAudioButton.classList.remove("active"); + addSystemMessage("Audio streaming stopped"); + addConsoleEntry('outgoing', 'Audio Mode Disabled', { status: 'Audio stopped' }, '🎤', 'system'); + } else { + // Start audio + try { + await startAudio(); + is_audio = true; + startAudioButton.textContent = "⏹️ Stop Voice"; + startAudioButton.classList.add("active"); + addSystemMessage("Audio mode enabled - you can now speak to the agent"); + addConsoleEntry('outgoing', 'Audio Mode Enabled', { + status: 'Audio worklets started', + message: 'Microphone active - audio input will be sent to agent' + }, '🎤', 'system'); + } catch (error) { + console.error("Failed to start audio:", error); + addSystemMessage(`Failed to start audio: ${error.message}`); + // Reset state + is_audio = false; + startAudioButton.textContent = "🎤 Start Voice"; + startAudioButton.classList.remove("active"); + addConsoleEntry('error', 'Failed to enable audio mode', { error: error.message }, '⚠️', 'system'); + } + } +} + +const startAudioButton = document.getElementById("startAudioButton"); +startAudioButton.addEventListener("click", toggleAudioStreaming); + +// Audio recorder handler +function audioRecorderHandler(pcmData) { + if (websocket && websocket.readyState === WebSocket.OPEN && is_audio) { + // Send audio as binary WebSocket frame (more efficient than base64 JSON) + websocket.send(pcmData); + console.log("[CLIENT TO AGENT] Sent audio chunk: %s bytes", pcmData.byteLength); + + // Log to console panel (optional, can be noisy with frequent audio chunks) + // addConsoleEntry('outgoing', `Audio chunk: ${pcmData.byteLength} bytes`); + } +} diff --git a/examples/bidi/static/js/audio-player.js b/examples/bidi/static/js/audio-player.js new file mode 100644 index 000000000..f7f235e01 --- /dev/null +++ b/examples/bidi/static/js/audio-player.js @@ -0,0 +1,24 @@ +/** + * Audio Player Worklet + */ + +export async function startAudioPlayerWorklet() { + // 1. Create an AudioContext + const audioContext = new AudioContext({ + sampleRate: 24000 + }); + + + // 2. Load your custom processor code + const workletURL = new URL('./pcm-player-processor.js', import.meta.url); + await audioContext.audioWorklet.addModule(workletURL); + + // 3. Create an AudioWorkletNode + const audioPlayerNode = new AudioWorkletNode(audioContext, 'pcm-player-processor'); + + // 4. Connect to the destination + audioPlayerNode.connect(audioContext.destination); + + // The audioPlayerNode.port is how we send messages (audio data) to the processor + return [audioPlayerNode, audioContext]; +} \ No newline at end of file diff --git a/examples/bidi/static/js/audio-recorder.js b/examples/bidi/static/js/audio-recorder.js new file mode 100644 index 000000000..876932a2f --- /dev/null +++ b/examples/bidi/static/js/audio-recorder.js @@ -0,0 +1,58 @@ +/** + * Audio Recorder Worklet + */ + +let micStream; + +export async function startAudioRecorderWorklet(audioRecorderHandler) { + // Create an AudioContext + const audioRecorderContext = new AudioContext({ sampleRate: 16000 }); + console.log("AudioContext sample rate:", audioRecorderContext.sampleRate); + + // Load the AudioWorklet module + const workletURL = new URL("./pcm-recorder-processor.js", import.meta.url); + await audioRecorderContext.audioWorklet.addModule(workletURL); + + // Request access to the microphone + micStream = await navigator.mediaDevices.getUserMedia({ + audio: { channelCount: 1 }, + }); + const source = audioRecorderContext.createMediaStreamSource(micStream); + + // Create an AudioWorkletNode that uses the PCMProcessor + const audioRecorderNode = new AudioWorkletNode( + audioRecorderContext, + "pcm-recorder-processor" + ); + + // Connect the microphone source to the worklet. + source.connect(audioRecorderNode); + audioRecorderNode.port.onmessage = (event) => { + // Convert to 16-bit PCM + const pcmData = convertFloat32ToPCM(event.data); + + // Send the PCM data to the handler. + audioRecorderHandler(pcmData); + }; + return [audioRecorderNode, audioRecorderContext, micStream]; +} + +/** + * Stop the microphone. + */ +export function stopMicrophone(micStream) { + micStream.getTracks().forEach((track) => track.stop()); + console.log("stopMicrophone(): Microphone stopped."); +} + +// Convert Float32 samples to 16-bit PCM. +function convertFloat32ToPCM(inputData) { + // Create an Int16Array of the same length. + const pcm16 = new Int16Array(inputData.length); + for (let i = 0; i < inputData.length; i++) { + // Multiply by 0x7fff (32767) to scale the float value to 16-bit PCM range. + pcm16[i] = inputData[i] * 0x7fff; + } + // Return the underlying ArrayBuffer. + return pcm16.buffer; +} \ No newline at end of file diff --git a/examples/bidi/static/js/pcm-player-processor.js b/examples/bidi/static/js/pcm-player-processor.js new file mode 100644 index 000000000..7e162362d --- /dev/null +++ b/examples/bidi/static/js/pcm-player-processor.js @@ -0,0 +1,75 @@ +/** + * An audio worklet processor that stores the PCM audio data sent from the main thread + * to a buffer and plays it. + */ +class PCMPlayerProcessor extends AudioWorkletProcessor { + constructor() { + super(); + + // Init buffer + this.bufferSize = 24000 * 180; // 24kHz x 180 seconds + this.buffer = new Float32Array(this.bufferSize); + this.writeIndex = 0; + this.readIndex = 0; + + // Handle incoming messages from main thread + this.port.onmessage = (event) => { + // Reset the buffer when 'endOfAudio' message received + if (event.data.command === 'endOfAudio') { + this.readIndex = this.writeIndex; // Clear the buffer + console.log("endOfAudio received, clearing the buffer."); + return; + } + + // Decode the base64 data to int16 array. + const int16Samples = new Int16Array(event.data); + + // Add the audio data to the buffer + this._enqueue(int16Samples); + }; + } + + // Push incoming Int16 data into our ring buffer. + _enqueue(int16Samples) { + for (let i = 0; i < int16Samples.length; i++) { + // Convert 16-bit integer to float in [-1, 1] + const floatVal = int16Samples[i] / 32768; + + // Store in ring buffer for left channel only (mono) + this.buffer[this.writeIndex] = floatVal; + this.writeIndex = (this.writeIndex + 1) % this.bufferSize; + + // Overflow handling (overwrite oldest samples) + if (this.writeIndex === this.readIndex) { + this.readIndex = (this.readIndex + 1) % this.bufferSize; + } + } + } + + // The system calls `process()` ~128 samples at a time (depending on the browser). + // We fill the output buffers from our ring buffer. + process(inputs, outputs, parameters) { + + // Write a frame to the output + const output = outputs[0]; + const framesPerBlock = output[0].length; + for (let frame = 0; frame < framesPerBlock; frame++) { + + // Write the sample(s) into the output buffer + output[0][frame] = this.buffer[this.readIndex]; // left channel + if (output.length > 1) { + output[1][frame] = this.buffer[this.readIndex]; // right channel + } + + // Move the read index forward unless underflowing + if (this.readIndex != this.writeIndex) { + this.readIndex = (this.readIndex + 1) % this.bufferSize; + } + } + + // Returning true tells the system to keep the processor alive + return true; + } +} + +registerProcessor('pcm-player-processor', PCMPlayerProcessor); diff --git a/examples/bidi/static/js/pcm-recorder-processor.js b/examples/bidi/static/js/pcm-recorder-processor.js new file mode 100644 index 000000000..585d67491 --- /dev/null +++ b/examples/bidi/static/js/pcm-recorder-processor.js @@ -0,0 +1,18 @@ +class PCMProcessor extends AudioWorkletProcessor { + constructor() { + super(); + } + + process(inputs, outputs, parameters) { + if (inputs.length > 0 && inputs[0].length > 0) { + // Use the first channel + const inputChannel = inputs[0][0]; + // Copy the buffer to avoid issues with recycled memory + const inputCopy = new Float32Array(inputChannel); + this.port.postMessage(inputCopy); + } + return true; + } +} + +registerProcessor("pcm-recorder-processor", PCMProcessor); \ No newline at end of file From 3954b83598249a48311821ddb66f5c3720720c05 Mon Sep 17 00:00:00 2001 From: Artsiom Shut <63152812+foxfrikses@users.noreply.github.com> Date: Mon, 18 May 2026 12:23:02 +0200 Subject: [PATCH 44/86] fix: Stop ignoring request Decode error in runtime. (#851) When d.Decode returned an error, we were overriding it (with nil in most cases). This was not intended. The documentation for http.Request.Body mentions: > The Server will close the request body. > The ServeHTTP Handler does not need to. Given there is no need to Close the Body in the first place, we just remove this defer. --- server/adkrest/controllers/runtime.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/adkrest/controllers/runtime.go b/server/adkrest/controllers/runtime.go index e97940637..9062534d0 100644 --- a/server/adkrest/controllers/runtime.go +++ b/server/adkrest/controllers/runtime.go @@ -230,11 +230,8 @@ func (c *RuntimeAPIController) getRunner(req models.RunAgentRequest) (*runner.Ru }, nil } -func decodeRequestBody(req *http.Request) (decodedReq models.RunAgentRequest, err error) { +func decodeRequestBody(req *http.Request) (models.RunAgentRequest, error) { var runAgentRequest models.RunAgentRequest - defer func() { - err = req.Body.Close() - }() d := json.NewDecoder(req.Body) d.DisallowUnknownFields() if err := d.Decode(&runAgentRequest); err != nil { From ead8568e6a48ca652e0d39007ba9b4ebd509a26f Mon Sep 17 00:00:00 2001 From: Artsiom Shut <63152812+foxfrikses@users.noreply.github.com> Date: Mon, 18 May 2026 14:43:43 +0200 Subject: [PATCH 45/86] fix: Propagate StateDelta for non-streaming agent. (#854) While StateDelta from agent request is propagated to runner in SSE Handler, it's silently ignored in non-SSE Handler. Make it propagate StateDelta in both handlers. --- server/adkrest/controllers/runtime.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/adkrest/controllers/runtime.go b/server/adkrest/controllers/runtime.go index 9062534d0..7eaea1119 100644 --- a/server/adkrest/controllers/runtime.go +++ b/server/adkrest/controllers/runtime.go @@ -79,7 +79,11 @@ func (c *RuntimeAPIController) runAgent(ctx context.Context, runAgentRequest mod return nil, err } - resp := r.Run(ctx, runAgentRequest.UserId, runAgentRequest.SessionId, &runAgentRequest.NewMessage, *rCfg) + var opts []runner.RunOption + if runAgentRequest.StateDelta != nil { + opts = append(opts, runner.WithStateDelta(*runAgentRequest.StateDelta)) + } + resp := r.Run(ctx, runAgentRequest.UserId, runAgentRequest.SessionId, &runAgentRequest.NewMessage, *rCfg, opts...) var events []*session.Event for event, err := range resp { From 9eb7630f38d1673d1005563477d80f37ad468175 Mon Sep 17 00:00:00 2001 From: Artsiom Shut <63152812+foxfrikses@users.noreply.github.com> Date: Mon, 18 May 2026 14:52:59 +0200 Subject: [PATCH 46/86] fix: Prevent nil deref when a tool doesn't implement tool.Tool interface. (#855) If a tool doesn't implement tool.Tool interface, runOneStep yields an error. If that error is ignored (yield returns true), we need to skip this tool. What we were doing so far, however, is we added nil to the map of tools. If that ever happen in runtime, the code will later crash with nil deref. I don't know if that could actually happen in context of where runOneStep is used, but anyway, let's handle it properly: added continue, which will skip adding nil to the map and will go to the next tool. --- internal/llminternal/base_flow.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index e9c200652..b7e009e70 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -470,6 +470,7 @@ func (f *Flow) runOneStep(ctx agent.InvocationContext) iter.Seq2[*session.Event, if !yield(nil, fmt.Errorf("unexpected tool type %T for tool %v", v, k)) { return } + continue } tools[k] = tool } From 2b79d381812a6b9b58559b1fe4e706564d23bf6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Mon, 18 May 2026 17:05:33 +0100 Subject: [PATCH 47/86] feat(live): Add session resumption (#837) * feat(live): Add core bidirectional streaming support Brings over the core files for live agent execution, including RunLive implementation, base flow updates, and session management. * lint fixes * feat(live): Add session resumption * lint fix * Change prints to log * Add invocation_context comments and simplify base flow context type cast. --- internal/context/context_test.go | 18 ++++++++++++++++++ internal/context/invocation_context.go | 21 +++++++++++++++++---- internal/llminternal/base_flow.go | 23 ++++++++++++++++++++++- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/internal/context/context_test.go b/internal/context/context_test.go index 8a18e20ea..8552375ac 100644 --- a/internal/context/context_test.go +++ b/internal/context/context_test.go @@ -64,3 +64,21 @@ func TestWithContext(t *testing.T) { t.Errorf("WithContext() mismatch (-want +got):\n%s", diff) } } + +func TestInvocationContext_LiveSessionResumptionHandle(t *testing.T) { + inv := NewInvocationContext(t.Context(), InvocationContextParams{}) + + iCtx, ok := inv.(*InvocationContext) + if !ok { + t.Fatalf("NewInvocationContext did not return *InvocationContext") + } + + if iCtx.LiveSessionResumptionHandle() != "" { + t.Errorf("expected empty handle, got %q", iCtx.LiveSessionResumptionHandle()) + } + + iCtx.SetLiveSessionResumptionHandle("test-handle") + if iCtx.LiveSessionResumptionHandle() != "test-handle" { + t.Errorf("expected test-handle, got %q", iCtx.LiveSessionResumptionHandle()) + } +} diff --git a/internal/context/invocation_context.go b/internal/context/invocation_context.go index c050fc11a..47ca280a7 100644 --- a/internal/context/invocation_context.go +++ b/internal/context/invocation_context.go @@ -32,10 +32,11 @@ type InvocationContextParams struct { Branch string Agent agent.Agent - UserContent *genai.Content - RunConfig *agent.RunConfig - EndInvocation bool - InvocationID string + UserContent *genai.Content + RunConfig *agent.RunConfig + EndInvocation bool + InvocationID string + LiveSessionResumptionHandle string } func NewInvocationContext(ctx context.Context, params InvocationContextParams) agent.InvocationContext { @@ -94,6 +95,18 @@ func (c *InvocationContext) Ended() bool { return c.params.EndInvocation } +// LiveSessionResumptionHandle returns the active live session's resumption handle. +// This handle is used to reconnect and resume a bidirectional streaming session with the model. +func (c *InvocationContext) LiveSessionResumptionHandle() string { + return c.params.LiveSessionResumptionHandle +} + +// SetLiveSessionResumptionHandle stores the latest resumption handle received from the model. +// This allows subsequent reconnection attempts in the live loop to resume the same session state. +func (c *InvocationContext) SetLiveSessionResumptionHandle(handle string) { + c.params.LiveSessionResumptionHandle = handle +} + func (c *InvocationContext) WithContext(ctx context.Context) agent.InvocationContext { newCtx := *c newCtx.Context = ctx diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index b7e009e70..54fee3b7e 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -256,11 +256,26 @@ func (f *Flow) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq strings.Contains(errStr, "GoAway") } + iCtx, isIContext := ctx.(*icontext.InvocationContext) + for { + if isIContext { + handle := iCtx.LiveSessionResumptionHandle() + if handle != "" { + if liveConnectConfig.SessionResumption == nil { + liveConnectConfig.SessionResumption = &genai.SessionResumptionConfig{} + } + liveConnectConfig.SessionResumption.Handle = handle + if googlellm.GetGoogleLLMVariant(f.Model) == genai.BackendVertexAI { + liveConnectConfig.SessionResumption.Transparent = true + } + } + } + connCtx, cancelConn := context.WithCancel(ctx) if liveConnectConfig.SessionResumption != nil { - log.Printf("connecting with %s\n", liveConnectConfig.SessionResumption.Handle) + log.Printf("connecting with live session handle: %s\n", liveConnectConfig.SessionResumption.Handle) } liveSession, err := client.Live.Connect(connCtx, f.Model.Name(), liveConnectConfig) if err != nil { @@ -298,6 +313,12 @@ func (f *Flow) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq return } if resp != nil { + if resp.SessionResumptionHandle != "" { + if isIContext { + log.Printf("received session resumption handle: %s\n", resp.SessionResumptionHandle) + iCtx.SetLiveSessionResumptionHandle(resp.SessionResumptionHandle) + } + } ev := session.NewEvent(ctx.InvocationID()) ev.Author = ctx.Agent().Name() ev.LLMResponse = *resp From e5c3f51091121b8098126392b28fd6012faa076c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Tue, 19 May 2026 11:10:49 +0100 Subject: [PATCH 48/86] feat(live): Add streaming tools (#836) * feat(live): Add streaming tools * Change sample static folder to dynamic path * lint fix * lint fix * Clean examples uiMode if --- examples/bidi/main.go | 44 ++- examples/bidi/sequential/main.go | 42 +-- examples/bidi/streaming_tool/main.go | 143 ++++++++ internal/llminternal/base_flow.go | 180 ++++++++-- .../request_confirmation_processor.go | 2 +- internal/llminternal/streaming_tool_test.go | 322 ++++++++++++++++++ tool/functiontool/streaming_function.go | 180 ++++++++++ 7 files changed, 842 insertions(+), 71 deletions(-) create mode 100644 examples/bidi/streaming_tool/main.go create mode 100644 internal/llminternal/streaming_tool_test.go create mode 100644 tool/functiontool/streaming_function.go diff --git a/examples/bidi/main.go b/examples/bidi/main.go index 3710164a2..9fcef6418 100644 --- a/examples/bidi/main.go +++ b/examples/bidi/main.go @@ -41,8 +41,6 @@ func main() { log.SetOutput(os.Stdout) ctx := context.Background() - // gemini-3.1-flash-live-preview - // gemini-2.5-flash-native-audio-preview-12-2025 model, err := gemini.NewModel(ctx, "gemini-3.1-flash-live-preview", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) @@ -80,31 +78,27 @@ func main() { log.Fatalf("Failed to create agent: %v", err) } - uiMode := true + // Create runner + ss := session.InMemoryService() - if uiMode { - // Create runner - ss := session.InMemoryService() - - _, filename, _, ok := runtime.Caller(0) - if !ok { - log.Fatal("No caller information") - } - staticDir := filepath.Join(filepath.Dir(filename), "static") - fs := http.FileServer(http.Dir(staticDir)) - http.Handle("/", fs) - http.Handle("/static/", http.StripPrefix("/static/", fs)) + _, filename, _, ok := runtime.Caller(0) + if !ok { + log.Fatal("No caller information") + } + staticDir := filepath.Join(filepath.Dir(filename), "static") + fs := http.FileServer(http.Dir(staticDir)) + http.Handle("/", fs) + http.Handle("/static/", http.StripPrefix("/static/", fs)) - controller := controllers.NewRuntimeAPIController(ss, nil, agent.NewSingleLoader(a), nil, 0, runner.PluginConfig{}, true) + controller := controllers.NewRuntimeAPIController(ss, nil, agent.NewSingleLoader(a), nil, 0, runner.PluginConfig{}, true) - http.HandleFunc("/run_live", func(w http.ResponseWriter, req *http.Request) { - err := controller.RunLiveHandler(w, req) - if err != nil { - log.Printf("RunLiveHandler failed: %v", err) - } - }) + http.HandleFunc("/run_live", func(w http.ResponseWriter, req *http.Request) { + err := controller.RunLiveHandler(w, req) + if err != nil { + log.Printf("RunLiveHandler failed: %v", err) + } + }) - fmt.Println("Serving UI on http://localhost:8081") - log.Fatal(http.ListenAndServe(":8081", nil)) - } + fmt.Println("Serving UI on http://localhost:8081") + log.Fatal(http.ListenAndServe(":8081", nil)) } diff --git a/examples/bidi/sequential/main.go b/examples/bidi/sequential/main.go index af025ad66..8631c68bb 100644 --- a/examples/bidi/sequential/main.go +++ b/examples/bidi/sequential/main.go @@ -41,8 +41,6 @@ func main() { log.SetOutput(os.Stdout) ctx := context.Background() - // gemini-3.1-flash-live-preview - // gemini-2.5-flash-native-audio-preview-12-2025 model, err := gemini.NewModel(ctx, "gemini-2.5-flash-native-audio-preview-12-2025", &genai.ClientConfig{ APIKey: os.Getenv("GOOGLE_API_KEY"), }) @@ -88,30 +86,26 @@ func main() { log.Fatalf("Failed to create sequential agent: %v", err) } - uiMode := true + ss := session.InMemoryService() - if uiMode { - ss := session.InMemoryService() - - _, filename, _, ok := runtime.Caller(0) - if !ok { - log.Fatal("No caller information") - } - staticDir := filepath.Join(filepath.Dir(filename), "../static") - fs := http.FileServer(http.Dir(staticDir)) - http.Handle("/", fs) - http.Handle("/static/", http.StripPrefix("/static/", fs)) + _, filename, _, ok := runtime.Caller(0) + if !ok { + log.Fatal("No caller information") + } + staticDir := filepath.Join(filepath.Dir(filename), "../static") + fs := http.FileServer(http.Dir(staticDir)) + http.Handle("/", fs) + http.Handle("/static/", http.StripPrefix("/static/", fs)) - controller := controllers.NewRuntimeAPIController(ss, nil, agent.NewSingleLoader(seqAgent), nil, 0, runner.PluginConfig{}, true) + controller := controllers.NewRuntimeAPIController(ss, nil, agent.NewSingleLoader(seqAgent), nil, 0, runner.PluginConfig{}, true) - http.HandleFunc("/run_live", func(w http.ResponseWriter, req *http.Request) { - err := controller.RunLiveHandler(w, req) - if err != nil { - log.Printf("RunLiveHandler failed: %v", err) - } - }) + http.HandleFunc("/run_live", func(w http.ResponseWriter, req *http.Request) { + err := controller.RunLiveHandler(w, req) + if err != nil { + log.Printf("RunLiveHandler failed: %v", err) + } + }) - fmt.Println("Serving UI on http://localhost:8081") - log.Fatal(http.ListenAndServe(":8081", nil)) - } + fmt.Println("Serving UI on http://localhost:8081") + log.Fatal(http.ListenAndServe(":8081", nil)) } diff --git a/examples/bidi/streaming_tool/main.go b/examples/bidi/streaming_tool/main.go new file mode 100644 index 000000000..6f0300de7 --- /dev/null +++ b/examples/bidi/streaming_tool/main.go @@ -0,0 +1,143 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package provides a quickstart ADK agent. +package main + +import ( + "context" + "fmt" + "iter" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "time" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/model/gemini" + "google.golang.org/adk/runner" + "google.golang.org/adk/server/adkrest/controllers" + "google.golang.org/adk/session" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" +) + +func main() { + log.SetOutput(os.Stdout) + ctx := context.Background() + + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-live-preview", &genai.ClientConfig{ + APIKey: os.Getenv("GOOGLE_API_KEY"), + }) + if err != nil { + log.Fatalf("Failed to create model: %v", err) + } + + // Define a streaming tool that yields numbers. + counterTool, err := functiontool.NewStreaming(functiontool.Config{ + Name: "count_to", + Description: "Counts to a specified number, yielding each number with a delay.", + }, func(ctx tool.Context, args struct { + N int `json:"n"` + }, + ) iter.Seq2[string, error] { + return func(yield func(string, error) bool) { + for i := 1; i <= args.N; i++ { + time.Sleep(5000 * time.Millisecond) + if !yield(fmt.Sprintf("Count: %d", i), nil) { + return + } + } + } + }) + if err != nil { + log.Fatalf("Failed to create streaming tool: %v", err) + } + + // Define a standard tool to stop streaming. + // Note: While defined here so that the model is aware of the tool declaration, + // during live bidirectional streaming the ADK Live Control Plane intercepts + // calls to "stop_streaming" dynamically. It will bulk-cancel the context for + // all running background goroutines executing that specific streaming tool name. + stopTool, err := functiontool.New(functiontool.Config{ + Name: "stop_streaming", + Description: "Stops a running streaming function.", + }, func(ctx tool.Context, args struct { + FunctionName string `json:"function_name"` + }, + ) (map[string]any, error) { + return map[string]any{"status": fmt.Sprintf("Requested to stop %s", args.FunctionName)}, nil + }) + if err != nil { + log.Fatalf("Failed to create stop tool: %v", err) + } + + // Define a function tool to check divisibility. + checkDivisibleTool, err := functiontool.New(functiontool.Config{ + Name: "check_divisible", + Description: "Checks if a number is divisible by another number.", + }, func(ctx tool.Context, args struct { + Number int `json:"number"` + Divisor int `json:"divisor"` + }, + ) (map[string]any, error) { + if args.Divisor == 0 { + return map[string]any{"result": false, "error": "cannot divide by zero"}, nil + } + fmt.Printf("Dividing %d by %d\n", args.Number, args.Divisor) + return map[string]any{"result": args.Number%args.Divisor == 0}, nil + }) + if err != nil { + log.Fatalf("Failed to create check divisible tool: %v", err) + } + + a, err := llmagent.New(llmagent.Config{ + Name: "bidi-demo", + Model: model, + Instruction: "You are a helpful assistant with a streaming tool 'count_to'. Always use it when asked to count. Wait for the tool results, and when you recieve them you should say the number, if it is divisible by 3 you should not say the number and instead say Fizz and if it is divisible by 5 you should say Buzz, if it is divisible by both 3 and 5 you should say FizzBuzz. Always use the check_divisible tool", + Tools: []tool.Tool{counterTool, stopTool, checkDivisibleTool}, + }) + if err != nil { + log.Fatalf("Failed to create agent: %v", err) + } + + // Create runner + ss := session.InMemoryService() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + log.Fatal("No caller information") + } + staticDir := filepath.Join(filepath.Dir(filename), "../static") + fs := http.FileServer(http.Dir(staticDir)) + http.Handle("/", fs) + http.Handle("/static/", http.StripPrefix("/static/", fs)) + + controller := controllers.NewRuntimeAPIController(ss, nil, agent.NewSingleLoader(a), nil, 0, runner.PluginConfig{}, true) + + http.HandleFunc("/run_live", func(w http.ResponseWriter, req *http.Request) { + err := controller.RunLiveHandler(w, req) + if err != nil { + log.Printf("RunLiveHandler failed: %v", err) + } + }) + + fmt.Println("Serving UI on http://localhost:8081") + log.Fatal(http.ListenAndServe(":8081", nil)) +} diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index 54fee3b7e..f69dcba29 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -126,11 +126,62 @@ func (f *Flow) Run(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] } } +type activeTask struct { + callID string + cancel context.CancelFunc +} + type liveSessionImpl struct { - inputCh chan agent.LiveRequest - outputCh chan eventOrError - done chan struct{} - closeOnce sync.Once + inputCh chan agent.LiveRequest + outputCh chan eventOrError + done chan struct{} + closeOnce sync.Once + mu sync.Mutex + activeTools map[string][]activeTask +} + +func (s *liveSessionImpl) RegisterStreamingTool(toolName, callID string, cancel context.CancelFunc) { + s.mu.Lock() + defer s.mu.Unlock() + if s.activeTools == nil { + s.activeTools = make(map[string][]activeTask) + } + s.activeTools[toolName] = append(s.activeTools[toolName], activeTask{ + callID: callID, + cancel: cancel, + }) +} + +func (s *liveSessionImpl) UnregisterStreamingTool(toolName, callID string) { + s.mu.Lock() + defer s.mu.Unlock() + tasks, exists := s.activeTools[toolName] + if !exists { + return + } + for i, task := range tasks { + if task.callID == callID { + s.activeTools[toolName] = append(tasks[:i], tasks[i+1:]...) + break + } + } + if len(s.activeTools[toolName]) == 0 { + delete(s.activeTools, toolName) + } +} + +func (s *liveSessionImpl) CancelAllStreamingTools(toolName string) bool { + s.mu.Lock() + defer s.mu.Unlock() + tasks, exists := s.activeTools[toolName] + if !exists || len(tasks) == 0 { + return false + } + for _, task := range tasks { + task.cancel() + } + delete(s.activeTools, toolName) + return true } type eventOrError struct { @@ -372,7 +423,7 @@ func (f *Flow) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq for _, t := range f.Tools { tools[t.Name()] = t } - respEv, err := f.handleFunctionCalls(ctx, tools, &ev.LLMResponse, nil) + respEv, err := f.handleFunctionCalls(ctx, tools, &ev.LLMResponse, nil, sess) if err != nil { sess.pushError(err) cleanup() @@ -508,7 +559,7 @@ func (f *Flow) runOneStep(ctx agent.InvocationContext) iter.Seq2[*session.Event, } // Handle function calls. - ev, err := f.handleFunctionCalls(ctx, tools, resp.LLMResponse, nil) + ev, err := f.handleFunctionCalls(ctx, tools, resp.LLMResponse, nil, nil) if err != nil { yield(nil, err) return @@ -893,11 +944,32 @@ Suggested fixes: - Check for typos in function name`, toolName, joinedTools) } +type cancelledToolContext struct { + tool.Context + cancelCtx context.Context +} + +func (c *cancelledToolContext) Done() <-chan struct{} { + return c.cancelCtx.Done() +} + +func (c *cancelledToolContext) Err() error { + return c.cancelCtx.Err() +} + +func (c *cancelledToolContext) Deadline() (deadline time.Time, ok bool) { + return c.cancelCtx.Deadline() +} + +func (c *cancelledToolContext) Value(key any) any { + return c.cancelCtx.Value(key) +} + // handleFunctionCalls calls the functions and returns the function response event. // // TODO: accept filters to include/exclude function calls. // TODO: check feasibility of running tool.Run concurrently. -func (f *Flow) handleFunctionCalls(ctx agent.InvocationContext, toolsDict map[string]tool.Tool, resp *model.LLMResponse, toolConfirmations map[string]*toolconfirmation.ToolConfirmation) (mergedEvent *session.Event, err error) { +func (f *Flow) handleFunctionCalls(ctx agent.InvocationContext, toolsDict map[string]tool.Tool, resp *model.LLMResponse, toolConfirmations map[string]*toolconfirmation.ToolConfirmation, liveSess agent.LiveSession) (mergedEvent *session.Event, err error) { fnCalls := utils.FunctionCalls(resp.Content) toolNames := slices.Collect(maps.Keys(toolsDict)) @@ -933,22 +1005,88 @@ func (f *Flow) handleFunctionCalls(ctx agent.InvocationContext, toolsDict map[st var result map[string]any var curTool tool.Tool - var found bool - curTool, found = toolsDict[fnCall.Name] - if !found { - err := newToolNotFoundError(fnCall.Name, toolNames) - result, err = f.runOnToolErrorCallbacks(toolCtx, &fakeTool{name: fnCall.Name}, fnCall.Args, err) - if err != nil { - result = map[string]any{"error": err.Error()} - } - } else if funcTool, ok := curTool.(toolinternal.FunctionTool); !ok { - err := newToolNotFoundError(fnCall.Name, toolNames) - result, err = f.runOnToolErrorCallbacks(toolCtx, &fakeTool{name: fnCall.Name}, fnCall.Args, err) - if err != nil { - result = map[string]any{"error": err.Error()} + if fnCall.Name == "stop_streaming" { + funcToStop, _ := fnCall.Args["function_name"].(string) + var status string + if impl, ok := liveSess.(*liveSessionImpl); ok && impl.CancelAllStreamingTools(funcToStop) { + status = fmt.Sprintf("Successfully stopped all running instances of %s", funcToStop) + } else { + status = fmt.Sprintf("No active streaming function named %s found", funcToStop) } + result = map[string]any{"status": status} } else { - result = f.callTool(toolCtx, funcTool, fnCall.Args) + var found bool + curTool, found = toolsDict[fnCall.Name] + if !found { + err := newToolNotFoundError(fnCall.Name, toolNames) + result, err = f.runOnToolErrorCallbacks(toolCtx, &fakeTool{name: fnCall.Name}, fnCall.Args, err) + if err != nil { + result = map[string]any{"error": err.Error()} + } + } else if streamTool, ok := curTool.(toolinternal.StreamingFunctionTool); ok { + if liveSess != nil { + result = map[string]any{"status": "The function is running asynchronously and the results are pending."} + cancelCtx, cancel := context.WithCancel(toolCtx) + cancelToolCtx := &cancelledToolContext{ + Context: toolCtx, + cancelCtx: cancelCtx, + } + if impl, ok := liveSess.(*liveSessionImpl); ok { + impl.RegisterStreamingTool(streamTool.Name(), fnCall.ID, cancel) + } + go func() { + defer func() { + if impl, ok := liveSess.(*liveSessionImpl); ok { + impl.UnregisterStreamingTool(streamTool.Name(), fnCall.ID) + } + cancel() + }() + for chunk, err := range streamTool.RunStream(cancelToolCtx, fnCall.Args) { + select { + case <-cancelCtx.Done(): + return + default: + } + if err != nil { + fmt.Printf("Error in streaming tool %s: %v\n", streamTool.Name(), err) + return + } + updatedContent := &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + { + Text: fmt.Sprintf("Function %s returned: %s", streamTool.Name(), chunk), + }, + }, + } + if err := liveSess.Send(agent.LiveRequest{Content: updatedContent}); err != nil { + fmt.Printf("Failed to send content from streaming tool %s: %v\n", streamTool.Name(), err) + return + } + } + }() + } else { + var sb strings.Builder + for chunk, err := range streamTool.RunStream(toolCtx, fnCall.Args) { + if err != nil { + result = map[string]any{"error": err.Error()} + break + } + sb.WriteString(chunk) + } + if result == nil { + result = map[string]any{"result": sb.String()} + } + } + } else if funcTool, ok := curTool.(toolinternal.FunctionTool); !ok { + err := newToolNotFoundError(fnCall.Name, toolNames) + result, err = f.runOnToolErrorCallbacks(toolCtx, &fakeTool{name: fnCall.Name}, fnCall.Args, err) + if err != nil { + result = map[string]any{"error": err.Error()} + } + } else { + result = f.callTool(toolCtx, funcTool, fnCall.Args) + } } // TODO: handle long-running tool. diff --git a/internal/llminternal/request_confirmation_processor.go b/internal/llminternal/request_confirmation_processor.go index 16aab5304..b87fad39f 100644 --- a/internal/llminternal/request_confirmation_processor.go +++ b/internal/llminternal/request_confirmation_processor.go @@ -163,7 +163,7 @@ func RequestConfirmationRequestProcessor(ctx agent.InvocationContext, req *model ev, err := f.handleFunctionCalls(ctx, toolsmap, &model.LLMResponse{ Content: &genai.Content{Parts: parts, Role: genai.RoleUser}, - }, toolsToResumeConfirmation) + }, toolsToResumeConfirmation, nil) if !yield(ev, err) { return } diff --git a/internal/llminternal/streaming_tool_test.go b/internal/llminternal/streaming_tool_test.go new file mode 100644 index 000000000..722f6c97e --- /dev/null +++ b/internal/llminternal/streaming_tool_test.go @@ -0,0 +1,322 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package llminternal + +import ( + "fmt" + "iter" + "sync" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "google.golang.org/genai" + + "google.golang.org/adk/agent" + icontext "google.golang.org/adk/internal/context" + "google.golang.org/adk/model" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" +) + +type mockLiveSession struct { + sendFunc func(agent.LiveRequest) error +} + +func (m *mockLiveSession) Send(req agent.LiveRequest) error { + if m.sendFunc != nil { + return m.sendFunc(req) + } + return nil +} + +func (m *mockLiveSession) Close() error { return nil } + +func TestHandleFunctionCalls_Streaming(t *testing.T) { + type Args struct { + Count int `json:"count"` + } + + handler := func(ctx tool.Context, args Args) iter.Seq2[string, error] { + return func(yield func(string, error) bool) { + for i := 0; i < args.Count; i++ { + if !yield(fmt.Sprintf("chunk %d", i), nil) { + return + } + } + } + } + + streamTool, err := functiontool.NewStreaming(functiontool.Config{ + Name: "test_stream", + Description: "streams chunks", + }, handler) + if err != nil { + t.Fatal(err) + } + + toolsDict := map[string]tool.Tool{ + "test_stream": streamTool, + } + + resp := &model.LLMResponse{ + Content: &genai.Content{ + Parts: []*genai.Part{ + { + FunctionCall: &genai.FunctionCall{ + ID: "call_1", + Name: "test_stream", + Args: map[string]any{"count": 3}, + }, + }, + }, + Role: "model", + }, + } + + t.Run("Live Mode (Streaming)", func(t *testing.T) { + var receivedChunks []string + var mu sync.Mutex + var wg sync.WaitGroup + wg.Add(3) // We expect 3 chunks + + mockSess := &mockLiveSession{ + sendFunc: func(req agent.LiveRequest) error { + mu.Lock() + defer mu.Unlock() + if req.Content != nil && len(req.Content.Parts) > 0 { + receivedChunks = append(receivedChunks, req.Content.Parts[0].Text) + } + wg.Done() + return nil + }, + } + + invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{ + InvocationID: "inv_1", + Agent: &mockAgent{name: "agent_1"}, + }) + + flow := &Flow{ + Tools: []tool.Tool{streamTool}, + } + + mergedEvent, err := flow.handleFunctionCalls(invCtx, toolsDict, resp, nil, mockSess) + if err != nil { + t.Fatal(err) + } + + // Verify immediate response is pending + if mergedEvent == nil { + t.Fatal("expected non-nil mergedEvent") + } + if len(mergedEvent.LLMResponse.Content.Parts) != 1 { + t.Fatalf("expected 1 part, got %d", len(mergedEvent.LLMResponse.Content.Parts)) + } + respPart := mergedEvent.LLMResponse.Content.Parts[0].FunctionResponse + if respPart == nil { + t.Fatal("expected FunctionResponse part") + } + status, ok := respPart.Response["status"].(string) + if !ok || status != "The function is running asynchronously and the results are pending." { + t.Errorf("unexpected status: %v", respPart.Response["status"]) + } + + // Wait for background streaming to complete + wg.Wait() + + wantChunks := []string{ + "Function test_stream returned: chunk 0", + "Function test_stream returned: chunk 1", + "Function test_stream returned: chunk 2", + } + if diff := cmp.Diff(wantChunks, receivedChunks); diff != "" { + t.Errorf("unexpected chunks (-want +got):\n%s", diff) + } + }) + + t.Run("Non-Live Mode (Aggregation)", func(t *testing.T) { + invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{ + InvocationID: "inv_1", + Agent: &mockAgent{name: "agent_1"}, + }) + + flow := &Flow{ + Tools: []tool.Tool{streamTool}, + } + + mergedEvent, err := flow.handleFunctionCalls(invCtx, toolsDict, resp, nil, nil) + if err != nil { + t.Fatal(err) + } + + if mergedEvent == nil { + t.Fatal("expected non-nil mergedEvent") + } + if len(mergedEvent.LLMResponse.Content.Parts) != 1 { + t.Fatalf("expected 1 part, got %d", len(mergedEvent.LLMResponse.Content.Parts)) + } + respPart := mergedEvent.LLMResponse.Content.Parts[0].FunctionResponse + if respPart == nil { + t.Fatal("expected FunctionResponse part") + } + result, ok := respPart.Response["result"].(string) + if !ok || result != "chunk 0chunk 1chunk 2" { + t.Errorf("unexpected result: %v", respPart.Response["result"]) + } + }) +} + +func TestHandleFunctionCalls_LiveControlPlane(t *testing.T) { + type Args struct { + DelayMS int `json:"delay_ms"` + } + + var cancelCount int + var cancelMu sync.Mutex + + handler := func(ctx tool.Context, args Args) iter.Seq2[string, error] { + return func(yield func(string, error) bool) { + for i := 0; ; i++ { + time.Sleep(time.Millisecond) + select { + case <-ctx.Done(): + cancelMu.Lock() + cancelCount++ + cancelMu.Unlock() + return + default: + } + if !yield(fmt.Sprintf("number %d", i), nil) { + cancelMu.Lock() + cancelCount++ + cancelMu.Unlock() + return + } + } + } + } + + streamTool, err := functiontool.NewStreaming(functiontool.Config{ + Name: "count_forever", + Description: "counts indefinitely", + }, handler) + if err != nil { + t.Fatal(err) + } + + toolsDict := map[string]tool.Tool{ + "count_forever": streamTool, + } + + invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{ + InvocationID: "inv_1", + Agent: &mockAgent{name: "agent_1"}, + }) + + flow := &Flow{ + Tools: []tool.Tool{streamTool}, + } + + respStart := &model.LLMResponse{ + Content: &genai.Content{ + Parts: []*genai.Part{ + { + FunctionCall: &genai.FunctionCall{ + ID: "call_forever_1", + Name: "count_forever", + Args: map[string]any{"delay_ms": 1}, + }, + }, + { + FunctionCall: &genai.FunctionCall{ + ID: "call_forever_2", + Name: "count_forever", + Args: map[string]any{"delay_ms": 1}, + }, + }, + }, + Role: "model", + }, + } + + liveSess := newLiveSessionImpl() + liveSess.activeTools = make(map[string][]activeTask) + + go func() { + for range liveSess.inputCh { + } + }() + + _, err = flow.handleFunctionCalls(invCtx, toolsDict, respStart, nil, liveSess) + if err != nil { + t.Fatal(err) + } + + liveSess.mu.Lock() + tasks, exists := liveSess.activeTools["count_forever"] + liveSess.mu.Unlock() + if !exists || len(tasks) != 2 { + t.Fatalf("expected 2 active tasks, found: %v", tasks) + } + + respStop := &model.LLMResponse{ + Content: &genai.Content{ + Parts: []*genai.Part{ + { + FunctionCall: &genai.FunctionCall{ + ID: "call_stop_1", + Name: "stop_streaming", + Args: map[string]any{"function_name": "count_forever"}, + }, + }, + }, + Role: "model", + }, + } + + mergedStopEvent, err := flow.handleFunctionCalls(invCtx, toolsDict, respStop, nil, liveSess) + if err != nil { + t.Fatal(err) + } + + if mergedStopEvent == nil { + t.Fatal("expected non-nil mergedStopEvent") + } + respPart := mergedStopEvent.LLMResponse.Content.Parts[0].FunctionResponse + if respPart == nil { + t.Fatal("expected FunctionResponse part") + } + status, ok := respPart.Response["status"].(string) + if !ok || status != "Successfully stopped all running instances of count_forever" { + t.Errorf("unexpected stop status: %s", status) + } + + time.Sleep(50 * time.Millisecond) + + cancelMu.Lock() + gotCancels := cancelCount + cancelMu.Unlock() + if gotCancels != 2 { + t.Errorf("expected exactly 2 goroutines to be cancelled, got: %d", gotCancels) + } + + liveSess.mu.Lock() + tasksAfter := liveSess.activeTools["count_forever"] + liveSess.mu.Unlock() + if len(tasksAfter) != 0 { + t.Errorf("expected registry to be empty after cancellation, got: %v", tasksAfter) + } +} diff --git a/tool/functiontool/streaming_function.go b/tool/functiontool/streaming_function.go new file mode 100644 index 000000000..7a674f525 --- /dev/null +++ b/tool/functiontool/streaming_function.go @@ -0,0 +1,180 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package functiontool provides a tool that wraps a Go function. +package functiontool + +import ( + "fmt" + "iter" + "reflect" + "runtime/debug" + + "github.com/google/jsonschema-go/jsonschema" + "google.golang.org/genai" + + "google.golang.org/adk/internal/toolinternal/toolutils" + "google.golang.org/adk/internal/typeutil" + "google.golang.org/adk/model" + "google.golang.org/adk/tool" +) + +// StreamingFunc represents a Go function that streams results. +type StreamingFunc[TArgs any] func(tool.Context, TArgs) iter.Seq2[string, error] + +// NewStreaming creates a new streaming tool. +func NewStreaming[TArgs any](cfg Config, handler StreamingFunc[TArgs]) (tool.Tool, error) { + var zeroArgs TArgs + argsType := reflect.TypeOf(zeroArgs) + for argsType != nil && argsType.Kind() == reflect.Pointer { + argsType = argsType.Elem() + } + if argsType == nil || (argsType.Kind() != reflect.Struct && argsType.Kind() != reflect.Map) { + return nil, fmt.Errorf("input must be a struct or a map or a pointer to those types, but received: %v: %w", argsType, ErrInvalidArgument) + } + + ischema, err := resolvedSchema[TArgs](cfg.InputSchema) + if err != nil { + return nil, fmt.Errorf("failed to infer input schema: %w", err) + } + + var confirmWrapper func(TArgs) bool + + if cfg.RequireConfirmationProvider != nil { + fn, ok := cfg.RequireConfirmationProvider.(func(TArgs) bool) + if !ok { + return nil, fmt.Errorf("error RequireConfirmationProvider must be a function with signature func(%T) bool", *new(TArgs)) + } + confirmWrapper = fn + } + + return &streamingFunctionTool[TArgs]{ + cfg: cfg, + inputSchema: ischema, + handler: handler, + requireConfirmation: cfg.RequireConfirmation, + requireConfirmationProvider: confirmWrapper, + }, nil +} + +// streamingFunctionTool wraps a Go function that streams results. +type streamingFunctionTool[TArgs any] struct { + cfg Config + + // A JSON Schema object defining the expected parameters for the tool. + inputSchema *jsonschema.Resolved + + // handler is the Go function. + handler StreamingFunc[TArgs] + + requireConfirmation bool + + requireConfirmationProvider func(TArgs) bool +} + +// Description implements tool.Tool. +func (f *streamingFunctionTool[TArgs]) Description() string { + return f.cfg.Description +} + +// Name implements tool.Tool. +func (f *streamingFunctionTool[TArgs]) Name() string { + return f.cfg.Name +} + +// IsLongRunning implements tool.Tool. +func (f *streamingFunctionTool[TArgs]) IsLongRunning() bool { + return f.cfg.IsLongRunning +} + +// ProcessRequest packs the function tool's declaration into the LLM request. +func (f *streamingFunctionTool[TArgs]) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { + return toolutils.PackTool(req, f) +} + +// FunctionDeclaration implements toolinternal.StreamingFunctionTool. +func (f *streamingFunctionTool[TArgs]) Declaration() *genai.FunctionDeclaration { + decl := &genai.FunctionDeclaration{ + Name: f.Name(), + Description: f.Description(), + } + if f.inputSchema != nil { + decl.ParametersJsonSchema = f.inputSchema.Schema() + } + + if f.cfg.IsLongRunning { + instruction := "NOTE: This is a long-running operation. Do not call this tool again if it has already returned some intermediate or pending status." + if decl.Description != "" { + decl.Description += "\n\n" + instruction + } else { + decl.Description = instruction + } + } + + return decl +} + +// RunStream executes the tool with the provided context and yields events. +func (f *streamingFunctionTool[TArgs]) RunStream(ctx tool.Context, args any) iter.Seq2[string, error] { + return func(yield func(string, error) bool) { + defer func() { + if r := recover(); r != nil { + yield("", fmt.Errorf("panic in tool %q: %v\nstack: %s", f.Name(), r, debug.Stack())) + } + }() + + m, ok := args.(map[string]any) + if !ok { + yield("", fmt.Errorf("unexpected args type, got: %T", args)) + return + } + input, err := typeutil.ConvertToWithJSONSchema[map[string]any, TArgs](m, f.inputSchema) + if err != nil { + yield("", err) + return + } + + if confirmation := ctx.ToolConfirmation(); confirmation != nil { + if !confirmation.Confirmed { + yield("", fmt.Errorf("error tool %q %w", f.Name(), tool.ErrConfirmationRejected)) + return + } + } else { + requireConfirmation := f.requireConfirmation + + if f.requireConfirmationProvider != nil { + requireConfirmation = f.requireConfirmationProvider(input) + } + + if requireConfirmation { + err := ctx.RequestConfirmation( + fmt.Sprintf("Please approve or reject the tool call %s() by responding with a FunctionResponse with an expected ToolConfirmation payload.", + f.Name()), nil) + if err != nil { + yield("", err) + return + } + ctx.Actions().SkipSummarization = true + yield("", fmt.Errorf("error tool %q %w", f.Name(), tool.ErrConfirmationRequired)) + return + } + } + + for res, err := range f.handler(ctx, input) { + if !yield(res, err) { + return + } + } + } +} From 236cab75e76b5ddd7dc6b32d3bfa72ca1f8211a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Tue, 19 May 2026 11:41:19 +0100 Subject: [PATCH 49/86] feat(live): Add audio cache for save artifact (#838) * feat(live): Add core bidirectional streaming support Brings over the core files for live agent execution, including RunLive implementation, base flow updates, and session management. * lint fixes * feat(live): Add audio cache for save artifact * lint fix * Fix audio cache manager different mimetypes for output and input * Change audio_cache_manager_test to table driven testing --- internal/llminternal/audio_cache_manager.go | 169 +++++++++++ .../llminternal/audio_cache_manager_test.go | 283 ++++++++++++++++++ internal/llminternal/base_flow.go | 37 +++ 3 files changed, 489 insertions(+) create mode 100644 internal/llminternal/audio_cache_manager.go create mode 100644 internal/llminternal/audio_cache_manager_test.go diff --git a/internal/llminternal/audio_cache_manager.go b/internal/llminternal/audio_cache_manager.go new file mode 100644 index 000000000..750329ad9 --- /dev/null +++ b/internal/llminternal/audio_cache_manager.go @@ -0,0 +1,169 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package llminternal + +import ( + "bytes" + "fmt" + "strings" + "sync" + "time" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/session" +) + +// AudioCacheManager manages audio caching and flushing for live streaming flows. +type AudioCacheManager struct { + mu sync.Mutex + inputCache [][]byte + outputCache [][]byte + inputStartTime time.Time + outputStartTime time.Time + inputMimeType string + outputMimeType string +} + +// NewAudioCacheManager creates a new AudioCacheManager. +func NewAudioCacheManager() *AudioCacheManager { + return &AudioCacheManager{ + inputMimeType: "audio/pcm", // Default to audio/pcm + outputMimeType: "audio/pcm", // Default to audio/pcm + } +} + +// CacheInput caches incoming user audio data. +func (m *AudioCacheManager) CacheInput(data []byte, mimeType string) { + if mimeType != "" && !strings.HasPrefix(mimeType, "audio/") { + return + } + m.mu.Lock() + defer m.mu.Unlock() + if len(m.inputCache) == 0 { + m.inputStartTime = time.Now() + if mimeType != "" { + m.inputMimeType = mimeType + } + } + m.inputCache = append(m.inputCache, data) +} + +// CacheOutput caches outgoing model audio data. +func (m *AudioCacheManager) CacheOutput(data []byte, mimeType string) { + if mimeType != "" && !strings.HasPrefix(mimeType, "audio/") { + return + } + m.mu.Lock() + defer m.mu.Unlock() + if len(m.outputCache) == 0 { + m.outputStartTime = time.Now() + if mimeType != "" { + m.outputMimeType = mimeType + } + } + m.outputCache = append(m.outputCache, data) +} + +// FlushCaches flushes audio caches to artifact services and returns created events. +func (m *AudioCacheManager) FlushCaches(ctx agent.InvocationContext, flushUser, flushModel bool) ([]*session.Event, error) { + m.mu.Lock() + defer m.mu.Unlock() + + var events []*session.Event + + if flushUser && len(m.inputCache) > 0 { + ev, err := m.flushCache(ctx, m.inputCache, "input_audio", m.inputMimeType, m.inputStartTime) + if err != nil { + return nil, err + } + if ev != nil { + events = append(events, ev) + } + m.inputCache = nil + } + + if flushModel && len(m.outputCache) > 0 { + ev, err := m.flushCache(ctx, m.outputCache, "output_audio", m.outputMimeType, m.outputStartTime) + if err != nil { + return nil, err + } + if ev != nil { + events = append(events, ev) + } + m.outputCache = nil + } + + return events, nil +} + +func (m *AudioCacheManager) flushCache(ctx agent.InvocationContext, cache [][]byte, cacheType, mimeType string, startTime time.Time) (*session.Event, error) { + if len(cache) == 0 { + return nil, nil + } + + // Combine chunks + var buf bytes.Buffer + for _, chunk := range cache { + buf.Write(chunk) + } + combinedData := buf.Bytes() + + // Generate filename + timestamp := startTime.UnixMilli() + ext := "pcm" + if mimeType == "audio/mp3" { + ext = "mp3" + } + + filename := fmt.Sprintf("adk_live_audio_storage_%s_%d.%s", cacheType, timestamp, ext) + + // Save to artifact service + part := genai.NewPartFromBytes(combinedData, mimeType) + resp, err := ctx.Artifacts().Save(ctx, filename, part) + if err != nil { + return nil, fmt.Errorf("failed to save audio artifact: %w", err) + } + + // Create artifact reference + sess := ctx.Session() + artifactRef := fmt.Sprintf("artifact://%s/%s/%s/_adk_live/%s#%d", sess.AppName(), sess.UserID(), sess.ID(), filename, resp.Version) + + // Create event with file data reference + author := ctx.Agent().Name() + role := "model" + if cacheType == "input_audio" { + author = "user" + role = "user" + } + + ev := session.NewEvent(ctx.InvocationID()) + ev.Author = author + ev.Timestamp = startTime + ev.Content = &genai.Content{ + Role: role, + Parts: []*genai.Part{ + { + FileData: &genai.FileData{ + FileURI: artifactRef, + MIMEType: mimeType, + }, + }, + }, + } + + return ev, nil +} diff --git a/internal/llminternal/audio_cache_manager_test.go b/internal/llminternal/audio_cache_manager_test.go new file mode 100644 index 000000000..f405c53c2 --- /dev/null +++ b/internal/llminternal/audio_cache_manager_test.go @@ -0,0 +1,283 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package llminternal + +import ( + "bytes" + "context" + "testing" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/artifact" + "google.golang.org/adk/session" +) + +type audioMockArtifacts struct { + savedName string + savedPart *genai.Part +} + +func (m *audioMockArtifacts) Save(ctx context.Context, name string, data *genai.Part) (*artifact.SaveResponse, error) { + m.savedName = name + m.savedPart = data + return &artifact.SaveResponse{Version: 1}, nil +} + +func (m *audioMockArtifacts) List(context.Context) (*artifact.ListResponse, error) { return nil, nil } +func (m *audioMockArtifacts) Load(ctx context.Context, name string) (*artifact.LoadResponse, error) { + return nil, nil +} + +func (m *audioMockArtifacts) LoadVersion(ctx context.Context, name string, version int) (*artifact.LoadResponse, error) { + return nil, nil +} + +type audioMockSession struct { + session.Session + id string + appName string + userID string +} + +func (m *audioMockSession) ID() string { return m.id } +func (m *audioMockSession) AppName() string { return m.appName } +func (m *audioMockSession) UserID() string { return m.userID } + +type audioMockInvocationContext struct { + agent.InvocationContext + artifacts agent.Artifacts + session session.Session + invocationID string + agentObj agent.Agent +} + +func (m *audioMockInvocationContext) Artifacts() agent.Artifacts { return m.artifacts } +func (m *audioMockInvocationContext) Session() session.Session { return m.session } +func (m *audioMockInvocationContext) InvocationID() string { return m.invocationID } +func (m *audioMockInvocationContext) Agent() agent.Agent { return m.agentObj } + +type audioMockAgent struct { + agent.Agent + name string +} + +func (m *audioMockAgent) Name() string { return m.name } + +func TestAudioCacheManager(t *testing.T) { + type chunk struct { + data []byte + mime string + } + tests := []struct { + name string + inputs []chunk + outputs []chunk + flushUser bool + flushModel bool + expectedEventsCount int + verify func(t *testing.T, events []*session.Event, mockArt *audioMockArtifacts) + }{ + { + name: "FlushBoth_PCM", + inputs: []chunk{ + {[]byte("input1"), "audio/pcm"}, + {[]byte("input2"), "audio/pcm"}, + }, + outputs: []chunk{ + {[]byte("output1"), "audio/pcm"}, + {[]byte("output2"), "audio/pcm"}, + }, + flushUser: true, + flushModel: true, + expectedEventsCount: 2, + verify: func(t *testing.T, events []*session.Event, mockArt *audioMockArtifacts) { + // Verify input event + ev1 := events[0] + if ev1.Author != "user" || ev1.Content.Role != "user" { + t.Errorf("ev1 author/role mismatch: author=%q, role=%q", ev1.Author, ev1.Content.Role) + } + if ev1.Content.Parts[0].FileData.MIMEType != "audio/pcm" { + t.Errorf("ev1 mimeType mismatch: got %s", ev1.Content.Parts[0].FileData.MIMEType) + } + + // Verify output event + ev2 := events[1] + if ev2.Author != "agent1" || ev2.Content.Role != "model" { + t.Errorf("ev2 author/role mismatch: author=%q, role=%q", ev2.Author, ev2.Content.Role) + } + if ev2.Content.Parts[0].FileData.MIMEType != "audio/pcm" { + t.Errorf("ev2 mimeType mismatch: got %s", ev2.Content.Parts[0].FileData.MIMEType) + } + }, + }, + { + name: "FlushSelective_InputOnly", + inputs: []chunk{ + {[]byte("input1"), "audio/pcm"}, + }, + outputs: []chunk{ + {[]byte("output1"), "audio/pcm"}, + }, + flushUser: true, + flushModel: false, + expectedEventsCount: 1, + verify: func(t *testing.T, events []*session.Event, mockArt *audioMockArtifacts) { + if events[0].Author != "user" { + t.Errorf("Expected author user, got %s", events[0].Author) + } + }, + }, + { + name: "FlushSelective_OutputOnly", + inputs: []chunk{ + {[]byte("input1"), "audio/pcm"}, + }, + outputs: []chunk{ + {[]byte("output1"), "audio/pcm"}, + }, + flushUser: false, + flushModel: true, + expectedEventsCount: 1, + verify: func(t *testing.T, events []*session.Event, mockArt *audioMockArtifacts) { + if events[0].Author != "agent1" { + t.Errorf("Expected author agent1, got %s", events[0].Author) + } + }, + }, + { + name: "FlushEmpty", + flushUser: true, + flushModel: true, + expectedEventsCount: 0, + }, + { + name: "VerifyCombinedData", + inputs: []chunk{ + {[]byte("chunk1"), "audio/pcm"}, + {[]byte("chunk2"), "audio/pcm"}, + }, + flushUser: true, + flushModel: false, + expectedEventsCount: 1, + verify: func(t *testing.T, events []*session.Event, mockArt *audioMockArtifacts) { + if mockArt.savedPart == nil { + t.Fatal("Expected savedPart, got nil") + } + expectedData := []byte("chunk1chunk2") + if !bytes.Equal(mockArt.savedPart.InlineData.Data, expectedData) { + t.Errorf("Expected combined data %s, got %s", expectedData, mockArt.savedPart.InlineData.Data) + } + }, + }, + { + name: "MimeTypeFallback", + inputs: []chunk{ + {[]byte("input1"), ""}, + }, + flushUser: true, + flushModel: false, + expectedEventsCount: 1, + verify: func(t *testing.T, events []*session.Event, mockArt *audioMockArtifacts) { + if mockArt.savedPart.InlineData.MIMEType != "audio/pcm" { + t.Errorf("Expected fallback MIMEType audio/pcm, got %s", mockArt.savedPart.InlineData.MIMEType) + } + }, + }, + { + name: "DifferentMimeTypes", + inputs: []chunk{ + {[]byte("input1"), "audio/pcm"}, + }, + outputs: []chunk{ + {[]byte("output1"), "audio/mp3"}, + }, + flushUser: true, + flushModel: true, + expectedEventsCount: 2, + verify: func(t *testing.T, events []*session.Event, mockArt *audioMockArtifacts) { + if events[0].Content.Parts[0].FileData.MIMEType != "audio/pcm" { + t.Errorf("Expected input MIMEType audio/pcm, got %s", events[0].Content.Parts[0].FileData.MIMEType) + } + if events[1].Content.Parts[0].FileData.MIMEType != "audio/mp3" { + t.Errorf("Expected output MIMEType audio/mp3, got %s", events[1].Content.Parts[0].FileData.MIMEType) + } + }, + }, + { + name: "FiltersNonAudio", + inputs: []chunk{ + {[]byte("input1"), "video/mp4"}, + {[]byte("input2"), "image/png"}, + {[]byte("audio_input"), "audio/pcm"}, + }, + outputs: []chunk{ + {[]byte("output1"), "video/h264"}, + }, + flushUser: true, + flushModel: true, + expectedEventsCount: 1, + verify: func(t *testing.T, events []*session.Event, mockArt *audioMockArtifacts) { + ev := events[0] + if ev.Author != "user" { + t.Errorf("Expected author user, got %s", ev.Author) + } + if mockArt.savedPart == nil { + t.Fatal("Expected savedPart, got nil") + } + if !bytes.Equal(mockArt.savedPart.InlineData.Data, []byte("audio_input")) { + t.Errorf("Expected only 'audio_input' to be saved, got %s", mockArt.savedPart.InlineData.Data) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mgr := NewAudioCacheManager() + + for _, in := range tt.inputs { + mgr.CacheInput(in.data, in.mime) + } + for _, out := range tt.outputs { + mgr.CacheOutput(out.data, out.mime) + } + + mockArt := &audioMockArtifacts{} + mockSess := &audioMockSession{id: "sess1", appName: "app1", userID: "user1"} + mockAg := &audioMockAgent{name: "agent1"} + mockCtx := &audioMockInvocationContext{ + artifacts: mockArt, + session: mockSess, + invocationID: "inv1", + agentObj: mockAg, + } + + events, err := mgr.FlushCaches(mockCtx, tt.flushUser, tt.flushModel) + if err != nil { + t.Fatalf("FlushCaches failed: %v", err) + } + + if len(events) != tt.expectedEventsCount { + t.Fatalf("Expected %d events, got %d", tt.expectedEventsCount, len(events)) + } + + if tt.verify != nil { + tt.verify(t, events, mockArt) + } + }) + } +} diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index f69dcba29..66f2e6f93 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -136,6 +136,7 @@ type liveSessionImpl struct { outputCh chan eventOrError done chan struct{} closeOnce sync.Once + audioMgr *AudioCacheManager mu sync.Mutex activeTools map[string][]activeTask } @@ -194,6 +195,7 @@ func newLiveSessionImpl() *liveSessionImpl { inputCh: make(chan agent.LiveRequest), outputCh: make(chan eventOrError), done: make(chan struct{}), + audioMgr: NewAudioCacheManager(), } } @@ -370,6 +372,13 @@ func (f *Flow) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq iCtx.SetLiveSessionResumptionHandle(resp.SessionResumptionHandle) } } + if runCfg.Live.SaveLiveBlob && resp.Content != nil { + for _, part := range resp.Content.Parts { + if part.InlineData != nil { + sess.audioMgr.CacheOutput(part.InlineData.Data, part.InlineData.MIMEType) + } + } + } ev := session.NewEvent(ctx.InvocationID()) ev.Author = ctx.Agent().Name() ev.LLMResponse = *resp @@ -399,6 +408,9 @@ func (f *Flow) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq } } if req.RealtimeInput != nil { + if blob, ok := req.RealtimeInput.(*genai.Blob); ok { + sess.audioMgr.CacheInput(blob.Data, blob.MIMEType) + } if err := liveConn.SendRealtime(connCtx, req.RealtimeInput); err != nil { errChan <- err return @@ -416,6 +428,31 @@ func (f *Flow) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq cleanup() return } + // Flush caches if needed + if runCfg.Live.SaveLiveBlob { + var flushUser, flushModel bool + if ev.LLMResponse.Interrupted { + flushModel = true + } + if ev.LLMResponse.TurnComplete { + flushUser = true + flushModel = true + } + if flushUser || flushModel { + flushedEvents, err := sess.audioMgr.FlushCaches(ctx, flushUser, flushModel) + if err != nil { + sess.pushError(err) + cleanup() + return + } + for _, fev := range flushedEvents { + if !sess.pushEvent(fev) { + cleanup() + return + } + } + } + } // Handle function calls if present in the event fnCalls := utils.FunctionCalls(ev.LLMResponse.Content) if len(fnCalls) > 0 { From f2aee5301649e7f28fe00564b906fa7c02c64e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Tue, 19 May 2026 13:27:26 +0100 Subject: [PATCH 50/86] chore: fix folder name (#859) --- examples/bidi/README.md | 4 ++-- examples/bidi/{streaming_tool => streamingtool}/main.go | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename examples/bidi/{streaming_tool => streamingtool}/main.go (100%) diff --git a/examples/bidi/README.md b/examples/bidi/README.md index c53aecb42..87d4b152f 100644 --- a/examples/bidi/README.md +++ b/examples/bidi/README.md @@ -72,8 +72,8 @@ Open your browser and navigate to `http://localhost:8081`. You should see a chat - `main.go`: The main entry point that configures a simple agent and starts the server. - `static/`: Contains the frontend files (HTML, CSS, JS) shared by all examples. -- `streaming_tool/`: Demonstrates a **streaming tool** that yields data over time (counting with delays) and how to handle the `stop_streaming` control signal. - - **Run**: `go run examples/bidi/streaming_tool/main.go` +- `streamingtool/`: Demonstrates a **streaming tool** that yields data over time (counting with delays) and how to handle the `stop_streaming` control signal. + - **Run**: `go run examples/bidi/streamingtool/main.go` - `sequential/`: Demonstrates a **Sequential Agent** flow where control is passed from an 'Idea Generator' agent to a 'Story Teller' agent. - **Run**: `go run examples/bidi/sequential/main.go` diff --git a/examples/bidi/streaming_tool/main.go b/examples/bidi/streamingtool/main.go similarity index 100% rename from examples/bidi/streaming_tool/main.go rename to examples/bidi/streamingtool/main.go From 428efadbb5b5551bb58bea32bac238f6ece76dcd Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Wed, 20 May 2026 11:14:20 +0200 Subject: [PATCH 51/86] fix: user auth propagation not working in adka2a compat (#861) * fix user auth propagation in adka2a compat * fix import --- agent/remoteagent/a2a_agent_compat_test.go | 94 +++++++++++++++++++++- server/adka2a/executor.go | 7 +- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/agent/remoteagent/a2a_agent_compat_test.go b/agent/remoteagent/a2a_agent_compat_test.go index 0b321a697..62879d7fb 100644 --- a/agent/remoteagent/a2a_agent_compat_test.go +++ b/agent/remoteagent/a2a_agent_compat_test.go @@ -22,7 +22,7 @@ import ( "time" legacyA2A "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2aclient" + legacyAClient "github.com/a2aproject/a2a-go/a2aclient" legacyASrv "github.com/a2aproject/a2a-go/a2asrv" v2a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" @@ -330,7 +330,7 @@ func TestCompat_RemoteTaskCleanupCallback(t *testing.T) { oldAgent := utils.Must(NewA2A(A2AConfig{ Name: "remote-agent", AgentCard: card, - RemoteTaskCleanupCallback: func(ctx context.Context, card *legacyA2A.AgentCard, client *a2aclient.Client, taskInfo legacyA2A.TaskInfo, cause error) { + RemoteTaskCleanupCallback: func(ctx context.Context, card *legacyA2A.AgentCard, client *legacyAClient.Client, taskInfo legacyA2A.TaskInfo, cause error) { cleanupCalled = true cleanupTaskInfo = taskInfo }, @@ -373,6 +373,96 @@ func TestCompat_RemoteTaskCleanupCallback(t *testing.T) { } } +func TestCompat_ContextPropagation(t *testing.T) { + appName := "yesapp" + sessionService := session.InMemoryService() + agnt := utils.Must(agent.New(agent.Config{ + Name: appName, + Run: func(ic agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + yield(&session.Event{LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("Yes", genai.RoleModel)}}, nil) + } + }, + })) + executor := adka2a.NewExecutor(adka2a.ExecutorConfig{ + RunnerConfig: runner.Config{ + AppName: appName, + Agent: agnt, + SessionService: sessionService, + }, + }) + + handler := legacyASrv.NewHandler( + executor, + legacyASrv.WithCallInterceptor(&testSrvAuthInterceptor{ + BeforeFunc: func(ctx context.Context, callCtx *legacyASrv.CallContext, req *legacyASrv.Request) (context.Context, error) { + if headers, _ := callCtx.RequestMeta().Get("authorization"); len(headers) > 0 { + callCtx.User = &legacyASrv.AuthenticatedUser{UserName: headers[0]} + } + return ctx, nil + }, + }), + ) + server := httptest.NewServer(legacyASrv.NewJSONRPCHandler(handler)) + t.Cleanup(server.Close) + + userID := "user123" + remote, err := NewA2A(A2AConfig{ + Name: "yes-client", + AgentCard: newLegacyCard(server.URL), + ClientFactory: legacyAClient.NewFactory(legacyAClient.WithInterceptors( + &testClientAuthInterceptor{ + BeforeFunc: func(ctx context.Context, req *legacyAClient.Request) (context.Context, error) { + req.Meta["Authorization"] = []string{userID} + return ctx, nil + }, + }, + )), + }) + if err != nil { + t.Fatalf("NewA2A() error = %v", err) + } + _, err = runAndCollect(newInvocationContext(t, []*session.Event{newUserHello()}), remote) + if err != nil { + t.Fatalf("agent.Run() error = %v", err) + } + + sessions, err := sessionService.List(t.Context(), &session.ListRequest{ + AppName: appName, + UserID: userID, + }) + if err != nil { + t.Fatalf("sessionService.List() error = %v", err) + } + if len(sessions.Sessions) != 1 { + t.Fatalf("len(sessions.Sessions) = %d, want 1", len(sessions.Sessions)) + } +} + +type testSrvAuthInterceptor struct { + legacyASrv.PassthroughCallInterceptor + BeforeFunc func(ctx context.Context, callCtx *legacyASrv.CallContext, req *legacyASrv.Request) (context.Context, error) +} + +func (i *testSrvAuthInterceptor) Before(ctx context.Context, callCtx *legacyASrv.CallContext, req *legacyASrv.Request) (context.Context, error) { + if i.BeforeFunc != nil { + return i.BeforeFunc(ctx, callCtx, req) + } + return ctx, nil +} + +type testClientAuthInterceptor struct { + legacyAClient.PassthroughInterceptor + BeforeFunc func(ctx context.Context, req *legacyAClient.Request) (context.Context, error) +} + +func (i *testClientAuthInterceptor) Before(ctx context.Context, req *legacyAClient.Request) (context.Context, error) { + if i.BeforeFunc != nil { + return i.BeforeFunc(ctx, req) + } + return ctx, nil +} + type mockQueue struct { events []legacyA2A.Event } diff --git a/server/adka2a/executor.go b/server/adka2a/executor.go index f959c4e0f..d39b2331f 100644 --- a/server/adka2a/executor.go +++ b/server/adka2a/executor.go @@ -253,6 +253,11 @@ func (e *Executor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, q return err } + ctx, callCtx := a2asrvv2.NewCallContext(ctx, execCtx.ServiceParams) + if execCtx.User.Authenticated { + callCtx.User = a2asrvv2.NewAuthenticatedUser(execCtx.User.Name, execCtx.User.Attributes) + } + for event, err := range e.impl.Execute(ctx, execCtx) { if err != nil { return err @@ -339,7 +344,7 @@ func toRequestContext(ctx *a2asrvv2.ExecutorContext) *a2asrv.RequestContext { } func toExecutorContext(ctx context.Context, reqCtx *a2asrv.RequestContext) (*a2asrvv2.ExecutorContext, error) { - var user *a2asrvv2.User + user := &a2asrvv2.User{Authenticated: false} reqMeta := make(map[string][]string) if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { user = &a2asrvv2.User{Name: callCtx.User.Name(), Authenticated: callCtx.User.Authenticated()} From 96e74d1b9ffd1fdcfec16ad21c66ea584606883c Mon Sep 17 00:00:00 2001 From: Anastasia Date: Wed, 20 May 2026 14:41:08 +0200 Subject: [PATCH 52/86] fix: add validation for transfer agent (#824) --- internal/llminternal/agent_transfer.go | 47 +++++++++++++++---- internal/llminternal/agent_transfer_test.go | 18 +++++++ ...kIntegration_transfer_to_agent_tool.httprr | 14 +++--- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/internal/llminternal/agent_transfer.go b/internal/llminternal/agent_transfer.go index ee021b88c..b44247f0b 100644 --- a/internal/llminternal/agent_transfer.go +++ b/internal/llminternal/agent_transfer.go @@ -65,6 +65,7 @@ import ( // // TODO: implement it in the runners package and update this doc. +// AgentTransferRequestProcessor processes agent transfer requests. func AgentTransferRequestProcessor(ctx agent.InvocationContext, req *model.LLMRequest, f *Flow) iter.Seq2[*session.Event, error] { return func(yield func(*session.Event, error) bool) { // TODO: support agent types other than LLMAgent, that have parent/subagents? @@ -82,13 +83,12 @@ func AgentTransferRequestProcessor(ctx agent.InvocationContext, req *model.LLMRe // TODO(hyangah): why do we set this up in request processor // instead of registering this as a normal function tool of the Agent? - transferToAgentTool := &TransferToAgentTool{} - si, err := instructionsForTransferToAgent(agent, parents[agent.Name()], targets, transferToAgentTool) + transferToAgentTool, err := NewTransferToAgentTool(agent, parents[agent.Name()], targets) if err != nil { yield(nil, err) return } - utils.AppendInstructions(req, si) + utils.AppendInstructions(req, transferToAgentTool.instructions) err = appendTools(req, transferToAgentTool) if err != nil { yield(nil, err) @@ -96,7 +96,25 @@ func AgentTransferRequestProcessor(ctx agent.InvocationContext, req *model.LLMRe } } -type TransferToAgentTool struct{} +const transferAgentName = "transfer_to_agent" + +// TransferToAgentTool is a tool that handles transferring control to another agent. +type TransferToAgentTool struct { + instructions string + supportedAgents []agent.Agent +} + +// NewTransferToAgentTool creates a new TransferToAgentTool. +func NewTransferToAgentTool(curAgent, parent agent.Agent, targets []agent.Agent) (*TransferToAgentTool, error) { + si, err := instructionsForTransferToAgent(curAgent, parent, targets) + if err != nil { + return nil, err + } + return &TransferToAgentTool{ + instructions: si, + supportedAgents: targets, + }, nil +} // Description implements tool.Tool. func (t *TransferToAgentTool) Description() string { @@ -106,7 +124,7 @@ This tool hands off control to another agent when it's more suitable to answer t // Name implements tool.Tool. func (t *TransferToAgentTool) Name() string { - return "transfer_to_agent" + return transferAgentName } // IsLongRunning implements tool.Tool. @@ -119,11 +137,12 @@ func (t *TransferToAgentTool) Declaration() *genai.FunctionDeclaration { Name: t.Name(), Description: t.Description(), Parameters: &genai.Schema{ - Type: "object", + Type: genai.TypeObject, Properties: map[string]*genai.Schema{ "agent_name": { - Type: "string", + Type: genai.TypeString, Description: "the agent name to transfer to", + Enum: t.enums(), }, }, Required: []string{"agent_name"}, @@ -131,6 +150,14 @@ func (t *TransferToAgentTool) Declaration() *genai.FunctionDeclaration { } } +func (t *TransferToAgentTool) enums() []string { + var agentNames []string + for _, a := range t.supportedAgents { + agentNames = append(agentNames, a.Name()) + } + return agentNames +} + // ProcessRequest implements types.Tool. func (t *TransferToAgentTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { return appendTools(req, t) @@ -201,7 +228,7 @@ func shouldUseAutoFlow(agent agent.Agent) bool { return len(agent.SubAgents()) != 0 || !a.internal().DisallowTransferToParent || !a.internal().DisallowTransferToPeers } -// AppendTools appends the tools to the request. +// appendTools appends the tools to the request. // Appending duplicate tools or nameless tools is an error. func appendTools(r *model.LLMRequest, tools ...tool.Tool) error { if r.Tools == nil { @@ -254,7 +281,7 @@ func appendTools(r *model.LLMRequest, tools ...tool.Tool) error { var transferToAgentPromptTmpl = template.Must( template.New("transfer_to_agent_prompt").Parse(agentTransferInstructionTemplate)) -func instructionsForTransferToAgent(curAgent, parent agent.Agent, targets []agent.Agent, transferTool tool.Tool) (string, error) { +func instructionsForTransferToAgent(curAgent, parent agent.Agent, targets []agent.Agent) (string, error) { if asLLMAgent(curAgent).internal().DisallowTransferToParent { parent = nil } @@ -270,7 +297,7 @@ func instructionsForTransferToAgent(curAgent, parent agent.Agent, targets []agen AgentName: curAgent.Name(), Parent: parent, Targets: targets, - ToolName: transferTool.Name(), + ToolName: transferAgentName, FormattedTargets: formatTargets(targets), }); err != nil { return "", err diff --git a/internal/llminternal/agent_transfer_test.go b/internal/llminternal/agent_transfer_test.go index 356e1e906..b60f7d023 100644 --- a/internal/llminternal/agent_transfer_test.go +++ b/internal/llminternal/agent_transfer_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genai" "google.golang.org/adk/agent" @@ -47,6 +48,7 @@ func TestAgentTransferRequestProcessor(t *testing.T) { } check := func(t *testing.T, curAgent, root agent.Agent, wantParent string, wantAgents, unwantAgents []string) { + t.Helper() req := &model.LLMRequest{} parents, err := parentmap.New(root) @@ -118,6 +120,22 @@ func TestAgentTransferRequestProcessor(t *testing.T) { }) { t.Errorf("instruction does not include subagents, got: %s", strings.Join(instructions, "\n")) } + if len(wantAgents) > 0 { + transferTool, ok := gotTool.(*llminternal.TransferToAgentTool) + if !ok { + t.Fatalf("failed to type convert tool %v, got %T", wantToolName, gotTool) + } + declaration := transferTool.Declaration() + gotEnums := slices.Clone(declaration.Parameters.Properties["agent_name"].Enum) + wantEnums := slices.Clone(wantAgents) + // Add parent to the list of agents to transfer to, if not already present. + if wantParent != "" && !slices.Contains(wantAgents, wantParent) { + wantEnums = append(wantEnums, wantParent) + } + if diff := cmp.Diff(wantEnums, gotEnums, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Fatalf("failed to set agent_name enums (-want, +got): %v", diff) + } + } if len(unwantAgents) > 0 && slices.ContainsFunc(instructions, func(s string) bool { return slices.ContainsFunc(unwantAgents, func(unwanted string) bool { for _, unwanted := range unwantAgents { diff --git a/plugin/functioncallmodifier/testdata/TestPluginCallbackIntegration_transfer_to_agent_tool.httprr b/plugin/functioncallmodifier/testdata/TestPluginCallbackIntegration_transfer_to_agent_tool.httprr index c55277002..eac8c191d 100644 --- a/plugin/functioncallmodifier/testdata/TestPluginCallbackIntegration_transfer_to_agent_tool.httprr +++ b/plugin/functioncallmodifier/testdata/TestPluginCallbackIntegration_transfer_to_agent_tool.httprr @@ -1,12 +1,12 @@ httprr trace v1 -1870 1661 +1892 1661 POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent HTTP/1.1 Host: generativelanguage.googleapis.com User-Agent: Go-http-client/1.1 -Content-Length: 1637 +Content-Length: 1659 Content-Type: application/json -{"contents":[{"parts":[{"text":"Can you add 2 and 2?"}],"role":"user"}],"generationConfig":{},"systemInstruction":{"parts":[{"text":"You are a transfer agent. You can transfer to other agents using your tools.\n\nYou are an agent. Your internal name is \"transfer_agent\". The description about you is \"transfer agent\".\n\n\nYou have a list of other agents to transfer to:\n\n\nAgent name: calculator\nAgent description: calculator agent\n Skills: add, subtract, multiply, divide\n\n\nIf you are the best to answer the question according to your description,\nyou can answer it.\n\nIf another agent is better for answering the question according to its\ndescription, call `transfer_to_agent` function to transfer the question to that\nagent. When transferring, do not generate any text other than the function\ncall.\n\n**NOTE**: the only available agents for `transfer_to_agent` function are\n`calculator`.\n"}],"role":"user"},"tools":[{"functionDeclarations":[{"description":"This tool can now optionally accept skill_id and rationale parameters to guide skill-based orchestration. Transfer the question to another agent.\nThis tool hands off control to another agent when it's more suitable to answer the user's question according to the agent's description.","name":"transfer_to_agent","parameters":{"properties":{"agent_name":{"description":"the agent name to transfer to","type":"string"},"rationale":{"description":"The reasoning behind selecting this agent and skill.","type":"STRING"},"skill_id":{"description":"The specific skill to be utilized by the agent.","type":"STRING"}},"required":["agent_name"],"type":"object"}}]}]}HTTP/2.0 200 OK +{"contents":[{"parts":[{"text":"Can you add 2 and 2?"}],"role":"user"}],"generationConfig":{},"systemInstruction":{"parts":[{"text":"You are a transfer agent. You can transfer to other agents using your tools.\n\nYou are an agent. Your internal name is \"transfer_agent\". The description about you is \"transfer agent\".\n\n\nYou have a list of other agents to transfer to:\n\n\nAgent name: calculator\nAgent description: calculator agent\n Skills: add, subtract, multiply, divide\n\n\nIf you are the best to answer the question according to your description,\nyou can answer it.\n\nIf another agent is better for answering the question according to its\ndescription, call `transfer_to_agent` function to transfer the question to that\nagent. When transferring, do not generate any text other than the function\ncall.\n\n**NOTE**: the only available agents for `transfer_to_agent` function are\n`calculator`.\n"}],"role":"user"},"tools":[{"functionDeclarations":[{"description":"This tool can now optionally accept skill_id and rationale parameters to guide skill-based orchestration. Transfer the question to another agent.\nThis tool hands off control to another agent when it's more suitable to answer the user's question according to the agent's description.","name":"transfer_to_agent","parameters":{"properties":{"agent_name":{"description":"the agent name to transfer to","enum":["calculator"],"type":"STRING"},"rationale":{"description":"The reasoning behind selecting this agent and skill.","type":"STRING"},"skill_id":{"description":"The specific skill to be utilized by the agent.","type":"STRING"}},"required":["agent_name"],"type":"OBJECT"}}]}]}HTTP/2.0 200 OK Content-Type: application/json; charset=UTF-8 Date: Tue, 10 Mar 2026 16:41:17 GMT Server: scaffolding on HTTPServer2 @@ -57,14 +57,14 @@ X-Xss-Protection: 0 "modelVersion": "gemini-2.5-flash", "responseId": "rEmwaZamPOmakdUP45HlkQo" } -2247 1254 +2273 1256 POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent HTTP/1.1 Host: generativelanguage.googleapis.com User-Agent: Go-http-client/1.1 -Content-Length: 2014 +Content-Length: 2040 Content-Type: application/json -{"contents":[{"parts":[{"text":"Can you add 2 and 2?"}],"role":"user"},{"parts":[{"text":"For context:"},{"text":"[transfer_agent] called tool `transfer_to_agent` with parameters: {\"agent_name\":\"calculator\"}"}],"role":"user"},{"parts":[{"text":"For context:"},{"text":"[transfer_agent] `transfer_to_agent` tool returned result: {}"}],"role":"user"}],"generationConfig":{},"systemInstruction":{"parts":[{"text":"You are a calculator agent. You can calculate numbers.\n\nYou are an agent. Your internal name is \"calculator\". The description about you is \"calculator agent\\n Skills: add, subtract, multiply, divide\".\n\n\nYou have a list of other agents to transfer to:\n\n\nAgent name: transfer_agent\nAgent description: transfer agent\n\n\nIf you are the best to answer the question according to your description,\nyou can answer it.\n\nIf another agent is better for answering the question according to its\ndescription, call `transfer_to_agent` function to transfer the question to that\nagent. When transferring, do not generate any text other than the function\ncall.\n\n**NOTE**: the only available agents for `transfer_to_agent` function are\n`transfer_agent`.\n\nIf neither you nor the other agents are best for the question, transfer to your parent agent transfer_agent.\n"}],"role":"user"},"tools":[{"functionDeclarations":[{"description":"This tool can now optionally accept skill_id and rationale parameters to guide skill-based orchestration. Transfer the question to another agent.\nThis tool hands off control to another agent when it's more suitable to answer the user's question according to the agent's description.","name":"transfer_to_agent","parameters":{"properties":{"agent_name":{"description":"the agent name to transfer to","type":"string"},"rationale":{"description":"The reasoning behind selecting this agent and skill.","type":"STRING"},"skill_id":{"description":"The specific skill to be utilized by the agent.","type":"STRING"}},"required":["agent_name"],"type":"object"}}]}]}HTTP/2.0 200 OK +{"contents":[{"parts":[{"text":"Can you add 2 and 2?"}],"role":"user"},{"parts":[{"text":"For context:"},{"text":"[transfer_agent] called tool `transfer_to_agent` with parameters: {\"agent_name\":\"calculator\"}"}],"role":"user"},{"parts":[{"text":"For context:"},{"text":"[transfer_agent] `transfer_to_agent` tool returned result: {}"}],"role":"user"}],"generationConfig":{},"systemInstruction":{"parts":[{"text":"You are a calculator agent. You can calculate numbers.\n\nYou are an agent. Your internal name is \"calculator\". The description about you is \"calculator agent\\n Skills: add, subtract, multiply, divide\".\n\n\nYou have a list of other agents to transfer to:\n\n\nAgent name: transfer_agent\nAgent description: transfer agent\n\n\nIf you are the best to answer the question according to your description,\nyou can answer it.\n\nIf another agent is better for answering the question according to its\ndescription, call `transfer_to_agent` function to transfer the question to that\nagent. When transferring, do not generate any text other than the function\ncall.\n\n**NOTE**: the only available agents for `transfer_to_agent` function are\n`transfer_agent`.\n\nIf neither you nor the other agents are best for the question, transfer to your parent agent transfer_agent.\n"}],"role":"user"},"tools":[{"functionDeclarations":[{"description":"This tool can now optionally accept skill_id and rationale parameters to guide skill-based orchestration. Transfer the question to another agent.\nThis tool hands off control to another agent when it's more suitable to answer the user's question according to the agent's description.","name":"transfer_to_agent","parameters":{"properties":{"agent_name":{"description":"the agent name to transfer to","enum":["transfer_agent"],"type":"STRING"},"rationale":{"description":"The reasoning behind selecting this agent and skill.","type":"STRING"},"skill_id":{"description":"The specific skill to be utilized by the agent.","type":"STRING"}},"required":["agent_name"],"type":"OBJECT"}}]}]}HTTP/2.0 200 OK Content-Type: application/json; charset=UTF-8 Date: Tue, 10 Mar 2026 16:41:18 GMT Server: scaffolding on HTTPServer2 @@ -106,4 +106,4 @@ X-Xss-Protection: 0 }, "modelVersion": "gemini-2.5-flash", "responseId": "rUmwaZLzNJaxkdUP58PwoAk" -} + } From ffb3c531360ca629e40c10eb5facc3e600618478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Fri, 22 May 2026 13:33:34 +0100 Subject: [PATCH 53/86] fix replay_plugin: empty llmresponses and normalize function formatting before comparing. (#869) * fix replay_plugin: empty llmresponses found in recording and normalize function formatting before comparing. * refactor(replay): Simplify YAML AST node copying and add description tests Use direct pointer struct copying for original YAML nodes in type mismatch correction, preserving all AST metadata (comments, line/column positions) automatically without manual property assignments. Add a comprehensive table-driven test suite for the package-private normalizeDescription utility, covering boundary spaces, edge newlines, middle empty lines, and complex whitespaces. --- .../conformance/replayplugin/replay_plugin.go | 40 ++++++++++ .../replay_plugin_internal_test.go | 80 +++++++++++++++++++ .../replayplugin/replay_plugin_test.go | 77 ++++++++++++++++++ .../conformance/replayplugin/yaml_utils.go | 9 +++ 4 files changed, 206 insertions(+) create mode 100644 internal/configurable/conformance/replayplugin/replay_plugin_internal_test.go diff --git a/internal/configurable/conformance/replayplugin/replay_plugin.go b/internal/configurable/conformance/replayplugin/replay_plugin.go index ffda62baf..1ab82abcb 100644 --- a/internal/configurable/conformance/replayplugin/replay_plugin.go +++ b/internal/configurable/conformance/replayplugin/replay_plugin.go @@ -129,6 +129,10 @@ func (p *replayPlugin) beforeModel(ctx agent.CallbackContext, req *model.LLMRequ return nil, err } + if len(recording.LLMResponses) == 0 { + return nil, fmt.Errorf("no LLM responses found in recording for agent %q", agentName) + } + return recording.LLMResponses[0], nil } @@ -425,6 +429,17 @@ func verifyLLMRequestMatch(expectedLLMRequest, actualLLMRequest *model.LLMReques cmpopts.EquateEmpty(), } + for _, toolAny := range expectedLLMRequest.Tools { + if funcDecl, ok := toolAny.(*genai.FunctionDeclaration); ok { + funcDecl.Description = normalizeDescription(funcDecl.Description) + } + } + for _, toolAny := range actualLLMRequest.Tools { + if funcDecl, ok := toolAny.(*genai.FunctionDeclaration); ok { + funcDecl.Description = normalizeDescription(funcDecl.Description) + } + } + if transferToolAny, ok := expectedLLMRequest.Tools["transfer_to_agent"]; ok { transferTool := transferToolAny.(*genai.FunctionDeclaration) transferTool.Description = `Transfer the question to another agent. @@ -434,6 +449,7 @@ This tool hands off control to another agent when it's more suitable to answer t if expectedLLMRequest.Config != nil { for _, tool := range expectedLLMRequest.Config.Tools { for _, funcDecl := range tool.FunctionDeclarations { + funcDecl.Description = normalizeDescription(funcDecl.Description) if funcDecl.Name == "transfer_to_agent" { funcDecl.Description = `Transfer the question to another agent. This tool hands off control to another agent when it's more suitable to answer the user's question according to the agent's description.` @@ -442,6 +458,14 @@ This tool hands off control to another agent when it's more suitable to answer t } } + if actualLLMRequest.Config != nil { + for _, tool := range actualLLMRequest.Config.Tools { + for _, funcDecl := range tool.FunctionDeclarations { + funcDecl.Description = normalizeDescription(funcDecl.Description) + } + } + } + // Compare! // cmp.Diff returns an empty string if they are equal, otherwise a human-readable diff. if diff := cmp.Diff(expectedLLMRequest, actualLLMRequest, opts...); diff != "" { @@ -580,3 +604,19 @@ func verifyToolCallMatch(expectedToolCall *genai.FunctionCall, toolName string, return nil } + +func normalizeDescription(desc string) string { + lines := strings.Split(desc, "\n") + var cleaned []string + for _, line := range lines { + cleaned = append(cleaned, strings.TrimSpace(line)) + } + // Remove empty lines at the start and end + for len(cleaned) > 0 && cleaned[0] == "" { + cleaned = cleaned[1:] + } + for len(cleaned) > 0 && cleaned[len(cleaned)-1] == "" { + cleaned = cleaned[:len(cleaned)-1] + } + return strings.Join(cleaned, "\n") +} diff --git a/internal/configurable/conformance/replayplugin/replay_plugin_internal_test.go b/internal/configurable/conformance/replayplugin/replay_plugin_internal_test.go new file mode 100644 index 000000000..8701d868b --- /dev/null +++ b/internal/configurable/conformance/replayplugin/replay_plugin_internal_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package replayplugin + +import "testing" + +func TestNormalizeDescription(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Simple single line", + input: "Simple description", + expected: "Simple description", + }, + { + name: "Single line with leading and trailing spaces", + input: " Simple description ", + expected: "Simple description", + }, + { + name: "Multiple lines with indentation", + input: " Line 1 \n Line 2 ", + expected: "Line 1\nLine 2", + }, + { + name: "Empty lines at start and end", + input: "\n\nLine 1\nLine 2\n\n", + expected: "Line 1\nLine 2", + }, + { + name: "Whitespace-only lines at start and end", + input: " \n \nLine 1\nLine 2\n \n ", + expected: "Line 1\nLine 2", + }, + { + name: "Empty lines in the middle are preserved", + input: "Line 1\n\nLine 2", + expected: "Line 1\n\nLine 2", + }, + { + name: "Empty input", + input: "", + expected: "", + }, + { + name: "Whitespace-only input", + input: " \n \n ", + expected: "", + }, + { + name: "Complex formatting with tabs and newlines", + input: "\t\n Line 1 \t \n\tLine 2\n \t\n", + expected: "Line 1\nLine 2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := normalizeDescription(tt.input) + if actual != tt.expected { + t.Errorf("normalizeDescription(%q) = %q; want %q", tt.input, actual, tt.expected) + } + }) + } +} diff --git a/internal/configurable/conformance/replayplugin/replay_plugin_test.go b/internal/configurable/conformance/replayplugin/replay_plugin_test.go index 83dc625d8..1eeb7b1ca 100644 --- a/internal/configurable/conformance/replayplugin/replay_plugin_test.go +++ b/internal/configurable/conformance/replayplugin/replay_plugin_test.go @@ -121,6 +121,83 @@ recordings: } }) + t.Run("BeforeModelCallback_WithSingleLlmResponse_ReturnsRecordedResponse", func(t *testing.T) { + plugin, mockSession, _ := setup(t) + tempDir := t.TempDir() + + // 1. Create recording file with singular llm_response instead of plural llm_responses + recordingsYaml := ` +recordings: + - user_message_index: 0 + agent_name: "test_agent" + llm_recording: + llm_request: + model: "gemini-2.0-flash" + contents: + - role: "user" + parts: + - text: "Hello" + llm_response: + content: + role: "model" + parts: + - text: "Recorded response" +` + createRecordingsFile(t, tempDir, recordingsYaml) + + // 2. Setup replay config + err := mockSession.State().Set("_adk_replay_config", map[string]any{ + "dir": tempDir, + "user_message_index": 0, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // 3. Load recordings (BeforeRunCallback) + invContext := &MockInvocationContext{ + session: mockSession, + invocationID: "test-invocation", + } + _, err = plugin.BeforeRunCallback()(invContext) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // 4. Call BeforeModelCallback with matching request + cbContext := &MockCallbackContext{ + state: mockSession.State(), + invocationID: "test-invocation", + agentName: "test_agent", + } + + request := &model.LLMRequest{ + Model: "gemini-2.0-flash", + Contents: []*genai.Content{ + { + Role: "user", + Parts: []*genai.Part{{Text: "Hello"}}, + }, + }, + } + + result, err := plugin.BeforeModelCallback()(cbContext, request) + // 5. Verify + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Content == nil { + t.Fatal("expected non-nil result.Content") + } + + if got := result.Content.Parts[0].Text; got != "Recorded response" { + t.Errorf("expected %q, got %q", "Recorded response", got) + } + }) + t.Run("BeforeModelCallback_RequestMismatch_ReturnsEmpty", func(t *testing.T) { plugin, mockSession, _ := setup(t) tempDir := t.TempDir() diff --git a/internal/configurable/conformance/replayplugin/yaml_utils.go b/internal/configurable/conformance/replayplugin/yaml_utils.go index ac3ec334c..353936fcf 100644 --- a/internal/configurable/conformance/replayplugin/yaml_utils.go +++ b/internal/configurable/conformance/replayplugin/yaml_utils.go @@ -86,6 +86,15 @@ func fixTypeMismatches(n *yaml.Node) { }, } } + case "llmresponse": + if valueNode.Kind == yaml.MappingNode { + origCopy := *valueNode + valueNode.Kind = yaml.SequenceNode + valueNode.Tag = "!!seq" + valueNode.Value = "" + valueNode.Content = []*yaml.Node{&origCopy} + } + keyNode.Value = "llmresponses" } // Recurse into the value to catch nested structures From 50a84c000e5022a86d78d22e094a111ec5fcd0ed Mon Sep 17 00:00:00 2001 From: Gustaf <32815781+gustafvh@users.noreply.github.com> Date: Mon, 25 May 2026 11:11:58 +0200 Subject: [PATCH 54/86] fix: copy ThoughtSignature onto adk_request_confirmation parts (#763) Gemini thinking models require replayed model-role function-call parts to carry their thought signature. The synthetic adk_request_confirmation parts created by generateRequestConfirmationEvent did not preserve the original part's ThoughtSignature, causing replay to fail with 400 INVALID_ARGUMENT. Fixes google/adk-go#656 --- internal/llminternal/functions.go | 17 +++--- internal/llminternal/functions_test.go | 76 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/internal/llminternal/functions.go b/internal/llminternal/functions.go index 98db0dfb0..7034fb5c7 100644 --- a/internal/llminternal/functions.go +++ b/internal/llminternal/functions.go @@ -43,20 +43,22 @@ func generateRequestConfirmationEvent( parts := []*genai.Part{} longRunningToolIDs := []string{} - functionCalls := make(map[string]*genai.FunctionCall, len(functionCallEvent.Content.Parts)) - for _, call := range utils.FunctionCalls(functionCallEvent.Content) { - functionCalls[call.ID] = call + functionCallParts := make(map[string]*genai.Part, len(functionCallEvent.Content.Parts)) + for _, part := range functionCallEvent.Content.Parts { + if part.FunctionCall != nil { + functionCallParts[part.FunctionCall.ID] = part + } } for funcID, confirmation := range functionResponseEvent.Actions.RequestedToolConfirmations { - originalFunctionCall, ok := functionCalls[funcID] - if !ok || originalFunctionCall == nil { + originalPart, ok := functionCallParts[funcID] + if !ok || originalPart.FunctionCall == nil { continue } // Prepare arguments for the adk_request_confirmation call args := map[string]any{ - "originalFunctionCall": originalFunctionCall, + "originalFunctionCall": originalPart.FunctionCall, "toolConfirmation": confirmation, } @@ -67,7 +69,8 @@ func generateRequestConfirmationEvent( } parts = append(parts, &genai.Part{ - FunctionCall: requestConfirmationFC, + FunctionCall: requestConfirmationFC, + ThoughtSignature: originalPart.ThoughtSignature, }) longRunningToolIDs = append(longRunningToolIDs, requestConfirmationFC.ID) } diff --git a/internal/llminternal/functions_test.go b/internal/llminternal/functions_test.go index b7c935613..1c17a7167 100644 --- a/internal/llminternal/functions_test.go +++ b/internal/llminternal/functions_test.go @@ -252,3 +252,79 @@ func TestGenerateRequestConfirmationEventHasID(t *testing.T) { t.Errorf("expected InvocationID=\"inv_1\", got %q", got.InvocationID) } } + +func TestGenerateRequestConfirmationEventPreservesThoughtSignature(t *testing.T) { + thoughtSignature := []byte("test-thought-signature") + ctx := &mockInvocationContext{ + invocationID: "inv_1", + agentName: "agent_1", + } + functionCallEvent := &session.Event{ + LLMResponse: model.LLMResponse{ + Content: &genai.Content{ + Parts: []*genai.Part{ + { + ThoughtSignature: thoughtSignature, + FunctionCall: &genai.FunctionCall{ + ID: "call_1", + Name: "test_tool", + Args: map[string]any{"arg": "val"}, + }, + }, + }, + }, + }, + } + functionResponseEvent := &session.Event{ + Actions: session.EventActions{ + RequestedToolConfirmations: map[string]toolconfirmation.ToolConfirmation{ + "call_1": {Hint: "Are you sure?"}, + }, + }, + } + + got := generateRequestConfirmationEvent(ctx, functionCallEvent, functionResponseEvent) + if got == nil || got.Content == nil || len(got.Content.Parts) != 1 { + t.Fatalf("expected one confirmation part, got %#v", got) + } + if diff := cmp.Diff(thoughtSignature, got.Content.Parts[0].ThoughtSignature); diff != "" { + t.Errorf("ThoughtSignature mismatch (-want +got):\n%s", diff) + } +} + +func TestGenerateRequestConfirmationEventNoThoughtSignature(t *testing.T) { + ctx := &mockInvocationContext{ + invocationID: "inv_1", + agentName: "agent_1", + } + functionCallEvent := &session.Event{ + LLMResponse: model.LLMResponse{ + Content: &genai.Content{ + Parts: []*genai.Part{ + { + FunctionCall: &genai.FunctionCall{ + ID: "call_1", + Name: "test_tool", + Args: map[string]any{"arg": "val"}, + }, + }, + }, + }, + }, + } + functionResponseEvent := &session.Event{ + Actions: session.EventActions{ + RequestedToolConfirmations: map[string]toolconfirmation.ToolConfirmation{ + "call_1": {Hint: "Are you sure?"}, + }, + }, + } + + got := generateRequestConfirmationEvent(ctx, functionCallEvent, functionResponseEvent) + if got == nil || got.Content == nil || len(got.Content.Parts) != 1 { + t.Fatalf("expected one confirmation part, got %#v", got) + } + if len(got.Content.Parts[0].ThoughtSignature) != 0 { + t.Errorf("ThoughtSignature = %q, want empty", got.Content.Parts[0].ThoughtSignature) + } +} From a7091922855391e2ea73943fbaf041d787af7fc2 Mon Sep 17 00:00:00 2001 From: Gustaf <32815781+gustafvh@users.noreply.github.com> Date: Mon, 25 May 2026 12:23:22 +0200 Subject: [PATCH 55/86] fix: yield tool responses before confirmations (#765) A consumer that pauses on receiving an adk_request_confirmation event (typically to await human approval) would never see the merged function-response event for tools that already executed in the same step. Yield completed responses first so they are persisted before the human-confirmation pause. Fixes google/adk-go#759 --- internal/llminternal/base_flow.go | 11 ++- .../parallel_function_call_hitl_test.go | 66 +++++++------- tool/functiontool/function_test.go | 86 +++++++++++++++---- tool/mcptoolset/set_test.go | 42 ++++----- 4 files changed, 129 insertions(+), 76 deletions(-) diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index 66f2e6f93..95faf8967 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -607,16 +607,19 @@ func (f *Flow) runOneStep(ctx agent.InvocationContext) iter.Seq2[*session.Event, } toolConfirmationEvent := generateRequestConfirmationEvent(ctx, modelResponseEvent, ev) + + // Yield function responses before confirmation requests so consumers that + // pause for user approval still persist completed tool results. + if !yield(ev, nil) { + return + } + if toolConfirmationEvent != nil { if !yield(toolConfirmationEvent, nil) { return } } - if !yield(ev, nil) { - return - } - // If the model response is structured, yield it as a final model response event. outputSchemaResponse, err := retrieveStructuredModelResponse(ev) if err != nil { diff --git a/internal/llminternal/parallel_function_call_hitl_test.go b/internal/llminternal/parallel_function_call_hitl_test.go index 8da57d786..7bf438725 100644 --- a/internal/llminternal/parallel_function_call_hitl_test.go +++ b/internal/llminternal/parallel_function_call_hitl_test.go @@ -107,8 +107,8 @@ func (m *hitlMockModel) GenerateContent(ctx context.Context, req *model.LLMReque // SkipSummarization flag, which halts LLM generation immediately after tool responses are returned. // The runner yields: // a) A model response event containing the two original parallel function calls. -// b) An aggregated tool confirmation event containing two adk_request_confirmation wrapper calls. -// c) A merged function response event containing the placeholder (unexecuted) tool responses. +// b) A merged function response event containing the placeholder (unexecuted) tool responses. +// c) An aggregated tool confirmation event containing two adk_request_confirmation wrapper calls. // 2. Turn 2: The client simulates user confirmation by returning confirmation responses matching // the unique wrapper call IDs. The RequestConfirmationRequestProcessor detects these, matches // them back to the original tools, and concurrently executes the sensitive tools in parallel @@ -175,8 +175,8 @@ func TestParallelFunctionCallsWithHITL(t *testing.T) { // Expecting exactly 3 events: // 1. ModelResponseEvent (yielding the two function calls secure_call_1 and secure_call_2) - // 2. ToolConfirmationEvent (yielding two adk_request_confirmation calls) - // 3. Merged function response event (returning placeholder executed: false responses) + // 2. Merged function response event (returning placeholder executed: false responses) + // 3. ToolConfirmationEvent (yielding two adk_request_confirmation calls) if len(turn1Events) != 3 { t.Fatalf("Expected 3 events in Turn 1, got %d", len(turn1Events)) } @@ -187,8 +187,35 @@ func TestParallelFunctionCallsWithHITL(t *testing.T) { t.Errorf("Expected model event to contain 2 parts (function calls), got %d", len(modelEvent.Content.Parts)) } + // Verify that the merged function response event contains the placeholder unexecuted responses + placeholderRespEvent := turn1Events[1] + if len(placeholderRespEvent.Content.Parts) != 2 { + t.Errorf("Expected placeholder function response event to contain 2 parts, got %d", len(placeholderRespEvent.Content.Parts)) + } + + expectedPlaceholders := []*genai.Part{ + { + FunctionResponse: &genai.FunctionResponse{ + Name: "secure_action", + ID: "secure_call_1", + Response: map[string]any{"error": `error tool "secure_action" requires confirmation, please approve or reject`}, + }, + }, + { + FunctionResponse: &genai.FunctionResponse{ + Name: "secure_action", + ID: "secure_call_2", + Response: map[string]any{"error": `error tool "secure_action" requires confirmation, please approve or reject`}, + }, + }, + } + + if diff := cmp.Diff(expectedPlaceholders, placeholderRespEvent.Content.Parts); diff != "" { + t.Errorf("Mismatch in placeholder tool responses (-want +got):\n%s", diff) + } + // Verify that the tool confirmation event has the confirmation wrapper calls - confirmEvent := turn1Events[1] + confirmEvent := turn1Events[2] if len(confirmEvent.Content.Parts) != 2 { t.Errorf("Expected tool confirmation event to contain 2 wrapper function calls, got %d", len(confirmEvent.Content.Parts)) } @@ -215,33 +242,6 @@ func TestParallelFunctionCallsWithHITL(t *testing.T) { t.Fatalf("Failed to retrieve both confirmation function call IDs from turn 1 events") } - // Verify that the merged function response event contains the placeholder unexecuted responses - placeholderRespEvent := turn1Events[2] - if len(placeholderRespEvent.Content.Parts) != 2 { - t.Errorf("Expected placeholder function response event to contain 2 parts, got %d", len(placeholderRespEvent.Content.Parts)) - } - - expectedPlaceholders := []*genai.Part{ - { - FunctionResponse: &genai.FunctionResponse{ - Name: "secure_action", - ID: "secure_call_1", - Response: map[string]any{"error": `error tool "secure_action" requires confirmation, please approve or reject`}, - }, - }, - { - FunctionResponse: &genai.FunctionResponse{ - Name: "secure_action", - ID: "secure_call_2", - Response: map[string]any{"error": `error tool "secure_action" requires confirmation, please approve or reject`}, - }, - }, - } - - if diff := cmp.Diff(expectedPlaceholders, placeholderRespEvent.Content.Parts); diff != "" { - t.Errorf("Mismatch in placeholder tool responses (-want +got):\n%s", diff) - } - // --- TURN 2: User confirms the actions --- // Build user's response to confirmation requests. userConfirmation := toolconfirmation.ToolConfirmation{Confirmed: true} @@ -387,7 +387,7 @@ func TestParallelFunctionCallsWithPartialHITL(t *testing.T) { t.Fatalf("Expected 3 events in Turn 1, got %d", len(turn1Events)) } - confirmEvent := turn1Events[1] + confirmEvent := turn1Events[2] var confirmCallID1, confirmCallID2 string for _, p := range confirmEvent.Content.Parts { origCall, err := toolconfirmation.OriginalCallFrom(p.FunctionCall) diff --git a/tool/functiontool/function_test.go b/tool/functiontool/function_test.go index 570b726ab..7a8517979 100644 --- a/tool/functiontool/function_test.go +++ b/tool/functiontool/function_test.go @@ -584,6 +584,9 @@ func TestToolConfirmation(t *testing.T) { args: map[string]any{"Num": 1}, want: []*genai.Content{ genai.NewContentFromFunctionCall("test_tool", map[string]any{"Num": 1}, "model"), + genai.NewContentFromFunctionResponse("test_tool", map[string]any{ + "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"Num": 1}, @@ -593,9 +596,6 @@ func TestToolConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call test_tool() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse("test_tool", map[string]any{ - "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), - }, "user"), }, }, { @@ -608,6 +608,9 @@ func TestToolConfirmation(t *testing.T) { confirmFunctionResponse: &genai.FunctionResponse{Name: toolconfirmation.FunctionCallName, Response: map[string]any{"confirmed": true}}, want: []*genai.Content{ genai.NewContentFromFunctionCall("test_tool", map[string]any{"Num": 1}, "model"), + genai.NewContentFromFunctionResponse("test_tool", map[string]any{ + "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"Num": 1}, @@ -617,9 +620,6 @@ func TestToolConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call test_tool() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse("test_tool", map[string]any{ - "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), - }, "user"), genai.NewContentFromFunctionResponse("test_tool", map[string]any{"result": "ok"}, "user"), }, }, @@ -633,6 +633,9 @@ func TestToolConfirmation(t *testing.T) { confirmFunctionResponse: &genai.FunctionResponse{Name: toolconfirmation.FunctionCallName, Response: map[string]any{"confirmed": false}}, want: []*genai.Content{ genai.NewContentFromFunctionCall("test_tool", map[string]any{"Num": 1}, "model"), + genai.NewContentFromFunctionResponse("test_tool", map[string]any{ + "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"Num": 1}, @@ -642,9 +645,6 @@ func TestToolConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call test_tool() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse("test_tool", map[string]any{ - "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), - }, "user"), genai.NewContentFromFunctionResponse("test_tool", map[string]any{ "error": errors.New("error tool \"test_tool\" call is rejected"), }, "user"), @@ -675,6 +675,9 @@ func TestToolConfirmation(t *testing.T) { args: map[string]any{"Num": 4}, want: []*genai.Content{ genai.NewContentFromFunctionCall("test_tool", map[string]any{"Num": 4}, "model"), + genai.NewContentFromFunctionResponse("test_tool", map[string]any{ + "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"Num": 4}, @@ -684,9 +687,6 @@ func TestToolConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call test_tool() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse("test_tool", map[string]any{ - "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), - }, "user"), }, }, { @@ -701,6 +701,9 @@ func TestToolConfirmation(t *testing.T) { confirmFunctionResponse: &genai.FunctionResponse{Name: toolconfirmation.FunctionCallName, Response: map[string]any{"confirmed": true}}, want: []*genai.Content{ genai.NewContentFromFunctionCall("test_tool", map[string]any{"Num": 4}, "model"), + genai.NewContentFromFunctionResponse("test_tool", map[string]any{ + "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"Num": 4}, @@ -710,9 +713,6 @@ func TestToolConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call test_tool() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse("test_tool", map[string]any{ - "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), - }, "user"), genai.NewContentFromFunctionResponse("test_tool", map[string]any{"result": "ok"}, "user"), }, }, @@ -728,6 +728,9 @@ func TestToolConfirmation(t *testing.T) { confirmFunctionResponse: &genai.FunctionResponse{Name: toolconfirmation.FunctionCallName, Response: map[string]any{"confirmed": false}}, want: []*genai.Content{ genai.NewContentFromFunctionCall("test_tool", map[string]any{"Num": 4}, "model"), + genai.NewContentFromFunctionResponse("test_tool", map[string]any{ + "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"Num": 4}, @@ -737,9 +740,6 @@ func TestToolConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call test_tool() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse("test_tool", map[string]any{ - "error": errors.New("error tool \"test_tool\" requires confirmation, please approve or reject"), - }, "user"), genai.NewContentFromFunctionResponse("test_tool", map[string]any{ "error": errors.New("error tool \"test_tool\" call is rejected"), }, "user"), @@ -865,6 +865,56 @@ func TestToolConfirmation(t *testing.T) { } } +func TestToolConfirmationYieldsFunctionResponseBeforeConfirmation(t *testing.T) { + mockModel := &testutil.MockModel{ + Responses: []*genai.Content{ + genai.NewContentFromFunctionCall("test_tool", map[string]any{"Num": 1}, genai.RoleModel), + }, + } + + myTool, err := functiontool.New(functiontool.Config{ + Name: "test_tool", + RequireConfirmation: true, + }, okFunc) + if err != nil { + t.Fatalf("Failed to create tool: %v", err) + } + + a, err := llmagent.New(llmagent.Config{ + Name: "simple agent", + Model: mockModel, + Tools: []tool.Tool{myTool}, + }) + if err != nil { + t.Fatalf("failed to create llm agent: %v", err) + } + + runner := testutil.NewTestAgentRunner(t, a) + sawFunctionResponse := false + + for got, err := range runner.Run(t, "id", "message") { + // The mock model emits a sentinel error once exhausted; treat any + // error as end-of-stream and rely on the post-loop assertions. + if err != nil { + break + } + + for _, part := range got.Content.Parts { + if part.FunctionResponse != nil && part.FunctionResponse.Name == "test_tool" { + sawFunctionResponse = true + } + if part.FunctionCall != nil && part.FunctionCall.Name == toolconfirmation.FunctionCallName { + if !sawFunctionResponse { + t.Fatal("confirmation was yielded before the tool function response") + } + return + } + } + } + + t.Fatal("expected confirmation event") +} + // Mock types for TArgs and TResults type TestArgs struct { Name string diff --git a/tool/mcptoolset/set_test.go b/tool/mcptoolset/set_test.go index 1fe3cb512..bcd9e38b5 100644 --- a/tool/mcptoolset/set_test.go +++ b/tool/mcptoolset/set_test.go @@ -415,6 +415,9 @@ func TestMCPToolSetConfirmation(t *testing.T) { city: "Lisbon", want: []*genai.Content{ genai.NewContentFromFunctionCall(toolName, map[string]any{"city": "Lisbon"}, "model"), + genai.NewContentFromFunctionResponse(toolName, map[string]any{ + "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"city": "Lisbon"}, @@ -424,9 +427,6 @@ func TestMCPToolSetConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call get_weather() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse(toolName, map[string]any{ - "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), - }, "user"), }, }, { @@ -438,6 +438,9 @@ func TestMCPToolSetConfirmation(t *testing.T) { confirmFunctionResponse: &genai.FunctionResponse{Name: toolconfirmation.FunctionCallName, Response: map[string]any{"confirmed": true}}, want: []*genai.Content{ genai.NewContentFromFunctionCall(toolName, map[string]any{"city": "Lisbon"}, "model"), + genai.NewContentFromFunctionResponse(toolName, map[string]any{ + "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"city": "Lisbon"}, @@ -447,9 +450,6 @@ func TestMCPToolSetConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call get_weather() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse(toolName, map[string]any{ - "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), - }, "user"), genai.NewContentFromFunctionResponse(toolName, map[string]any{ "output": map[string]any{"weather_summary": string(`Today in "Lisbon" is sunny`)}, }, "user"), @@ -465,6 +465,9 @@ func TestMCPToolSetConfirmation(t *testing.T) { confirmFunctionResponse: &genai.FunctionResponse{Name: toolconfirmation.FunctionCallName, Response: map[string]any{"confirmed": false}}, want: []*genai.Content{ genai.NewContentFromFunctionCall(toolName, map[string]any{"city": "Lisbon"}, "model"), + genai.NewContentFromFunctionResponse(toolName, map[string]any{ + "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"city": "Lisbon"}, @@ -474,9 +477,6 @@ func TestMCPToolSetConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call get_weather() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse(toolName, map[string]any{ - "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), - }, "user"), genai.NewContentFromFunctionResponse(toolName, map[string]any{ "error": errors.New("error tool \"get_weather\" call is rejected"), }, "user"), @@ -523,6 +523,9 @@ func TestMCPToolSetConfirmation(t *testing.T) { city: "Lisbon", want: []*genai.Content{ genai.NewContentFromFunctionCall(toolName, map[string]any{"city": "Lisbon"}, "model"), + genai.NewContentFromFunctionResponse(toolName, map[string]any{ + "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"city": "Lisbon"}, @@ -532,9 +535,6 @@ func TestMCPToolSetConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call get_weather() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse(toolName, map[string]any{ - "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), - }, "user"), }, }, { @@ -545,6 +545,9 @@ func TestMCPToolSetConfirmation(t *testing.T) { city: "Lisbon", want: []*genai.Content{ genai.NewContentFromFunctionCall(toolName, map[string]any{"city": "Lisbon"}, "model"), + genai.NewContentFromFunctionResponse(toolName, map[string]any{ + "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"city": "Lisbon"}, @@ -554,9 +557,6 @@ func TestMCPToolSetConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call get_weather() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse(toolName, map[string]any{ - "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), - }, "user"), }, }, { @@ -568,6 +568,9 @@ func TestMCPToolSetConfirmation(t *testing.T) { confirmFunctionResponse: &genai.FunctionResponse{Name: toolconfirmation.FunctionCallName, Response: map[string]any{"confirmed": true}}, want: []*genai.Content{ genai.NewContentFromFunctionCall(toolName, map[string]any{"city": "Lisbon"}, "model"), + genai.NewContentFromFunctionResponse(toolName, map[string]any{ + "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"city": "Lisbon"}, @@ -577,9 +580,6 @@ func TestMCPToolSetConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call get_weather() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse(toolName, map[string]any{ - "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), - }, "user"), genai.NewContentFromFunctionResponse(toolName, map[string]any{ "output": map[string]any{"weather_summary": string(`Today in "Lisbon" is sunny`)}, }, "user"), @@ -595,6 +595,9 @@ func TestMCPToolSetConfirmation(t *testing.T) { confirmFunctionResponse: &genai.FunctionResponse{Name: toolconfirmation.FunctionCallName, Response: map[string]any{"confirmed": false}}, want: []*genai.Content{ genai.NewContentFromFunctionCall(toolName, map[string]any{"city": "Lisbon"}, "model"), + genai.NewContentFromFunctionResponse(toolName, map[string]any{ + "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), + }, "user"), genai.NewContentFromFunctionCall(toolconfirmation.FunctionCallName, map[string]any{ "originalFunctionCall": &genai.FunctionCall{ Args: map[string]any{"city": "Lisbon"}, @@ -604,9 +607,6 @@ func TestMCPToolSetConfirmation(t *testing.T) { Hint: "Please approve or reject the tool call get_weather() by responding with a FunctionResponse with an expected ToolConfirmation payload.", }, }, "model"), - genai.NewContentFromFunctionResponse(toolName, map[string]any{ - "error": errors.New("error tool \"get_weather\" requires confirmation, please approve or reject"), - }, "user"), genai.NewContentFromFunctionResponse(toolName, map[string]any{ "error": errors.New("error tool \"get_weather\" call is rejected"), }, "user"), From f09fe89b1bc4e3af64aae9a75318e00cdea9e069 Mon Sep 17 00:00:00 2001 From: Gustaf <32815781+gustafvh@users.noreply.github.com> Date: Mon, 25 May 2026 12:28:08 +0200 Subject: [PATCH 56/86] fix: preserve Vertex AI function call IDs (#762) Keep function call and response IDs when deserializing Vertex AI session events so tool history can be matched across invocations. --- session/vertexai/vertexai_client.go | 2 + session/vertexai/vertexai_test.go | 65 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/session/vertexai/vertexai_client.go b/session/vertexai/vertexai_client.go index 387a80ce3..dab671a3c 100644 --- a/session/vertexai/vertexai_client.go +++ b/session/vertexai/vertexai_client.go @@ -461,12 +461,14 @@ func aiplatformToGenaiContent(rpcResp *aiplatformpb.SessionEvent) *genai.Content case *aiplatformpb.Part_FunctionCall: argsMap := v.FunctionCall.Args.AsMap() // Converts *structpb.Struct -> map[string]any part.FunctionCall = &genai.FunctionCall{ + ID: v.FunctionCall.Id, Name: v.FunctionCall.Name, Args: argsMap, } case *aiplatformpb.Part_FunctionResponse: responseMap := v.FunctionResponse.Response.AsMap() // Converts *structpb.Struct -> map[string]any part.FunctionResponse = &genai.FunctionResponse{ + ID: v.FunctionResponse.Id, Name: v.FunctionResponse.Name, Response: responseMap, } diff --git a/session/vertexai/vertexai_test.go b/session/vertexai/vertexai_test.go index ad6046461..9aa3a6874 100644 --- a/session/vertexai/vertexai_test.go +++ b/session/vertexai/vertexai_test.go @@ -18,6 +18,11 @@ import ( "testing" "google.golang.org/adk/util/vertexai" + + "google.golang.org/genai" + "google.golang.org/protobuf/types/known/structpb" + + aiplatformpb "cloud.google.com/go/aiplatform/apiv1beta1/aiplatformpb" ) func TestGetReasoningEngineID(t *testing.T) { @@ -104,3 +109,63 @@ func TestGetReasoningEngineID(t *testing.T) { }) } } + +func TestAiplatformToGenaiContentPreservesFunctionIDs(t *testing.T) { + args, err := structpb.NewStruct(map[string]any{"city": "Stockholm"}) + if err != nil { + t.Fatalf("structpb.NewStruct(args) failed: %v", err) + } + response, err := structpb.NewStruct(map[string]any{"temperature": 21}) + if err != nil { + t.Fatalf("structpb.NewStruct(response) failed: %v", err) + } + + content := aiplatformToGenaiContent(&aiplatformpb.SessionEvent{ + Content: &aiplatformpb.Content{ + Role: string(genai.RoleModel), + Parts: []*aiplatformpb.Part{ + { + Data: &aiplatformpb.Part_FunctionCall{ + FunctionCall: &aiplatformpb.FunctionCall{ + Id: "call-123", + Name: "get_weather", + Args: args, + }, + }, + }, + { + Data: &aiplatformpb.Part_FunctionResponse{ + FunctionResponse: &aiplatformpb.FunctionResponse{ + Id: "call-123", + Name: "get_weather", + Response: response, + }, + }, + }, + }, + }, + }) + + if content == nil { + t.Fatal("aiplatformToGenaiContent() returned nil content") + } + if got, want := len(content.Parts), 2; got != want { + t.Fatalf("len(content.Parts) = %d, want %d", got, want) + } + + functionCall := content.Parts[0].FunctionCall + if functionCall == nil { + t.Fatal("content.Parts[0].FunctionCall is nil") + } + if got, want := functionCall.ID, "call-123"; got != want { + t.Errorf("FunctionCall.ID = %q, want %q", got, want) + } + + functionResponse := content.Parts[1].FunctionResponse + if functionResponse == nil { + t.Fatal("content.Parts[1].FunctionResponse is nil") + } + if got, want := functionResponse.ID, "call-123"; got != want { + t.Errorf("FunctionResponse.ID = %q, want %q", got, want) + } +} From ae8321f1de5a0c1b0becc89947b4102fd8897b31 Mon Sep 17 00:00:00 2001 From: Serob Nahapetyan Date: Mon, 25 May 2026 13:05:45 +0200 Subject: [PATCH 57/86] feat: update examples and cli launcher to use a2a-go/v2 sdk (#780) update examples and cli launcher to use a2a-go/v2 sdk --- cmd/launcher/launcher.go | 2 +- cmd/launcher/web/a2a/a2a.go | 53 ++++++--- cmd/launcher/web/a2a/a2a_test.go | 115 +++++++++++++------ examples/a2a/main.go | 27 +++-- examples/web/main.go | 12 +- server/agentengine/internal/helper/encode.go | 2 +- 6 files changed, 143 insertions(+), 68 deletions(-) diff --git a/cmd/launcher/launcher.go b/cmd/launcher/launcher.go index 82570ce22..2642bcaaa 100644 --- a/cmd/launcher/launcher.go +++ b/cmd/launcher/launcher.go @@ -18,7 +18,7 @@ package launcher import ( "context" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2asrv" "google.golang.org/adk/agent" "google.golang.org/adk/artifact" diff --git a/cmd/launcher/web/a2a/a2a.go b/cmd/launcher/web/a2a/a2a.go index 0eab946f6..26cf47787 100644 --- a/cmd/launcher/web/a2a/a2a.go +++ b/cmd/launcher/web/a2a/a2a.go @@ -20,19 +20,23 @@ import ( "fmt" "net/url" - a2acore "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" + a2acore "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/gorilla/mux" "google.golang.org/adk/cmd/launcher" "google.golang.org/adk/cmd/launcher/web" "google.golang.org/adk/internal/cli/util" "google.golang.org/adk/runner" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" ) -// apiPath is a suffix used to build an A2A invocation URL -const apiPath = "/a2a/invoke" +// compatApiPath is a suffix used to build an A2A invocation URL for 0.3 +const compatApiPath = "/a2a/invoke" + +// apiPath is a suffix used to build an A2A invocation URL for 1.0 +const apiPath = "/a2a/v1/invoke" // a2aConfig contains parameters for launching ADK A2A server type a2aConfig struct { @@ -79,6 +83,10 @@ func (a *a2aLauncher) Parse(args []string) ([]string, error) { // SetupSubrouters implements the web.Sublauncher interface. It adds A2A paths to the main router. func (a *a2aLauncher) SetupSubrouters(router *mux.Router, config *launcher.Config) error { + publicCompatURL, err := url.JoinPath(a.config.agentURL, compatApiPath) + if err != nil { + return err + } publicURL, err := url.JoinPath(a.config.agentURL, apiPath) if err != nil { return err @@ -86,17 +94,29 @@ func (a *a2aLauncher) SetupSubrouters(router *mux.Router, config *launcher.Confi rootAgent := config.AgentLoader.RootAgent() agentCard := &a2acore.AgentCard{ - Name: rootAgent.Name(), - Description: rootAgent.Description(), - DefaultInputModes: []string{"text/plain"}, - DefaultOutputModes: []string{"text/plain"}, - URL: publicURL, - PreferredTransport: a2acore.TransportProtocolJSONRPC, - Skills: adka2a.BuildAgentSkills(rootAgent), - Capabilities: a2acore.AgentCapabilities{Streaming: true}, - SupportsAuthenticatedExtendedCard: false, + Name: rootAgent.Name(), + Description: rootAgent.Description(), + DefaultInputModes: []string{"text/plain"}, + DefaultOutputModes: []string{"text/plain"}, + SupportedInterfaces: []*a2acore.AgentInterface{ + { + URL: publicURL, + ProtocolBinding: a2acore.TransportProtocolJSONRPC, + ProtocolVersion: a2acore.Version, + }, + { + URL: publicCompatURL, + ProtocolBinding: a2acore.TransportProtocolJSONRPC, + ProtocolVersion: a2av0.Version, + }, + }, + Version: "1.0.0", + Skills: adka2a.BuildAgentSkills(rootAgent), + Capabilities: a2acore.AgentCapabilities{Streaming: true}, } - router.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(agentCard)) + + compatProducer := a2av0.NewStaticAgentCardProducer(agentCard) + router.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewAgentCardHandler(compatProducer)) agent := config.AgentLoader.RootAgent() executor := adka2a.NewExecutor(adka2a.ExecutorConfig{ @@ -110,7 +130,10 @@ func (a *a2aLauncher) SetupSubrouters(router *mux.Router, config *launcher.Confi }, }) reqHandler := a2asrv.NewHandler(executor, config.A2AOptions...) + router.Handle(apiPath, a2asrv.NewJSONRPCHandler(reqHandler)) + router.Handle(compatApiPath, a2av0.NewJSONRPCHandler(reqHandler)) + return nil } diff --git a/cmd/launcher/web/a2a/a2a_test.go b/cmd/launcher/web/a2a/a2a_test.go index 147a495d1..7b3131e36 100644 --- a/cmd/launcher/web/a2a/a2a_test.go +++ b/cmd/launcher/web/a2a/a2a_test.go @@ -21,9 +21,12 @@ import ( "testing" "time" - a2acore "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2aclient" - "github.com/a2aproject/a2a-go/a2aclient/agentcard" + a2alegacy "github.com/a2aproject/a2a-go/a2a" + a2alegacyclient "github.com/a2aproject/a2a-go/a2aclient" + a2alegacyagentcard "github.com/a2aproject/a2a-go/a2aclient/agentcard" + a2acore "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" "google.golang.org/genai" "google.golang.org/adk/agent" @@ -92,41 +95,83 @@ func TestWebLauncher_ServesA2A(t *testing.T) { } }() - var card *a2acore.AgentCard - for retry := range 3 { - time.Sleep(10 * time.Millisecond) // give server time to start - card, err = agentcard.DefaultResolver.Resolve(ctx, "http://localhost:"+strconv.Itoa(port)) - if err == nil { - break + t.Run("A2A v2 client", func(t *testing.T) { + var card *a2acore.AgentCard + for retry := range 3 { + time.Sleep(10 * time.Millisecond) // give server time to start + card, err = agentcard.DefaultResolver.Resolve(ctx, "http://localhost:"+strconv.Itoa(port)) + if err == nil { + break + } + if retry == 2 { + t.Fatalf("cardResolver.Resolve() error = %v", err) + } } - if retry == 2 { - t.Fatalf("cardResolver.Resolve() error = %v", err) + + client, err := a2aclient.NewFromCard(ctx, card) + if err != nil { + t.Fatalf("a2aclient.NewFromCard() error = %v", err) } - } - client, err := a2aclient.NewFromCard(ctx, card) - if err != nil { - t.Fatalf("a2aclient.NewFromCard() error = %v", err) - } + got, err := client.SendMessage(ctx, &a2acore.SendMessageRequest{ + Message: a2acore.NewMessage(a2acore.MessageRoleUser, a2acore.NewTextPart("Hi!")), + }) + if err != nil { + t.Fatalf("client.SendMessage() error = %v", err) + } + task, ok := got.(*a2acore.Task) + if !ok { + t.Fatalf("client.SendMessage() result type = %T, want a2a.Task", got) + } + if len(task.Artifacts) != 1 { + t.Fatalf("len(task.Artifacts) = %d, want 1", len(task.Artifacts)) + } + parts := task.Artifacts[0].Parts + if len(parts) != 1 { + t.Fatalf("len(task.Artifacts[0].Parts) = %d, want 1", len(parts)) + } + if gotPart := parts[0].Text(); gotPart != wantMessage { + t.Fatalf("task.Artifacts[0].Parts[0] = %v, want %v", parts[0], wantMessage) + } + }) + + t.Run("A2A v0 client", func(t *testing.T) { + var card *a2alegacy.AgentCard + for retry := range 3 { + time.Sleep(10 * time.Millisecond) // give server time to start + card, err = a2alegacyagentcard.DefaultResolver.Resolve(ctx, "http://localhost:"+strconv.Itoa(port)) + if err == nil { + break + } + if retry == 2 { + t.Fatalf("a2alegacyagentcard.DefaultResolver.Resolve() error = %v", err) + } + } + + client, err := a2alegacyclient.NewFromCard(ctx, card) + if err != nil { + t.Fatalf("a2alegacyclient.NewFromCard() error = %v", err) + } - got, err := client.SendMessage(ctx, &a2acore.MessageSendParams{ - Message: a2acore.NewMessage(a2acore.MessageRoleUser, a2acore.TextPart{Text: "Hi!"}), + got, err := client.SendMessage(ctx, &a2alegacy.MessageSendParams{ + Message: a2alegacy.NewMessage(a2alegacy.MessageRoleUser, a2alegacy.TextPart{Text: "Hi!"}), + }) + if err != nil { + t.Fatalf("client.SendMessage() error = %v", err) + } + task, ok := got.(*a2alegacy.Task) + if !ok { + t.Fatalf("client.SendMessage() result type = %T, want a2alegacy.Task", got) + } + if len(task.Artifacts) != 1 { + t.Fatalf("len(task.Artifacts) = %d, want 1", len(task.Artifacts)) + } + parts := task.Artifacts[0].Parts + if len(parts) != 1 { + t.Fatalf("len(task.Artifacts[0].Parts) = %d, want 1", len(parts)) + } + if gotPart := parts[0].(a2alegacy.TextPart); gotPart.Text != wantMessage { + t.Fatalf("task.Artifacts[0].Parts[0] = %v, want %v", parts[0], wantMessage) + } }) - if err != nil { - t.Fatalf("client.SendMessage() error = %v", err) - } - task, ok := got.(*a2acore.Task) - if !ok { - t.Fatalf("client.SendMessage() result type = %T, want a2a.Task", got) - } - if len(task.Artifacts) != 1 { - t.Fatalf("len(task.Artifacts) = %d, want 1", len(task.Artifacts)) - } - parts := task.Artifacts[0].Parts - if len(parts) != 1 { - t.Fatalf("len(task.Artifacts[0].Parts) = %d, want 1", len(parts)) - } - if gotPart, ok := parts[0].(a2acore.TextPart); !ok || gotPart.Text != wantMessage { - t.Fatalf("task.Artifacts[0].Parts[0] = %v, want %v", parts[0], a2acore.TextPart{Text: wantMessage}) - } } diff --git a/examples/a2a/main.go b/examples/a2a/main.go index 239272942..7e74ccbda 100644 --- a/examples/a2a/main.go +++ b/examples/a2a/main.go @@ -23,18 +23,18 @@ import ( "net/url" "os" - "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" "google.golang.org/genai" "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" - "google.golang.org/adk/agent/remoteagent" + "google.golang.org/adk/agent/remoteagent/v2" "google.golang.org/adk/cmd/launcher" "google.golang.org/adk/cmd/launcher/full" "google.golang.org/adk/model/gemini" "google.golang.org/adk/runner" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" "google.golang.org/adk/session" "google.golang.org/adk/tool" "google.golang.org/adk/tool/geminitool" @@ -79,10 +79,19 @@ func startWeatherAgentServer() string { agentPath := "/invoke" agentCard := &a2a.AgentCard{ - Name: agent.Name(), + Name: agent.Name(), + Description: agent.Description(), + SupportedInterfaces: []*a2a.AgentInterface{ + { + URL: baseURL.JoinPath(agentPath).String(), + ProtocolBinding: a2a.TransportProtocolJSONRPC, + ProtocolVersion: a2a.Version, + }, + }, + Version: "1.0.0", + DefaultInputModes: []string{"text/plain"}, + DefaultOutputModes: []string{"text/plain"}, Skills: adka2a.BuildAgentSkills(agent), - PreferredTransport: a2a.TransportProtocolJSONRPC, - URL: baseURL.JoinPath(agentPath).String(), Capabilities: a2a.AgentCapabilities{Streaming: true}, } @@ -113,8 +122,8 @@ func main() { a2aServerAddress := startWeatherAgentServer() remoteAgent, err := remoteagent.NewA2A(remoteagent.A2AConfig{ - Name: "A2A Weather agent", - AgentCardSource: a2aServerAddress, + Name: "A2A Weather agent", + AgentCardProvider: remoteagent.NewAgentCardProvider(a2aServerAddress), }) if err != nil { log.Fatalf("Failed to create a remote agent: %v", err) diff --git a/examples/web/main.go b/examples/web/main.go index 431a0673b..de6bd3960 100644 --- a/examples/web/main.go +++ b/examples/web/main.go @@ -19,7 +19,7 @@ import ( "log" "os" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/google/uuid" "google.golang.org/genai" @@ -55,11 +55,9 @@ type AuthInterceptor struct { } // Before implements a before request callback. -func (a *AuthInterceptor) Before(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) (context.Context, error) { - callCtx.User = &a2asrv.AuthenticatedUser{ - UserName: "user", - } - return ctx, nil +func (a *AuthInterceptor) Before(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) (context.Context, any, error) { + callCtx.User = a2asrv.NewAuthenticatedUser("user", nil) + return ctx, nil, nil } func main() { @@ -105,7 +103,7 @@ func main() { SessionService: sessionService, AgentLoader: agentLoader, A2AOptions: []a2asrv.RequestHandlerOption{ - a2asrv.WithCallInterceptor(&AuthInterceptor{}), + a2asrv.WithCallInterceptors(&AuthInterceptor{}), }, } diff --git a/server/agentengine/internal/helper/encode.go b/server/agentengine/internal/helper/encode.go index ab99078f5..8e9eb905d 100644 --- a/server/agentengine/internal/helper/encode.go +++ b/server/agentengine/internal/helper/encode.go @@ -145,7 +145,7 @@ func convertSnake(path, indent string, o any) (any, error) { return map[string]any{}, nil } return res, nil - case reflect.Ptr: + case reflect.Pointer: if v.IsNil() { return nil, nil } From 05d6b957bbdfb0273a666e21be48f0f3a56e0987 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Wed, 27 May 2026 11:59:34 +0200 Subject: [PATCH 58/86] feat: adka2a structured error propagation (#874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * implement adka2a structured error propagation * lint * status preservation test * fix wrong parts passed * add state guard for extracting errmessage * fix test --------- Co-authored-by: João Westerberg --- agent/remoteagent/v2/a2a_e2e_test.go | 50 ++++++++++ server/adka2a/v2/events.go | 83 +++++++++++----- server/adka2a/v2/events_test.go | 136 ++++++++++++++++++++++++++- server/adka2a/v2/executor_test.go | 10 +- server/adka2a/v2/metadata.go | 1 + server/adka2a/v2/processor.go | 30 +++--- 6 files changed, 269 insertions(+), 41 deletions(-) diff --git a/agent/remoteagent/v2/a2a_e2e_test.go b/agent/remoteagent/v2/a2a_e2e_test.go index 32754a190..490301a23 100644 --- a/agent/remoteagent/v2/a2a_e2e_test.go +++ b/agent/remoteagent/v2/a2a_e2e_test.go @@ -1199,3 +1199,53 @@ func TestA2AMultiHopInputRequiredCancellation(t *testing.T) { t.Fatalf("remoteTask.Status.State = %q, want %q", remoteTask.Status.State, a2a.TaskStateCanceled) } } + +func TestA2AMultiHopStructuredErrorPropagation(t *testing.T) { + // Server B with structured error serialization logic + structuredErr := a2a.NewDataPart(map[string]any{"error_type": "auth_required"}) + serverB := startA2AServer(adka2a.NewExecutor(adka2a.ExecutorConfig{ + RunnerConfig: runner.Config{ + AppName: "broken", + Agent: utils.Must(agent.New(agent.Config{ + Name: "broken", + Run: func(ic agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + yield(nil, a2a.ErrUnauthorized) + } + }, + })), + SessionService: session.InMemoryService(), + }, + AfterExecuteCallback: func(ctx adka2a.ExecutorContext, finalEvent *a2a.TaskStatusUpdateEvent, err error) error { + if errors.Is(err, a2a.ErrUnauthorized) { + finalEvent.Status.Message.Parts = append(finalEvent.Status.Message.Parts, structuredErr) + } + return nil + }, + })) + defer serverB.Close() + + // Server A, default configuration + remoteAgent := newA2ARemoteAgent(t, "broken-remote", serverB) + rootAgent := newRootAgent("root", remoteAgent) + executorA := newAgentExecutor(rootAgent, nil, adka2a.OutputArtifactPerRun) + serverA := startA2AServer(executorA) + defer serverA.Close() + + // Send message + clientA := newA2AClient(t, serverA) + msg1 := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("Hello")) + task1 := mustSendMessage(t, clientA, msg1) + if task1.Status.State != a2a.TaskStateFailed { + t.Fatalf("task1.Status.State = %q, want %q", task1.Status.State, a2a.TaskStateFailed) + } + if len(task1.Status.Message.Parts) != 2 { + t.Fatalf("len(task1.Status.Message.Parts) = %d, want len([error text, extra parts]) = 2", len(task1.Status.Message.Parts)) + } + if !strings.Contains(task1.Status.Message.Parts[0].Text(), a2a.ErrUnauthorized.Error()) { + t.Fatalf("status.Message.Parts[0].Text() = %s, want contain %q", task1.Status.Message.Parts[0].Text(), a2a.ErrUnauthorized.Error()) + } + if diff := cmp.Diff(task1.Status.Message.Parts[1].Data(), structuredErr.Data()); diff != "" { + t.Fatalf("wrong structured error part (-want,+got):\n%s", diff) + } +} diff --git a/server/adka2a/v2/events.go b/server/adka2a/v2/events.go index 7a47cdd67..ede8a5153 100644 --- a/server/adka2a/v2/events.go +++ b/server/adka2a/v2/events.go @@ -254,20 +254,19 @@ func taskToEvent(ctx agent.InvocationContext, task *a2a.Task, partConverter A2AP event := NewRemoteAgentEvent(ctx) - if task.Status.Message != nil { - allMsgParts, err := convertParts(ctx, task, task.Status.Message.Parts, partConverter) - if err != nil { - return nil, fmt.Errorf("failed to convert status message parts: %w", err) - } - lrtIDs := getLongRunningToolIDs(task.Status.Message.Parts, allMsgParts) - msgParts := filterNilParts(allMsgParts) + convertedStatusMsg, err := convertStatusMessage(ctx, task, task.Status, partConverter) + if err != nil { + return nil, err + } + parts = append(parts, convertedStatusMsg.parts...) + longRunningToolIDs = append(longRunningToolIDs, convertedStatusMsg.longRunningToolIDs...) - if task.Status.State == a2a.TaskStateFailed && len(msgParts) == 1 && msgParts[0].Text != "" { - event.ErrorMessage = msgParts[0].Text + if task.Status.State == a2a.TaskStateFailed { + if convertedStatusMsg.errorMessage != "" { + event.ErrorMessage = convertedStatusMsg.errorMessage } else { - parts = append(parts, msgParts...) + event.ErrorMessage = "a2a task failed" } - longRunningToolIDs = append(longRunningToolIDs, lrtIDs...) } isTerminal := task.Status.State.Terminal() || task.Status.State == a2a.TaskStateInputRequired @@ -294,26 +293,26 @@ func finalTaskStatusUpdateToEvent(ctx agent.InvocationContext, update *a2a.TaskS event := NewRemoteAgentEvent(ctx) - var allParts []*genai.Part - var err error - if update.Status.Message != nil { - allParts, err = convertParts(ctx, update, update.Status.Message.Parts, partConverter) - if err != nil { - return nil, fmt.Errorf("failed to convert status message parts: %w", err) - } + convertedStatusMsg, err := convertStatusMessage(ctx, update, update.Status, partConverter) + if err != nil { + return nil, err } - parts := filterNilParts(allParts) - if update.Status.State == a2a.TaskStateFailed && len(parts) == 1 && parts[0].Text != "" { - event.ErrorMessage = parts[0].Text - } else if len(parts) > 0 { - event.Content = genai.NewContentFromParts(parts, genai.RoleModel) + if len(convertedStatusMsg.parts) > 0 { + event.Content = genai.NewContentFromParts(convertedStatusMsg.parts, genai.RoleModel) + } + event.LongRunningToolIDs = convertedStatusMsg.longRunningToolIDs + + if update.Status.State == a2a.TaskStateFailed { + if convertedStatusMsg.errorMessage != "" { + event.ErrorMessage = convertedStatusMsg.errorMessage + } else { + event.ErrorMessage = "a2a task failed" + } } + if err := processA2AMeta(update, event); err != nil { return nil, fmt.Errorf("metadata processing failed: %w", err) } - if update.Status.Message != nil { - event.LongRunningToolIDs = getLongRunningToolIDs(update.Status.Message.Parts, allParts) - } event.TurnComplete = true return event, nil } @@ -382,3 +381,35 @@ func filterNilParts(parts []*genai.Part) []*genai.Part { } return result } + +type convertedStatusMessage struct { + errorMessage string + parts []*genai.Part + longRunningToolIDs []string +} + +func convertStatusMessage(ctx agent.InvocationContext, event a2a.Event, status a2a.TaskStatus, partConverter A2APartConverter) (*convertedStatusMessage, error) { + if status.Message == nil { + return &convertedStatusMessage{}, nil + } + errMessage := "" + parts := status.Message.Parts + if status.State == a2a.TaskStateFailed && len(parts) > 0 && parts[0].Text() != "" { + errMessage = parts[0].Text() + + isErrMessage, _ := parts[0].Metadata[metadataIsErrMessageKey].(bool) + if isErrMessage { + parts = parts[1:] + } + } + + convertedParts, err := convertParts(ctx, event, parts, partConverter) + if err != nil { + return nil, fmt.Errorf("failed to convert status message parts: %w", err) + } + return &convertedStatusMessage{ + errorMessage: errMessage, + parts: filterNilParts(convertedParts), + longRunningToolIDs: getLongRunningToolIDs(parts, convertedParts), + }, nil +} diff --git a/server/adka2a/v2/events_test.go b/server/adka2a/v2/events_test.go index 2d248c60d..3d72d639f 100644 --- a/server/adka2a/v2/events_test.go +++ b/server/adka2a/v2/events_test.go @@ -419,6 +419,7 @@ func TestToSessionEvent(t *testing.T) { }, want: &session.Event{ LLMResponse: model.LLMResponse{ + Content: genai.NewContentFromParts([]*genai.Part{genai.NewPartFromText("failed with an error")}, genai.RoleModel), ErrorMessage: "failed with an error", CustomMetadata: map[string]any{ customMetaTaskIDKey: string(taskID), @@ -483,7 +484,7 @@ func TestToSessionEvent(t *testing.T) { }, }, { - name: "task with single-part text status", + name: "failed task with single-part text status", input: &a2a.Task{ ID: taskID, ContextID: contextID, @@ -494,6 +495,7 @@ func TestToSessionEvent(t *testing.T) { }, want: &session.Event{ LLMResponse: model.LLMResponse{ + Content: genai.NewContentFromParts([]*genai.Part{genai.NewPartFromText("failed with an error")}, genai.RoleModel), ErrorMessage: "failed with an error", CustomMetadata: map[string]any{customMetaTaskIDKey: string(taskID), customMetaContextIDKey: contextID}, TurnComplete: true, @@ -502,6 +504,138 @@ func TestToSessionEvent(t *testing.T) { Branch: branch, }, }, + { + name: "completed task with single-part text status", + input: &a2a.Task{ + ID: taskID, + ContextID: contextID, + Status: a2a.TaskStatus{ + State: a2a.TaskStateCompleted, + Message: &a2a.Message{Parts: a2a.ContentParts{a2a.NewTextPart("failed with an error")}}, + }, + }, + want: &session.Event{ + LLMResponse: model.LLMResponse{ + Content: genai.NewContentFromParts([]*genai.Part{genai.NewPartFromText("failed with an error")}, genai.RoleModel), + CustomMetadata: map[string]any{customMetaTaskIDKey: string(taskID), customMetaContextIDKey: contextID}, + TurnComplete: true, + }, + Author: agentName, + Branch: branch, + }, + }, + { + name: "failed task with a multi-part status", + input: &a2a.Task{ + ID: taskID, + ContextID: contextID, + Status: a2a.TaskStatus{ + State: a2a.TaskStateFailed, + Message: a2a.NewMessage(a2a.MessageRoleAgent, + a2a.NewTextPart("failed with an error"), + a2a.NewTextPart("extra details"), + ), + }, + }, + want: &session.Event{ + LLMResponse: model.LLMResponse{ + ErrorMessage: "failed with an error", + Content: genai.NewContentFromParts([]*genai.Part{ + genai.NewPartFromText("failed with an error"), + genai.NewPartFromText("extra details"), + }, genai.RoleModel), + CustomMetadata: map[string]any{customMetaTaskIDKey: string(taskID), customMetaContextIDKey: contextID}, + TurnComplete: true, + }, + Author: agentName, + Branch: branch, + }, + }, + { + name: "failed task status with a multi-part status", + input: &a2a.TaskStatusUpdateEvent{ + TaskID: taskID, + ContextID: contextID, + Status: a2a.TaskStatus{ + State: a2a.TaskStateFailed, + Message: a2a.NewMessage(a2a.MessageRoleAgent, + a2a.NewTextPart("failed with an error"), + a2a.NewDataPart(map[string]any{"structured": true}), + ), + }, + }, + want: &session.Event{ + LLMResponse: model.LLMResponse{ + ErrorMessage: "failed with an error", + Content: genai.NewContentFromParts([]*genai.Part{ + genai.NewPartFromText("failed with an error"), + {InlineData: &genai.Blob{ + Data: []byte(`{"structured":true}`), + MIMEType: "text/plain", + }}, + }, genai.RoleModel), + CustomMetadata: map[string]any{customMetaTaskIDKey: string(taskID), customMetaContextIDKey: contextID}, + TurnComplete: true, + }, + Author: agentName, + Branch: branch, + }, + }, + { + name: "failed task status metadataIsErrMessageKey not in content", + input: &a2a.TaskStatusUpdateEvent{ + TaskID: taskID, + ContextID: contextID, + Status: a2a.TaskStatus{ + State: a2a.TaskStateFailed, + Message: a2a.NewMessage(a2a.MessageRoleAgent, + &a2a.Part{Content: a2a.Text("failed with an error"), Metadata: map[string]any{metadataIsErrMessageKey: true}}, + a2a.NewTextPart("extra details"), + ), + }, + }, + want: &session.Event{ + LLMResponse: model.LLMResponse{ + ErrorMessage: "failed with an error", + Content: genai.NewContentFromParts([]*genai.Part{genai.NewPartFromText("extra details")}, genai.RoleModel), + CustomMetadata: map[string]any{customMetaTaskIDKey: string(taskID), customMetaContextIDKey: contextID}, + TurnComplete: true, + }, + Author: agentName, + Branch: branch, + }, + }, + { + name: "input-required task status with err message and long-running tool", + input: &a2a.TaskStatusUpdateEvent{ + TaskID: taskID, + ContextID: contextID, + Status: a2a.TaskStatus{ + State: a2a.TaskStateFailed, + Message: a2a.NewMessage(a2a.MessageRoleAgent, + &a2a.Part{Content: a2a.Text("requesting input"), Metadata: map[string]any{metadataIsErrMessageKey: true}}, + func() *a2a.Part { + p := a2a.NewDataPart(map[string]any{"id": "tool_lr", "name": "LongRunning", "args": map[string]any{}}) + p.Metadata = map[string]any{a2aDataPartMetaTypeKey: a2aDataPartTypeFunctionCall, a2aDataPartMetaLongRunningKey: true} + return p + }(), + ), + }, + }, + want: &session.Event{ + LLMResponse: model.LLMResponse{ + Content: genai.NewContentFromParts([]*genai.Part{ + {FunctionCall: &genai.FunctionCall{ID: "tool_lr", Name: "LongRunning", Args: map[string]any{}}}, + }, genai.RoleModel), + ErrorMessage: "requesting input", + CustomMetadata: map[string]any{customMetaTaskIDKey: string(taskID), customMetaContextIDKey: contextID}, + TurnComplete: true, + }, + LongRunningToolIDs: []string{"tool_lr"}, + Author: agentName, + Branch: branch, + }, + }, } ignoreFields := []cmp.Option{ diff --git a/server/adka2a/v2/executor_test.go b/server/adka2a/v2/executor_test.go index ef27b96ad..4457c398a 100644 --- a/server/adka2a/v2/executor_test.go +++ b/server/adka2a/v2/executor_test.go @@ -111,7 +111,10 @@ func TestExecutor_Execute(t *testing.T) { wantEvents: []a2a.Event{ newFinalStatusUpdate( task, a2a.TaskStateFailed, - a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.NewTextPart("failed to create a session: session creation failed")), + a2a.NewMessageForTask(a2a.MessageRoleAgent, task, &a2a.Part{ + Content: a2a.Text("failed to create a session: session creation failed"), + Metadata: map[string]any{metadataIsErrMessageKey: true}, + }), ), }, }, @@ -180,7 +183,10 @@ func TestExecutor_Execute(t *testing.T) { a2a.NewArtifactEvent(task, a2a.NewTextPart("Hello")), newFinalStatusUpdate( task, a2a.TaskStateFailed, - a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.NewTextPart("agent run failed: OOF")), + a2a.NewMessageForTask(a2a.MessageRoleAgent, task, &a2a.Part{ + Content: a2a.Text("agent run failed: OOF"), + Metadata: map[string]any{metadataIsErrMessageKey: true}, + }), ), }, }, diff --git a/server/adka2a/v2/metadata.go b/server/adka2a/v2/metadata.go index 36511f392..d7159cb03 100644 --- a/server/adka2a/v2/metadata.go +++ b/server/adka2a/v2/metadata.go @@ -37,6 +37,7 @@ var ( metadataUsageKey = ToA2AMetaKey("usage_metadata") metadataCustomMetaKey = ToA2AMetaKey("custom_metadata") metadataPartialKey = ToA2AMetaKey("partial") + metadataIsErrMessageKey = ToA2AMetaKey("is_error_message") ) // ToA2AMetaKey adds a prefix used to differentiate ADK-related values stored in Metadata an A2A event. diff --git a/server/adka2a/v2/processor.go b/server/adka2a/v2/processor.go index f11ffae26..94dd4e9ab 100644 --- a/server/adka2a/v2/processor.go +++ b/server/adka2a/v2/processor.go @@ -81,6 +81,16 @@ func (p *eventProcessor) process(ctx context.Context, event *session.Event) (*a2 return nil, err } + event, err = p.inputRequiredProcessor.process(ctx, event) + if err != nil { + return nil, fmt.Errorf("input required processing failed: %w", err) + } + + parts, err := p.convertParts(ctx, event) + if err != nil { + return nil, err + } + resp := event.LLMResponse if resp.ErrorCode != "" || resp.ErrorMessage != "" { // TODO(yarolegovich): consider merging responses if multiple errors can be produced during an invocation @@ -89,22 +99,13 @@ func (p *eventProcessor) process(ctx context.Context, event *session.Event) (*a2 // not be reflected in this event's metadata terminalEventMeta := maps.Clone(eventMeta) p.failedEvent = toTaskFailedUpdateEvent(p.reqCtx, errorFromResponse(&resp), terminalEventMeta) + p.failedEvent.Status.Message.Parts = append(p.failedEvent.Status.Message.Parts, parts...) } } - event, err = p.inputRequiredProcessor.process(ctx, event) - if err != nil { - return nil, fmt.Errorf("input required processing failed: %w", err) - } - - parts, err := p.convertParts(ctx, event) - if err != nil { - return nil, err - } if len(parts) == 0 { return nil, nil } - result, err := p.eventToArtifact.transform(event, parts, eventMeta) if err != nil { return nil, err @@ -175,12 +176,17 @@ func (p *eventProcessor) convertParts(ctx context.Context, event *session.Event) } func toTaskFailedUpdateEvent(task a2a.TaskInfoProvider, cause error, meta map[string]any) *a2a.TaskStatusUpdateEvent { - msg := a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.NewTextPart(cause.Error())) + msgPart := a2a.NewTextPart(cause.Error()) + msgPart.Metadata = map[string]any{metadataIsErrMessageKey: true} + msg := a2a.NewMessageForTask(a2a.MessageRoleAgent, task, msgPart) ev := a2a.NewStatusUpdateEvent(task, a2a.TaskStateFailed, msg) ev.Metadata = meta return ev } func errorFromResponse(resp *model.LLMResponse) error { - return fmt.Errorf("llm error response: %q", resp.ErrorMessage) + if resp.ErrorCode == "" { + return fmt.Errorf("llm error response: %q", resp.ErrorMessage) + } + return fmt.Errorf("llm error response (code %s): %q", resp.ErrorCode, resp.ErrorMessage) } From 6e1fcdb303aeb18eae535edf5d662c98040d5591 Mon Sep 17 00:00:00 2001 From: Serob Nahapetyan Date: Thu, 28 May 2026 09:50:19 +0200 Subject: [PATCH 59/86] fix: a2a execution cleanup callback not triggered (#903) * fix A2AExecutionCleanupCallback trigger * default client factory --- agent/remoteagent/a2a_agent.go | 58 +++-- agent/remoteagent/a2a_agent_compat_test.go | 242 +++++++++++++++++++++ agent/remoteagent/v2/a2a_e2e_test.go | 31 ++- server/adka2a/executor.go | 27 ++- 4 files changed, 330 insertions(+), 28 deletions(-) diff --git a/agent/remoteagent/a2a_agent.go b/agent/remoteagent/a2a_agent.go index 2d68fc54b..593e840bd 100644 --- a/agent/remoteagent/a2a_agent.go +++ b/agent/remoteagent/a2a_agent.go @@ -27,7 +27,8 @@ import ( "github.com/a2aproject/a2a-go/a2aclient" "github.com/a2aproject/a2a-go/a2aclient/agentcard" "github.com/a2aproject/a2a-go/log" - v2a2a "github.com/a2aproject/a2a-go/v2/a2a" + a2av2 "github.com/a2aproject/a2a-go/v2/a2a" + a2aclientv2 "github.com/a2aproject/a2a-go/v2/a2aclient" "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" "google.golang.org/genai" @@ -136,6 +137,26 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) { Description: cfg.Description, BeforeAgentCallbacks: cfg.BeforeAgentCallbacks, AfterAgentCallbacks: cfg.AfterAgentCallbacks, + ClientProvider: func(ctx context.Context, card *a2av2.AgentCard) (v2.A2AClient, error) { + if cfg.ClientFactory == nil { + factory := a2aclientv2.NewFactory( + a2aclientv2.WithCompatTransport( + a2av0.Version, a2av2.TransportProtocolJSONRPC, a2av0.NewJSONRPCTransportFactory(a2av0.JSONRPCTransportConfig{}), + ), + a2aclientv2.WithCompatTransport( + a2av0.Version, a2av2.TransportProtocolHTTPJSON, a2av0.NewRESTTransportFactory(a2av0.RESTTransportConfig{}), + ), + ) + return factory.CreateFromCard(ctx, card) + } + + legacyCard := a2av0.FromV1AgentCard(card) + client, err := cfg.ClientFactory.CreateFromCard(ctx, legacyCard) + if err != nil { + return nil, err + } + return &compatClient{client: client}, nil + }, } if cfg.AgentCard != nil { @@ -143,7 +164,7 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) { } else if cfg.AgentCardSource != "" { source := cfg.AgentCardSource resolveOpts := cfg.CardResolveOptions - v1Cfg.AgentCardProvider = func(ctx context.Context) (*v2a2a.AgentCard, error) { + v1Cfg.AgentCardProvider = func(ctx context.Context) (*a2av2.AgentCard, error) { v0Card, err := agentcard.DefaultResolver.Resolve(ctx, source, resolveOpts...) if err != nil { return nil, err @@ -160,19 +181,8 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) { v1Cfg.MessageSendConfig = req.Config } - if cfg.ClientFactory != nil { - v1Cfg.ClientProvider = func(ctx context.Context, card *v2a2a.AgentCard) (v2.A2AClient, error) { - legacyCard := a2av0.FromV1AgentCard(card) - client, err := cfg.ClientFactory.CreateFromCard(ctx, legacyCard) - if err != nil { - return nil, err - } - return &compatClient{client: client}, nil - } - } - if cfg.Converter != nil { - v1Cfg.Converter = func(ctx agent.InvocationContext, req *v2a2a.SendMessageRequest, event v2a2a.Event, err error) (*session.Event, error) { + v1Cfg.Converter = func(ctx agent.InvocationContext, req *a2av2.SendMessageRequest, event a2av2.Event, err error) (*session.Event, error) { legacyReq := a2av0.FromV1SendMessageRequest(req) var legacyEvent a2a.Event if event != nil { @@ -189,7 +199,7 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) { if cfg.BeforeRequestCallbacks != nil { v1Cfg.BeforeRequestCallbacks = make([]v2.BeforeA2ARequestCallback, 0, len(cfg.BeforeRequestCallbacks)) for _, cb := range cfg.BeforeRequestCallbacks { - v1Cfg.BeforeRequestCallbacks = append(v1Cfg.BeforeRequestCallbacks, func(ctx agent.CallbackContext, req *v2a2a.SendMessageRequest) (*session.Event, error) { + v1Cfg.BeforeRequestCallbacks = append(v1Cfg.BeforeRequestCallbacks, func(ctx agent.CallbackContext, req *a2av2.SendMessageRequest) (*session.Event, error) { legacyReq := a2av0.FromV1SendMessageRequest(req) resp, err := cb(ctx, legacyReq) if resp != nil || err != nil { // short-circuit, no need to convert the request back @@ -209,7 +219,7 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) { if cfg.AfterRequestCallbacks != nil { v1Cfg.AfterRequestCallbacks = make([]v2.AfterA2ARequestCallback, 0, len(cfg.AfterRequestCallbacks)) for _, cb := range cfg.AfterRequestCallbacks { - v1Cfg.AfterRequestCallbacks = append(v1Cfg.AfterRequestCallbacks, func(ctx agent.CallbackContext, req *v2a2a.SendMessageRequest, resp *session.Event, err error) (*session.Event, error) { + v1Cfg.AfterRequestCallbacks = append(v1Cfg.AfterRequestCallbacks, func(ctx agent.CallbackContext, req *a2av2.SendMessageRequest, resp *session.Event, err error) (*session.Event, error) { legacyReq := a2av0.FromV1SendMessageRequest(req) newResp, newErr := cb(ctx, legacyReq, resp, err) if newResp != nil || newErr != nil { // short-circuit, no need to convert the request back @@ -227,7 +237,7 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) { } if cfg.A2APartConverter != nil { - v1Cfg.A2APartConverter = func(ctx context.Context, a2aEvent v2a2a.Event, part *v2a2a.Part) (*genai.Part, error) { + v1Cfg.A2APartConverter = func(ctx context.Context, a2aEvent a2av2.Event, part *a2av2.Part) (*genai.Part, error) { legacyEvent, convErr := a2av0.FromV1Event(a2aEvent) if convErr != nil { return nil, convErr @@ -237,7 +247,7 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) { } if cfg.GenAIPartConverter != nil { - v1Cfg.GenAIPartConverter = func(ctx context.Context, adkEvent *session.Event, part *genai.Part) (*v2a2a.Part, error) { + v1Cfg.GenAIPartConverter = func(ctx context.Context, adkEvent *session.Event, part *genai.Part) (*a2av2.Part, error) { legacyPart, err := cfg.GenAIPartConverter(ctx, adkEvent, part) if err != nil { return nil, err @@ -247,7 +257,7 @@ func NewA2A(cfg A2AConfig) (agent.Agent, error) { } if cfg.RemoteTaskCleanupCallback != nil { - v1Cfg.RemoteTaskCleanupCallback = func(ctx context.Context, card *v2a2a.AgentCard, client v2.A2AClient, taskInfo v2a2a.TaskInfo, cause error) { + v1Cfg.RemoteTaskCleanupCallback = func(ctx context.Context, card *a2av2.AgentCard, client v2.A2AClient, taskInfo a2av2.TaskInfo, cause error) { legacyCard := a2av0.FromV1AgentCard(card) legacyTaskInfo := a2a.TaskInfo{TaskID: a2a.TaskID(taskInfo.TaskID), ContextID: taskInfo.ContextID} @@ -283,7 +293,7 @@ type compatClient struct { client *a2aclient.Client } -func (s *compatClient) SendMessage(ctx context.Context, req *v2a2a.SendMessageRequest) (v2a2a.SendMessageResult, error) { +func (s *compatClient) SendMessage(ctx context.Context, req *a2av2.SendMessageRequest) (a2av2.SendMessageResult, error) { legacyResp, err := s.client.SendMessage(ctx, a2av0.FromV1SendMessageRequest(req)) if err != nil { return nil, err @@ -292,15 +302,15 @@ func (s *compatClient) SendMessage(ctx context.Context, req *v2a2a.SendMessageRe if err != nil { return nil, err } - res, ok := v1Event.(v2a2a.SendMessageResult) + res, ok := v1Event.(a2av2.SendMessageResult) if !ok { return nil, fmt.Errorf("converted event does not implement SendMessageResult: %T", v1Event) } return res, nil } -func (s *compatClient) SendStreamingMessage(ctx context.Context, req *v2a2a.SendMessageRequest) iter.Seq2[v2a2a.Event, error] { - return func(yield func(v2a2a.Event, error) bool) { +func (s *compatClient) SendStreamingMessage(ctx context.Context, req *a2av2.SendMessageRequest) iter.Seq2[a2av2.Event, error] { + return func(yield func(a2av2.Event, error) bool) { for legacyEvent, err := range s.client.SendStreamingMessage(ctx, a2av0.FromV1SendMessageRequest(req)) { if err != nil { yield(nil, err) @@ -318,7 +328,7 @@ func (s *compatClient) SendStreamingMessage(ctx context.Context, req *v2a2a.Send } } -func (s *compatClient) CancelTask(ctx context.Context, req *v2a2a.CancelTaskRequest) (*v2a2a.Task, error) { +func (s *compatClient) CancelTask(ctx context.Context, req *a2av2.CancelTaskRequest) (*a2av2.Task, error) { legacyResp, err := s.client.CancelTask(ctx, a2av0.FromV1CancelTaskRequest(req)) if err != nil { return nil, err diff --git a/agent/remoteagent/a2a_agent_compat_test.go b/agent/remoteagent/a2a_agent_compat_test.go index 62879d7fb..08dabf7d0 100644 --- a/agent/remoteagent/a2a_agent_compat_test.go +++ b/agent/remoteagent/a2a_agent_compat_test.go @@ -24,12 +24,14 @@ import ( legacyA2A "github.com/a2aproject/a2a-go/a2a" legacyAClient "github.com/a2aproject/a2a-go/a2aclient" legacyASrv "github.com/a2aproject/a2a-go/a2asrv" + legacyEQ "github.com/a2aproject/a2a-go/a2asrv/eventqueue" v2a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" v2asrv "github.com/a2aproject/a2a-go/v2/a2asrv" "google.golang.org/genai" "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" @@ -524,6 +526,19 @@ func newLegacyCard(serverURL string) *legacyA2A.AgentCard { }) } +func newV0Card(serverURL string) *legacyA2A.AgentCard { + return a2av0.FromV1AgentCard(&v2a2a.AgentCard{ + SupportedInterfaces: []*v2a2a.AgentInterface{ + { + URL: serverURL, + ProtocolBinding: v2a2a.TransportProtocolJSONRPC, + ProtocolVersion: a2av0.Version, + }, + }, + Capabilities: v2a2a.AgentCapabilities{Streaming: true}, + }) +} + func newInvocationContext(t *testing.T, events []*session.Event) agent.InvocationContext { t.Helper() ctx := t.Context() @@ -566,3 +581,230 @@ func runAndCollect(ic agent.InvocationContext, agnt agent.Agent) ([]*session.Eve } return collected, nil } + +type mockLegacyExecutor struct { + executeFn func(ctx context.Context, reqCtx *legacyASrv.RequestContext, queue legacyEQ.Queue) error + cancelFn func(ctx context.Context, reqCtx *legacyASrv.RequestContext, queue legacyEQ.Queue) error + cleanupFn func(ctx context.Context, reqCtx *legacyASrv.RequestContext, result legacyA2A.SendMessageResult, err error) +} + +var ( + _ legacyASrv.AgentExecutor = (*mockLegacyExecutor)(nil) + _ legacyASrv.AgentExecutionCleaner = (*mockLegacyExecutor)(nil) + + transferToolName = "transfer_to_agent" + modelTextRootTransfer = "transferring... please hold... beepboop..." +) + +type testA2AServer struct { + *httptest.Server + handler legacyASrv.RequestHandler +} + +func (e *mockLegacyExecutor) Execute(ctx context.Context, reqCtx *legacyASrv.RequestContext, queue legacyEQ.Queue) error { + if e.executeFn != nil { + return e.executeFn(ctx, reqCtx, queue) + } + return nil +} + +func (e *mockLegacyExecutor) Cancel(ctx context.Context, reqCtx *legacyASrv.RequestContext, queue legacyEQ.Queue) error { + if e.cancelFn != nil { + return e.cancelFn(ctx, reqCtx, queue) + } + return queue.Write(ctx, legacyA2A.NewStatusUpdateEvent(reqCtx, legacyA2A.TaskStateCanceled, nil)) +} + +func (e *mockLegacyExecutor) Cleanup(ctx context.Context, reqCtx *legacyASrv.RequestContext, result legacyA2A.SendMessageResult, err error) { + if e.cleanupFn != nil { + e.cleanupFn(ctx, reqCtx, result, err) + } +} + +func startLegacyA2AServer(t *testing.T, executor legacyASrv.AgentExecutor) *testA2AServer { + t.Helper() + handler := legacyASrv.NewHandler(executor) + server := httptest.NewServer(legacyASrv.NewJSONRPCHandler(handler)) + return &testA2AServer{Server: server, handler: handler} +} + +func newLegacyA2AClient(t *testing.T, server *testA2AServer) *legacyAClient.Client { + t.Helper() + card := newV0Card(server.Server.URL) + client, err := legacyAClient.NewFromCard(t.Context(), card) + if err != nil { + t.Fatalf("legacyAClient.NewFromCard() error = %v", err) + } + return client +} + +func newA2ARemoteAgent(t *testing.T, name string, server *testA2AServer) agent.Agent { + t.Helper() + card := newV0Card(server.Server.URL) + + return utils.Must(NewA2A(A2AConfig{AgentCard: card, Name: name})) +} + +type llmStub struct { + name string + generateContent func(ctx context.Context, req *model.LLMRequest, stream bool) iter.Seq2[*model.LLMResponse, error] +} + +func (d *llmStub) Name() string { + return d.name +} + +func (d *llmStub) GenerateContent(ctx context.Context, req *model.LLMRequest, stream bool) iter.Seq2[*model.LLMResponse, error] { + return d.generateContent(ctx, req, stream) +} + +func newRootAgent(name string, subAgent agent.Agent) agent.Agent { + return utils.Must(llmagent.New(llmagent.Config{ + Name: name, + SubAgents: []agent.Agent{subAgent}, + Model: &llmStub{ + name: name + "-model", + generateContent: func(ctx context.Context, req *model.LLMRequest, stream bool) iter.Seq2[*model.LLMResponse, error] { + return func(yield func(*model.LLMResponse, error) bool) { + yield(&model.LLMResponse{ + Content: genai.NewContentFromParts([]*genai.Part{ + genai.NewPartFromText(modelTextRootTransfer), + genai.NewPartFromFunctionCall(transferToolName, map[string]any{"agent_name": subAgent.Name()}), + }, genai.RoleModel), + }, nil) + } + }, + }, + })) +} + +func TestCompat_A2ACleanupPropagation(t *testing.T) { + // Remote A2A server publishes a submitted task and start generating artifact updates + // until it detects a context cancelation + remoteTaskIDChan, remoteCleanupCalledChan := make(chan legacyA2A.TaskID, 1), make(chan struct{}, 2) + serverB := startLegacyA2AServer(t, &mockLegacyExecutor{ + cancelFn: func(ctx context.Context, reqCtx *legacyASrv.RequestContext, queue legacyEQ.Queue) error { + event := legacyA2A.NewStatusUpdateEvent(reqCtx, legacyA2A.TaskStateCanceled, nil) + event.Final = true + return queue.Write(ctx, event) + }, + executeFn: func(ctx context.Context, reqCtx *legacyASrv.RequestContext, queue legacyEQ.Queue) error { + remoteTaskIDChan <- reqCtx.TaskID + if err := queue.Write(ctx, legacyA2A.NewSubmittedTask(reqCtx, reqCtx.Message)); err != nil { + return err + } + for ctx.Err() == nil { + if err := queue.Write(ctx, legacyA2A.NewArtifactEvent(reqCtx, legacyA2A.TextPart{Text: "foo"})); err != nil { + return err + } + time.Sleep(1 * time.Millisecond) + } + return queue.Write(ctx, legacyA2A.NewStatusUpdateEvent(reqCtx, legacyA2A.TaskStateCompleted, nil)) + }, + cleanupFn: func(ctx context.Context, reqCtx *legacyASrv.RequestContext, result legacyA2A.SendMessageResult, cause error) { + remoteCleanupCalledChan <- struct{}{} + }, + }) + defer serverB.Close() + + // Root server connects to server B through remote subagent + remoteAgentB := newA2ARemoteAgent(t, "remote-agent-b", serverB) + rootA := newRootAgent("agent-b", remoteAgentB) + + // A2A Execution Cleanup callback + executorCleanupCalledChan := make(chan struct{}, 2) + executorA := adka2a.NewExecutor(adka2a.ExecutorConfig{ + OutputMode: adka2a.OutputArtifactPerEvent, + RunnerConfig: runner.Config{ + AppName: rootA.Name(), + SessionService: session.InMemoryService(), + Agent: rootA, + }, + A2AExecutionCleanupCallback: func(ctx context.Context, reqCtx *legacyASrv.RequestContext, subAgentCards []*legacyA2A.AgentCard, result legacyA2A.SendMessageResult, cause error) { + executorCleanupCalledChan <- struct{}{} + }, + RunConfig: agent.RunConfig{StreamingMode: agent.StreamingModeSSE}, + }) + + serverA := startLegacyA2AServer(t, executorA) + defer serverA.Close() + + client := newLegacyA2AClient(t, serverA) + + // Send a streaming message in a detached goroutine, passing status update through chan + statusUpdateEventChan := make(chan legacyA2A.Event, 10) + go func() { + defer close(statusUpdateEventChan) + msg := legacyA2A.NewMessage(legacyA2A.MessageRoleUser, legacyA2A.TextPart{Text: "work"}) + for event, err := range client.SendStreamingMessage(t.Context(), &legacyA2A.MessageSendParams{Message: msg}) { + if err != nil { + t.Errorf("client.SendStreamingMessage() error = %v", err) + return + } + if _, ok := event.(*legacyA2A.TaskArtifactUpdateEvent); ok { + continue + } + statusUpdateEventChan <- event + } + }() + + // Issue a task cancellation request + taskID := (<-statusUpdateEventChan).TaskInfo().TaskID + cancelResultChan := make(chan *legacyA2A.Task, 1) + go func() { + defer close(cancelResultChan) + task, err := client.CancelTask(t.Context(), &legacyA2A.TaskIDParams{ID: taskID}) + if err != nil { + t.Errorf("client.CancelTask() error = %v", err) + return + } + cancelResultChan <- task + }() + + // Check the streaming message sender got a cancelled state task in their response + var lastStreamingUpdate legacyA2A.Event + for event := range statusUpdateEventChan { + lastStreamingUpdate = event + } + if tu, ok := lastStreamingUpdate.(*legacyA2A.TaskStatusUpdateEvent); ok { + if tu.Status.State != legacyA2A.TaskStateCanceled { + t.Errorf("lastStreamingUpdate.Status.State = %q, want %q", tu.Status.State, legacyA2A.TaskStateCanceled) + } + } else { + t.Fatalf("type(lastStreamingUpdate) = %T, want *a2a.TaskStatusUpdateEvent", lastStreamingUpdate) + } + + // Check subagent task got cancelled when the parent task was cancelled. + // Reads from channel twice because cleanup gets called both for cancelation and execution. + timeout := time.After(5 * time.Second) + for range 2 { + select { + case <-remoteCleanupCalledChan: + case <-timeout: + t.Fatalf("remote cleanup was not called") + } + } + var remoteTaskID legacyA2A.TaskID + select { + case remoteTaskID = <-remoteTaskIDChan: + case <-time.After(1 * time.Second): + t.Fatal("server B was never reached; remoteTaskIDChan is empty") + } + + for range 2 { + select { + case <-executorCleanupCalledChan: + case <-timeout: + t.Fatalf("executor cleanup was not called") + } + } + + remoteClient := newLegacyA2AClient(t, serverB) + remoteTask, err := remoteClient.GetTask(t.Context(), &legacyA2A.TaskQueryParams{ID: remoteTaskID}) + if err != nil { + t.Fatalf("remoteClient.GetTask() error = %v", err) + } + if remoteTask.Status.State != legacyA2A.TaskStateCanceled { + t.Errorf("remoteTask.Status.State = %q, want %q", remoteTask.Status.State, legacyA2A.TaskStateCanceled) + } +} diff --git a/agent/remoteagent/v2/a2a_e2e_test.go b/agent/remoteagent/v2/a2a_e2e_test.go index 490301a23..7702b3bad 100644 --- a/agent/remoteagent/v2/a2a_e2e_test.go +++ b/agent/remoteagent/v2/a2a_e2e_test.go @@ -410,7 +410,18 @@ func TestA2ACleanupPropagation(t *testing.T) { // Root server connects to server B through remote subagent remoteAgentB := newA2ARemoteAgent(t, "remote-agent-b", serverB) rootA := newRootAgent("agent-b", remoteAgentB) - executorA := newAgentExecutor(rootA, nil, adka2a.OutputArtifactPerEvent) + executorCleanupCalledChan := make(chan struct{}, 2) + executorA := adka2a.NewExecutor(adka2a.ExecutorConfig{ + OutputMode: adka2a.OutputArtifactPerRun, + RunnerConfig: runner.Config{ + AppName: rootA.Name(), + SessionService: session.InMemoryService(), + Agent: rootA, + }, + A2AExecutionCleanupCallback: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext, subAgentCards []*a2a.AgentCard, result a2a.SendMessageResult, cause error) { + executorCleanupCalledChan <- struct{}{} + }, + }) serverA := startA2AServer(executorA) defer serverA.Close() @@ -461,9 +472,23 @@ func TestA2ACleanupPropagation(t *testing.T) { // Check subagent task got cancelled when the parent task was cancelled. // Reads from channel twice because cleanup gets called both for cancelation and execution. - <-remoteCleanupCalledChan - <-remoteCleanupCalledChan + timeout := time.After(5 * time.Second) + for range 2 { + select { + case <-remoteCleanupCalledChan: + case <-timeout: + t.Fatalf("remote cleanup was not called") + } + } remoteTaskID := <-remoteTaskIDChan + + for range 2 { + select { + case <-executorCleanupCalledChan: + case <-timeout: + t.Fatalf("executor cleanup was not called") + } + } remoteClient := newA2AClient(t, serverB) remoteTask, err := remoteClient.GetTask(t.Context(), &a2a.GetTaskRequest{ID: remoteTaskID}) if err != nil { diff --git a/server/adka2a/executor.go b/server/adka2a/executor.go index d39b2331f..990b7a2fb 100644 --- a/server/adka2a/executor.go +++ b/server/adka2a/executor.go @@ -130,7 +130,10 @@ type ExecutorConfig struct { A2AExecutionCleanupCallback A2AExecutionCleanupCallback } -var _ a2asrv.AgentExecutor = (*Executor)(nil) +var ( + _ a2asrv.AgentExecutor = (*Executor)(nil) + _ a2asrv.AgentExecutionCleaner = (*Executor)(nil) +) // Executor is the legacy AgentExecutor implementation which delegates to [v2.Executor]. type Executor struct { @@ -296,6 +299,28 @@ func (e *Executor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, qu return nil } +func (e *Executor) Cleanup(ctx context.Context, reqCtx *a2asrv.RequestContext, result a2a.SendMessageResult, cause error) { + execCtx, err := toExecutorContext(ctx, reqCtx) + if err != nil { + log.Warn(ctx, "failed to convert request context to executor context", "error", err) + return + } + + v2Event, err := a2av0.ToV1Event(result) + if err != nil { + log.Warn(ctx, "failed to convert result to v2 event", "error", err) + return + } + + v2Result, ok := v2Event.(a2av2.SendMessageResult) + if !ok { + log.Warn(ctx, "converted event is not SendMessageResult", "type", fmt.Sprintf("%T", v2Event)) + return + } + + e.impl.Cleanup(ctx, execCtx, v2Result, cause) +} + // ExecutorContext provides read-only information about the context of an A2A agent execution. // An execution starts with a user message and ends with a task in a terminal or input-required state. type ExecutorContext interface { From 6c2384a3fff33fe7944979e2dc139ad1d6d70c34 Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Thu, 28 May 2026 12:48:21 +0200 Subject: [PATCH 60/86] Context unification agent/context + internal/contenxt/callback_context (#868) * Create unified callbackContext * Fixed Actions * Introduced optional NewCallbackContextWithArtifactTracking --- agent/agent.go | 108 ++---------------- agent/callback_context.go | 157 +++++++++++++++++++++++++++ internal/context/callback_context.go | 109 ++----------------- 3 files changed, 179 insertions(+), 195 deletions(-) create mode 100644 agent/callback_context.go diff --git a/agent/agent.go b/agent/agent.go index 450389743..fecd476af 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -248,11 +248,8 @@ func runBeforeAgentCallbacks(ctx InvocationContext) (*session.Event, error) { agent := ctx.Agent() pluginManager := pluginManagerFromContext(ctx) - callbackCtx := &callbackContext{ - Context: ctx, - invocationContext: ctx, - actions: &session.EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)}, - } + actions := &session.EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)} + callbackCtx := NewCallbackContext(ctx, actions) if pluginManager != nil { content, err := pluginManager.RunBeforeAgentCallback(callbackCtx) @@ -266,7 +263,7 @@ func runBeforeAgentCallbacks(ctx InvocationContext) (*session.Event, error) { } event.Author = agent.Name() event.Branch = ctx.Branch() - event.Actions = *callbackCtx.actions + event.Actions = *actions ctx.EndInvocation() return event, nil } @@ -287,17 +284,17 @@ func runBeforeAgentCallbacks(ctx InvocationContext) (*session.Event, error) { } event.Author = agent.Name() event.Branch = ctx.Branch() - event.Actions = *callbackCtx.actions + event.Actions = *actions ctx.EndInvocation() return event, nil } // check if has delta create event with it - if len(callbackCtx.actions.StateDelta) > 0 { + if len(actions.StateDelta) > 0 { event := session.NewEvent(ctx.InvocationID()) event.Author = agent.Name() event.Branch = ctx.Branch() - event.Actions = *callbackCtx.actions + event.Actions = *actions return event, nil } @@ -310,11 +307,8 @@ func runAfterAgentCallbacks(ctx InvocationContext) (*session.Event, error) { agent := ctx.Agent() pluginManager := pluginManagerFromContext(ctx) - callbackCtx := &callbackContext{ - Context: ctx, - invocationContext: ctx, - actions: &session.EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)}, - } + actions := &session.EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)} + callbackCtx := NewCallbackContext(ctx, actions) if pluginManager != nil { content, err := pluginManager.RunAfterAgentCallback(callbackCtx) @@ -328,7 +322,7 @@ func runAfterAgentCallbacks(ctx InvocationContext) (*session.Event, error) { } event.Author = agent.Name() event.Branch = ctx.Branch() - event.Actions = *callbackCtx.actions + event.Actions = *actions return event, nil } } @@ -348,101 +342,23 @@ func runAfterAgentCallbacks(ctx InvocationContext) (*session.Event, error) { } event.Author = agent.Name() event.Branch = ctx.Branch() - event.Actions = *callbackCtx.actions + event.Actions = *actions // TODO set context invocation ended // ctx.invocationEnded = true return event, nil } // check if has delta create event with it - if len(callbackCtx.actions.StateDelta) > 0 { + if len(actions.StateDelta) > 0 { event := session.NewEvent(ctx.InvocationID()) event.Author = agent.Name() event.Branch = ctx.Branch() - event.Actions = *callbackCtx.actions + event.Actions = *actions return event, nil } return nil, nil } -// TODO: unify with internal/context.callbackContext - -type callbackContext struct { - context.Context - invocationContext InvocationContext - actions *session.EventActions -} - -func (c *callbackContext) AgentName() string { - return c.invocationContext.Agent().Name() -} - -func (c *callbackContext) ReadonlyState() session.ReadonlyState { - return c.invocationContext.Session().State() -} - -func (c *callbackContext) State() session.State { - return &callbackContextState{ctx: c} -} - -func (c *callbackContext) Artifacts() Artifacts { - return c.invocationContext.Artifacts() -} - -func (c *callbackContext) InvocationID() string { - return c.invocationContext.InvocationID() -} - -func (c *callbackContext) UserContent() *genai.Content { - return c.invocationContext.UserContent() -} - -// AppName implements CallbackContext. -func (c *callbackContext) AppName() string { - return c.invocationContext.Session().AppName() -} - -// Branch implements CallbackContext. -func (c *callbackContext) Branch() string { - return c.invocationContext.Branch() -} - -// SessionID implements CallbackContext. -func (c *callbackContext) SessionID() string { - return c.invocationContext.Session().ID() -} - -// UserID implements CallbackContext. -func (c *callbackContext) UserID() string { - return c.invocationContext.Session().UserID() -} - -var _ CallbackContext = (*callbackContext)(nil) - -type callbackContextState struct { - ctx *callbackContext -} - -func (c *callbackContextState) Get(key string) (any, error) { - if c.ctx.actions != nil && c.ctx.actions.StateDelta != nil { - if val, ok := c.ctx.actions.StateDelta[key]; ok { - return val, nil - } - } - return c.ctx.invocationContext.Session().State().Get(key) -} - -func (c *callbackContextState) Set(key string, val any) error { - if c.ctx.actions != nil && c.ctx.actions.StateDelta != nil { - c.ctx.actions.StateDelta[key] = val - } - return c.ctx.invocationContext.Session().State().Set(key, val) -} - -func (c *callbackContextState) All() iter.Seq2[string, any] { - return c.ctx.invocationContext.Session().State().All() -} - type invocationContext struct { context.Context diff --git a/agent/callback_context.go b/agent/callback_context.go new file mode 100644 index 000000000..dbece8bb5 --- /dev/null +++ b/agent/callback_context.go @@ -0,0 +1,157 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agent + +import ( + "context" + "iter" + + "google.golang.org/genai" + + "google.golang.org/adk/artifact" + "google.golang.org/adk/session" +) + +// NewCallbackContext returns CallbackContext initialized with provided actions. +// actions may be nil; if so, a new session.EventActions is created with empty StateDelta and ArtifactDelta +func NewCallbackContext(ic InvocationContext, actions *session.EventActions) CallbackContext { + if actions == nil { + actions = &session.EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)} + } + + cc := &callbackContext{ + Context: ic, + invocationContext: ic, + actions: actions, + artifacts: ic.Artifacts(), + } + return cc +} + +// NewCallbackContextWithArtifactTracking returns CallbackContext initialized with provided actions. +// the returned context's Artifacts().Save(...) wrapper records each saved artifact's version into the underlying +// EventActions.ArtifactDelta so the resulting Event reflects the saves. +// actions may be nil; if so, a new session.EventActions is created with empty StateDelta and ArtifactDelta +func NewCallbackContextWithArtifactTracking(ic InvocationContext, actions *session.EventActions) CallbackContext { + if actions == nil { + actions = &session.EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)} + } + + cc := &callbackContext{ + Context: ic, + invocationContext: ic, + actions: actions, + artifacts: &trackedArtifacts{Artifacts: ic.Artifacts(), actions: actions}, + } + return cc +} + +// callbackContext is the single concrete implementation of CallbackContext. +type callbackContext struct { + context.Context + invocationContext InvocationContext + artifacts Artifacts + actions *session.EventActions +} + +func (c *callbackContext) AgentName() string { + return c.invocationContext.Agent().Name() +} + +func (c *callbackContext) ReadonlyState() session.ReadonlyState { + return c.invocationContext.Session().State() +} + +func (c *callbackContext) State() session.State { + return &callbackContextState{ctx: c} +} + +func (c *callbackContext) Artifacts() Artifacts { + return c.artifacts +} + +func (c *callbackContext) InvocationID() string { + return c.invocationContext.InvocationID() +} + +func (c *callbackContext) UserContent() *genai.Content { + return c.invocationContext.UserContent() +} + +func (c *callbackContext) AppName() string { + return c.invocationContext.Session().AppName() +} + +func (c *callbackContext) Branch() string { + return c.invocationContext.Branch() +} + +func (c *callbackContext) SessionID() string { + return c.invocationContext.Session().ID() +} + +func (c *callbackContext) UserID() string { + return c.invocationContext.Session().UserID() +} + +var _ CallbackContext = (*callbackContext)(nil) + +// callbackContextState is a session.State implementation backed by the +// callback context's EventActions.StateDelta and the underlying session state. +type callbackContextState struct { + ctx *callbackContext +} + +func (c *callbackContextState) Get(key string) (any, error) { + if c.ctx.actions != nil && c.ctx.actions.StateDelta != nil { + if val, ok := c.ctx.actions.StateDelta[key]; ok { + return val, nil + } + } + return c.ctx.invocationContext.Session().State().Get(key) +} + +func (c *callbackContextState) Set(key string, val any) error { + if c.ctx.actions != nil && c.ctx.actions.StateDelta != nil { + c.ctx.actions.StateDelta[key] = val + } + return c.ctx.invocationContext.Session().State().Set(key, val) +} + +func (c *callbackContextState) All() iter.Seq2[string, any] { + return c.ctx.invocationContext.Session().State().All() +} + +// trackedArtifacts wraps an Artifacts to record each successful Save into the +// supplied EventActions.ArtifactDelta. +type trackedArtifacts struct { + Artifacts + actions *session.EventActions +} + +func (a *trackedArtifacts) Save(ctx context.Context, name string, data *genai.Part) (*artifact.SaveResponse, error) { + resp, err := a.Artifacts.Save(ctx, name, data) + if err != nil { + return resp, err + } + if a.actions != nil { + if a.actions.ArtifactDelta == nil { + a.actions.ArtifactDelta = make(map[string]int64) + } + // TODO: RWLock, check the version stored is newer in case multiple tools save the same file. + a.actions.ArtifactDelta[name] = resp.Version + } + return resp, nil +} diff --git a/internal/context/callback_context.go b/internal/context/callback_context.go index ca56ff0ae..251a507f3 100644 --- a/internal/context/callback_context.go +++ b/internal/context/callback_context.go @@ -15,111 +15,22 @@ package context import ( - "context" - "iter" - - "google.golang.org/genai" - "google.golang.org/adk/agent" - "google.golang.org/adk/artifact" "google.golang.org/adk/session" ) -type internalArtifacts struct { - agent.Artifacts - eventActions *session.EventActions -} - -func (ia *internalArtifacts) Save(ctx context.Context, name string, data *genai.Part) (*artifact.SaveResponse, error) { - resp, err := ia.Artifacts.Save(ctx, name, data) - if err != nil { - return resp, err - } - if ia.eventActions != nil { - if ia.eventActions.ArtifactDelta == nil { - ia.eventActions.ArtifactDelta = make(map[string]int64) - } - // TODO: RWLock, check the version stored is newer in case multiple tools save the same file. - ia.eventActions.ArtifactDelta[name] = resp.Version - } - return resp, nil -} - +// NewCallbackContext returns a CallbackContext suitable for model, tool, and +// related callbacks. The returned context's Artifacts().Save tracks each saved +// artifact's version into the underlying EventActions.ArtifactDelta. func NewCallbackContext(ctx agent.InvocationContext) agent.CallbackContext { - return newCallbackContext(ctx, make(map[string]any), make(map[string]int64)) + return agent.NewCallbackContextWithArtifactTracking(ctx, nil) } +// NewCallbackContextWithDelta returns a CallbackContext that uses the given +// stateDelta and artifactDelta maps as the initial backing storage for its +// EventActions. The returned context's Artifacts().Save tracks each saved +// artifact's version into artifactDelta. func NewCallbackContextWithDelta(ctx agent.InvocationContext, stateDelta map[string]any, artifactDelta map[string]int64) agent.CallbackContext { - return newCallbackContext(ctx, stateDelta, artifactDelta) -} - -func newCallbackContext(ctx agent.InvocationContext, stateDelta map[string]any, artifactDelta map[string]int64) *callbackContext { - rCtx := NewReadonlyContext(ctx) - eventActions := &session.EventActions{StateDelta: stateDelta, ArtifactDelta: artifactDelta} - return &callbackContext{ - ReadonlyContext: rCtx, - invocationCtx: ctx, - eventActions: eventActions, - artifacts: &internalArtifacts{ - Artifacts: ctx.Artifacts(), - eventActions: eventActions, - }, - } -} - -// TODO: unify with agent.callbackContext - -type callbackContext struct { - agent.ReadonlyContext - artifacts *internalArtifacts - invocationCtx agent.InvocationContext - eventActions *session.EventActions -} - -func (c *callbackContext) Artifacts() agent.Artifacts { - return c.artifacts -} - -func (c *callbackContext) AgentName() string { - return c.invocationCtx.Agent().Name() -} - -func (c *callbackContext) ReadonlyState() session.ReadonlyState { - return c.invocationCtx.Session().State() -} - -func (c *callbackContext) State() session.State { - return &callbackContextState{ctx: c} -} - -func (c *callbackContext) InvocationID() string { - return c.invocationCtx.InvocationID() -} - -func (c *callbackContext) UserContent() *genai.Content { - return c.invocationCtx.UserContent() -} - -type callbackContextState struct { - ctx *callbackContext -} - -func (c *callbackContextState) Get(key string) (any, error) { - if c.ctx.eventActions != nil && c.ctx.eventActions.StateDelta != nil { - if val, ok := c.ctx.eventActions.StateDelta[key]; ok { - return val, nil - } - } - return c.ctx.invocationCtx.Session().State().Get(key) -} - -func (c *callbackContextState) Set(key string, val any) error { - if c.ctx.eventActions != nil && c.ctx.eventActions.StateDelta != nil { - c.ctx.eventActions.StateDelta[key] = val - } - return c.ctx.invocationCtx.Session().State().Set(key, val) -} - -func (c *callbackContextState) All() iter.Seq2[string, any] { - return c.ctx.invocationCtx.Session().State().All() + actions := &session.EventActions{StateDelta: stateDelta, ArtifactDelta: artifactDelta} + return agent.NewCallbackContextWithArtifactTracking(ctx, actions) } From 163763b6281211da49ee7319b3674e4c0be9c977 Mon Sep 17 00:00:00 2001 From: Gustaf <32815781+gustafvh@users.noreply.github.com> Date: Thu, 28 May 2026 13:21:14 +0200 Subject: [PATCH 61/86] fix: preserve unrelated function events (#767) * fix: preserve unrelated function events Keep unrelated function-call and function-response events when rearranging the latest function response so valid tool history is not dropped. * test: Add extended tool confirmation tests --- internal/llminternal/contents_processor.go | 13 +- .../llminternal/contents_processor_test.go | 115 ++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/internal/llminternal/contents_processor.go b/internal/llminternal/contents_processor.go index d2c498676..46fb5a141 100644 --- a/internal/llminternal/contents_processor.go +++ b/internal/llminternal/contents_processor.go @@ -272,10 +272,18 @@ SearchLoop: // A label to allow breaking out of the nested loop ) } - // Collect all function response events *between* the call and the last response. + // Collect function response events related to the matching call while + // preserving unrelated tool events that happened in between. var responseEventsToMerge []*session.Event + resultEvents := events[:functionCallEventIdx+1] for i := functionCallEventIdx + 1; i < len(events)-1; i++ { event := events[i] + calls := utils.FunctionCalls(event.Content) + if len(calls) > 0 { + resultEvents = append(resultEvents, event) + continue + } + responses := utils.FunctionResponses(event.Content) if len(responses) == 0 { continue @@ -292,13 +300,14 @@ SearchLoop: // A label to allow breaking out of the nested loop if isRelated { responseEventsToMerge = append(responseEventsToMerge, event) + } else { + resultEvents = append(resultEvents, event) } } // Add the final response event itself to the list to be merged. responseEventsToMerge = append(responseEventsToMerge, events[len(events)-1]) - resultEvents := events[:functionCallEventIdx+1] mergedEvent, err := mergeFunctionResponseEvents(responseEventsToMerge) if err != nil { return nil, err diff --git a/internal/llminternal/contents_processor_test.go b/internal/llminternal/contents_processor_test.go index d827283c6..3fce0d8d7 100644 --- a/internal/llminternal/contents_processor_test.go +++ b/internal/llminternal/contents_processor_test.go @@ -31,6 +31,7 @@ import ( "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" "google.golang.org/adk/session" + "google.golang.org/adk/tool/toolconfirmation" ) type testModel struct { @@ -745,6 +746,70 @@ func TestContentsRequestProcessor_Rearrange(t *testing.T) { Response: map[string]any{"output": "preserved"}, } + // Human-in-the-loop confirmation call/responses + fcHITLApproved := &genai.FunctionCall{ + ID: "hitl_approved_call", + Name: "request_vacation_days", + Args: map[string]any{"days": 5, "user_id": "user-123"}, + } + frHITLApprovedPending := &genai.FunctionResponse{ + ID: "hitl_approved_call", + Name: "request_vacation_days", + Response: map[string]any{"status": "Manager approval is required.", "request_id": "req-1"}, + } + frHITLApprovedFinal := &genai.FunctionResponse{ + ID: "hitl_approved_call", + Name: "request_vacation_days", + Response: map[string]any{"status": "The time off request is accepted.", "days_approved": 5, "request_id": "req-1"}, + } + fcHITLApprovalRequest := &genai.FunctionCall{ + ID: "hitl_approved_confirmation", + Name: toolconfirmation.FunctionCallName, + Args: map[string]any{ + "originalFunctionCall": fcHITLApproved, + "toolConfirmation": toolconfirmation.ToolConfirmation{ + Confirmed: false, + Hint: "Please approve or reject the tool call request_vacation_days().", + }, + }, + } + frHITLApprovalConfirmed := &genai.FunctionResponse{ + ID: "hitl_approved_confirmation", + Name: toolconfirmation.FunctionCallName, + Response: map[string]any{"confirmed": true}, + } + fcHITLDenied := &genai.FunctionCall{ + ID: "hitl_denied_call", + Name: "request_vacation_days", + Args: map[string]any{"days": 10, "user_id": "user-123"}, + } + frHITLDeniedPending := &genai.FunctionResponse{ + ID: "hitl_denied_call", + Name: "request_vacation_days", + Response: map[string]any{"status": "Manager approval is required.", "request_id": "req-2"}, + } + frHITLDeniedFinal := &genai.FunctionResponse{ + ID: "hitl_denied_call", + Name: "request_vacation_days", + Response: map[string]any{"status": "The time off request is rejected.", "days_approved": 0, "request_id": "req-2"}, + } + fcHITLDenialRequest := &genai.FunctionCall{ + ID: "hitl_denied_confirmation", + Name: toolconfirmation.FunctionCallName, + Args: map[string]any{ + "originalFunctionCall": fcHITLDenied, + "toolConfirmation": toolconfirmation.ToolConfirmation{ + Confirmed: false, + Hint: "Please approve or reject the tool call request_vacation_days().", + }, + }, + } + frHITLDenialRejected := &genai.FunctionResponse{ + ID: "hitl_denied_confirmation", + Name: toolconfirmation.FunctionCallName, + Response: map[string]any{"confirmed": false}, + } + // Error Call/Response frOrphaned := &genai.FunctionResponse{ ID: "no_matching_call", @@ -804,6 +869,56 @@ func TestContentsRequestProcessor_Rearrange(t *testing.T) { NewContentFromFunctionResponse(frLROFinal, "user"), }, }, + { + name: "Rearrangement preserves unrelated function events", + events: []*session.Event{ + {Author: "user", LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("Run long process and search", "user")}}, + {Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionCall(fcLRO, "model")}}, + {Author: "user", LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frLROInter, "user")}}, + {Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionCall(fcBasic, "model")}}, + {Author: "user", LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frBasic, "user")}}, + {Author: "user", LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frLROFinal, "user")}}, + }, + want: []*genai.Content{ + genai.NewContentFromText("Run long process and search", "user"), + NewContentFromFunctionCall(fcLRO, "model"), + NewContentFromFunctionResponse(frLROFinal, "user"), + NewContentFromFunctionCall(fcBasic, "model"), + NewContentFromFunctionResponse(frBasic, "user"), + }, + }, + { + name: "HITL confirmation approved preserves resumed tool response", + events: []*session.Event{ + {Author: "user", LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("Request five vacation days", "user")}}, + {Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionCall(fcHITLApproved, "model")}}, + {Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frHITLApprovedPending, "user")}}, + {Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionCall(fcHITLApprovalRequest, "model")}}, + {Author: "user", LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frHITLApprovalConfirmed, "user")}}, + {Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frHITLApprovedFinal, "user")}}, + }, + want: []*genai.Content{ + genai.NewContentFromText("Request five vacation days", "user"), + NewContentFromFunctionCall(fcHITLApproved, "model"), + NewContentFromFunctionResponse(frHITLApprovedFinal, "user"), + }, + }, + { + name: "HITL confirmation denied preserves rejected tool response", + events: []*session.Event{ + {Author: "user", LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("Request ten vacation days", "user")}}, + {Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionCall(fcHITLDenied, "model")}}, + {Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frHITLDeniedPending, "user")}}, + {Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionCall(fcHITLDenialRequest, "model")}}, + {Author: "user", LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frHITLDenialRejected, "user")}}, + {Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frHITLDeniedFinal, "user")}}, + }, + want: []*genai.Content{ + genai.NewContentFromText("Request ten vacation days", "user"), + NewContentFromFunctionCall(fcHITLDenied, "model"), + NewContentFromFunctionResponse(frHITLDeniedFinal, "user"), + }, + }, { name: "Rearrangement with mixed LRO and normal calls", events: []*session.Event{ From d19c1670625913126689d21203d0673f5fd6aacb Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Thu, 28 May 2026 15:57:43 +0200 Subject: [PATCH 62/86] Context unification: merged tool context to callbackContext (#871) * Merged internal.toolinternal.toolContext into agent.CallbackContext * Renaming * Moved to agent * Updated model for examples/vertexai/imagegenerator * Renamed tool.ToolContext to tool.Context * Added explicit make(map[]) for actions passed to NewCallbackContext/NewCallbackContextWithArtifactTracking * Minor fixes * Switched from tool.NewToolContext to agent.NewToolContext --- agent/callback_context.go | 124 ++++++++++++++-- agent/context.go | 62 ++++++++ examples/vertexai/imagegenerator/main.go | 2 +- internal/llminternal/agent_transfer_test.go | 4 +- internal/llminternal/base_flow.go | 6 +- internal/llminternal/base_flow_test.go | 2 +- .../outputschema_processor_test.go | 7 +- internal/toolinternal/context.go | 136 ------------------ tool/agenttool/agent_tool_test.go | 2 +- tool/context.go | 30 ++++ .../toolinternal => tool}/context_test.go | 24 ++-- tool/functiontool/function_test.go | 3 +- .../load_artifacts_tool_test.go | 3 +- tool/loadmemorytool/tool_test.go | 3 +- tool/mcptoolset/set_test.go | 3 +- tool/preloadmemorytool/tool_test.go | 4 +- .../internal/skilltool/tools_test.go | 3 +- tool/tool.go | 61 +------- 18 files changed, 247 insertions(+), 232 deletions(-) delete mode 100644 internal/toolinternal/context.go create mode 100644 tool/context.go rename {internal/toolinternal => tool}/context_test.go (75%) diff --git a/agent/callback_context.go b/agent/callback_context.go index dbece8bb5..9ba05844e 100644 --- a/agent/callback_context.go +++ b/agent/callback_context.go @@ -16,21 +16,22 @@ package agent import ( "context" + "fmt" "iter" + "github.com/google/uuid" "google.golang.org/genai" "google.golang.org/adk/artifact" + "google.golang.org/adk/memory" "google.golang.org/adk/session" + "google.golang.org/adk/tool/toolconfirmation" ) // NewCallbackContext returns CallbackContext initialized with provided actions. // actions may be nil; if so, a new session.EventActions is created with empty StateDelta and ArtifactDelta func NewCallbackContext(ic InvocationContext, actions *session.EventActions) CallbackContext { - if actions == nil { - actions = &session.EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)} - } - + actions = prepareEventActions(actions) cc := &callbackContext{ Context: ic, invocationContext: ic, @@ -45,10 +46,7 @@ func NewCallbackContext(ic InvocationContext, actions *session.EventActions) Cal // EventActions.ArtifactDelta so the resulting Event reflects the saves. // actions may be nil; if so, a new session.EventActions is created with empty StateDelta and ArtifactDelta func NewCallbackContextWithArtifactTracking(ic InvocationContext, actions *session.EventActions) CallbackContext { - if actions == nil { - actions = &session.EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)} - } - + actions = prepareEventActions(actions) cc := &callbackContext{ Context: ic, invocationContext: ic, @@ -58,12 +56,57 @@ func NewCallbackContextWithArtifactTracking(ic InvocationContext, actions *sessi return cc } -// callbackContext is the single concrete implementation of CallbackContext. +// NewToolContext constructs a ToolContext for a tool execution. +// +// If functionCallID is empty a new UUID is generated. If actions is nil a +// fresh session.EventActions with empty StateDelta and ArtifactDelta is +// allocated; missing sub-maps are populated. The returned ToolContext is +// backed by the same *callbackContext implementation used for CallbackContext, +// so all callback-context semantics (state delta tracking, artifact delta +// tracking, etc.) apply, plus the tool-specific extensions on ToolContext. +func NewToolContext(ic InvocationContext, functionCallID string, actions *session.EventActions, confirmation *toolconfirmation.ToolConfirmation) ToolContext { + if functionCallID == "" { + functionCallID = uuid.NewString() + } + actions = prepareEventActions(actions) + return &callbackContext{ + Context: ic, + invocationContext: ic, + actions: actions, + artifacts: &trackedArtifacts{Artifacts: ic.Artifacts(), actions: actions}, + functionCallID: functionCallID, + toolConfirmation: confirmation, + } +} + +func prepareEventActions(actions *session.EventActions) *session.EventActions { + if actions == nil { + return &session.EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)} + } + // create missing maps if needed + if actions.StateDelta == nil { + actions.StateDelta = make(map[string]any) + } + if actions.ArtifactDelta == nil { + actions.ArtifactDelta = make(map[string]int64) + } + return actions +} + +// callbackContext is the single concrete implementation of CallbackContext +// (and, when constructed via NewToolContext, of ToolContext as well). The +// tool-specific methods (FunctionCallID, Actions, SearchMemory, +// ToolConfirmation, RequestConfirmation) are always present on the concrete +// type; they are only meaningful when the context is used as a ToolContext. type callbackContext struct { context.Context invocationContext InvocationContext artifacts Artifacts actions *session.EventActions + + // Fields below are only populated by NewToolContext. + functionCallID string + toolConfirmation *toolconfirmation.ToolConfirmation } func (c *callbackContext) AgentName() string { @@ -106,7 +149,68 @@ func (c *callbackContext) UserID() string { return c.invocationContext.Session().UserID() } -var _ CallbackContext = (*callbackContext)(nil) +var ( + _ CallbackContext = (*callbackContext)(nil) + _ ToolContext = (*callbackContext)(nil) +) + +// --- ToolContext extensions ---------------------------------------------- +// +// The methods below are always present on *callbackContext but only +// meaningful when the context was constructed via NewToolContext (i.e. +// when functionCallID is set). + +// FunctionCallID returns the function call identifier associated with the +// current tool execution, or "" if this context was not constructed for a +// tool call. +func (c *callbackContext) FunctionCallID() string { + return c.functionCallID +} + +// Actions returns the EventActions for the current event. Tools can mutate +// the returned value to influence the agent loop (e.g. state deltas, agent +// transfers). +func (c *callbackContext) Actions() *session.EventActions { + return c.actions +} + +// SearchMemory performs a semantic search on the agent's memory. +func (c *callbackContext) SearchMemory(ctx context.Context, query string) (*memory.SearchResponse, error) { + if c.invocationContext.Memory() == nil { + return nil, fmt.Errorf("memory service is not set") + } + return c.invocationContext.Memory().SearchMemory(ctx, query) +} + +// ToolConfirmation returns the Human-in-the-Loop confirmation handle for the +// current tool execution, or nil if no confirmation is currently associated +// with the call. +func (c *callbackContext) ToolConfirmation() *toolconfirmation.ToolConfirmation { + return c.toolConfirmation +} + +// RequestConfirmation initiates the Human-in-the-Loop (HITL) approval flow +// for the current tool call. It records a pending confirmation in the +// underlying EventActions and sets SkipSummarization so the agent loop halts +// until the user responds. +func (c *callbackContext) RequestConfirmation(hint string, payload any) error { + if c.functionCallID == "" { + return fmt.Errorf("error function call id not set when requesting confirmation for tool") + } + if c.actions.RequestedToolConfirmations == nil { + c.actions.RequestedToolConfirmations = make(map[string]toolconfirmation.ToolConfirmation) + } + c.actions.RequestedToolConfirmations[c.functionCallID] = toolconfirmation.ToolConfirmation{ + Hint: hint, + Confirmed: false, + Payload: payload, + } + // SkipSummarization stops the agent loop after this tool call. Without it, + // the function response event becomes lastEvent and IsFinalResponse() returns + // false (hasFunctionResponses == true), causing the loop to continue. + c.actions.SkipSummarization = true + return nil +} // callbackContextState is a session.State implementation backed by the // callback context's EventActions.StateDelta and the underlying session state. diff --git a/agent/context.go b/agent/context.go index aa4e613bc..1fd8b640a 100644 --- a/agent/context.go +++ b/agent/context.go @@ -19,7 +19,9 @@ import ( "google.golang.org/genai" + "google.golang.org/adk/memory" "google.golang.org/adk/session" + "google.golang.org/adk/tool/toolconfirmation" ) /* @@ -126,3 +128,63 @@ type CallbackContext interface { Artifacts() Artifacts State() session.State } + +// ToolContext is the context passed to a tool when it is called. It extends +// CallbackContext with tool-specific facilities: access to the originating +// function call, mutable event actions, long-term memory search, and the +// Human-in-the-Loop (HITL) confirmation flow. +type ToolContext interface { + CallbackContext + + // FunctionCallID returns the unique identifier of the function call + // that triggered this tool execution. + FunctionCallID() string + + // Actions returns the EventActions for the current event. This can be + // used by the tool to modify the agent's state, transfer to another + // agent, or perform other actions. + Actions() *session.EventActions + + // SearchMemory performs a semantic search on the agent's memory. + SearchMemory(ctx context.Context, query string) (*memory.SearchResponse, error) + + // ToolConfirmation returns a handler for checking the Human-in-the-Loop + // confirmation status for the current tool context. This should be used + // within a tool's logic *before* performing any sensitive operations that + // require user approval. + // + // Example Usage: + // if confirmation := ctx.ToolConfirmation(); confirmation == nil { + // // Confirmation required, create confirmation or handle appropriately + // ctx.RequestConfirmation("hint", payload) + // } + // + // The returned *toolconfirmation.ToolConfirmation object provides methods + // to check the actual confirmation state. + ToolConfirmation() *toolconfirmation.ToolConfirmation + + // RequestConfirmation initiates the Human-in-the-Loop (HITL) process to + // ask the user for approval before the tool proceeds with a specific + // action. Call this method when a tool needs explicit user consent. + // + // This will typically result in the ADK emitting a special event + // (e.g., a FunctionCall like "adk_request_confirmation") to the client + // application/UI, prompting the user for a decision. + // + // Args: + // - hint: A human-readable string explaining why confirmation is needed. + // This is usually displayed to the user in the confirmation prompt. + // - payload: Any additional data or context about the action requiring + // confirmation. + // + // Returns: + // - nil: If the confirmation request was successfully enqueued or + // initiated within the ADK. This indicates that the process of asking + // the user has begun. It does NOT mean the action is approved. The + // tool's execution will likely pause or be suspended until the user + // responds. + // - error: If there was a failure in initiating the confirmation process + // itself (e.g., invalid arguments, issue with the event system). The + // request to ask the user has not been sent. + RequestConfirmation(hint string, payload any) error +} diff --git a/examples/vertexai/imagegenerator/main.go b/examples/vertexai/imagegenerator/main.go index 15efc7eb7..13d45b20c 100644 --- a/examples/vertexai/imagegenerator/main.go +++ b/examples/vertexai/imagegenerator/main.go @@ -39,7 +39,7 @@ import ( func main() { ctx := context.Background() - model, err := gemini.NewModel(ctx, "gemini-2.0-flash-001", nil) + model, err := gemini.NewModel(ctx, "gemini-3.1-flash-image-preview", nil) if err != nil { log.Fatalf("Failed to create model: %v", err) } diff --git a/internal/llminternal/agent_transfer_test.go b/internal/llminternal/agent_transfer_test.go index b60f7d023..f8b473c4b 100644 --- a/internal/llminternal/agent_transfer_test.go +++ b/internal/llminternal/agent_transfer_test.go @@ -471,7 +471,7 @@ func TestTransferToAgentToolRun(t *testing.T) { curTool := &llminternal.TransferToAgentTool{} invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) - ctx := toolinternal.NewToolContext(invCtx, "", &session.EventActions{}, nil) + ctx := agent.NewToolContext(invCtx, "", &session.EventActions{}, nil) wantAgentName := "TestAgent" args := map[string]any{"agent_name": wantAgentName} @@ -497,7 +497,7 @@ func TestTransferToAgentToolRun(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { curTool := &llminternal.TransferToAgentTool{} - ctx := toolinternal.NewToolContext(icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}), "", nil, nil) + ctx := agent.NewToolContext(icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}), "", nil, nil) if got, err := curTool.Run(ctx, tc.args); err == nil { t.Fatalf("Run(%v) = (%v, %v), want error", tc.args, got, err) } diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index 95faf8967..a0ef15fce 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -689,7 +689,7 @@ func toolPreprocess(ctx agent.InvocationContext, req *model.LLMRequest, tools [] return fmt.Errorf("tool %q does not implement RequestProcessor() method", t.Name()) } // TODO: how to prevent mutation on this? - toolCtx := toolinternal.NewToolContext(ctx, "", &session.EventActions{}, nil) + toolCtx := agent.NewToolContext(ctx, "", &session.EventActions{}, nil) if err := requestProcessor.ProcessRequest(toolCtx, req); err != nil { return err } @@ -707,7 +707,7 @@ func toolsetPreprocess(ctx agent.InvocationContext, req *model.LLMRequest) error if !ok { continue // Not all toolsets implement RequestProcessor. } - toolCtx := toolinternal.NewToolContext(ctx, "", nil, nil) + toolCtx := agent.NewToolContext(ctx, "", nil, nil) if err := processor.ProcessRequest(toolCtx, req); err != nil { return fmt.Errorf("process request by toolset %q: %w", toolset.Name(), err) } @@ -1041,7 +1041,7 @@ func (f *Flow) handleFunctionCalls(ctx agent.InvocationContext, toolsDict map[st if toolConfirmations != nil { confirmation = toolConfirmations[fnCall.ID] } - toolCtx := toolinternal.NewToolContext(toolCallCtx, fnCall.ID, &session.EventActions{StateDelta: make(map[string]any)}, confirmation) + toolCtx := agent.NewToolContext(toolCallCtx, fnCall.ID, &session.EventActions{StateDelta: make(map[string]any)}, confirmation) var result map[string]any var curTool tool.Tool diff --git a/internal/llminternal/base_flow_test.go b/internal/llminternal/base_flow_test.go index 3b211df9c..27f06a6a9 100644 --- a/internal/llminternal/base_flow_test.go +++ b/internal/llminternal/base_flow_test.go @@ -421,7 +421,7 @@ func TestCallTool(t *testing.T) { OnToolErrorCallbacks: tc.onToolErrorCallbacks, } ctx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) - got := f.callTool(toolinternal.NewToolContext(ctx, "", nil, nil), tc.tool, tc.args) + got := f.callTool(agent.NewToolContext(ctx, "", nil, nil), tc.tool, tc.args) if diff := cmp.Diff(tc.want, got); diff != "" { t.Errorf("callTool() mismatch (-want +got):\n%s", diff) } diff --git a/internal/llminternal/outputschema_processor_test.go b/internal/llminternal/outputschema_processor_test.go index 6d0d8c29c..6970fe784 100644 --- a/internal/llminternal/outputschema_processor_test.go +++ b/internal/llminternal/outputschema_processor_test.go @@ -25,7 +25,6 @@ import ( "google.golang.org/adk/agent" icontext "google.golang.org/adk/internal/context" - "google.golang.org/adk/internal/toolinternal" "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" "google.golang.org/adk/session" @@ -315,7 +314,7 @@ func TestSetModelResponseTool(t *testing.T) { t.Run("RunSuccess", func(t *testing.T) { invCtx := icontext.NewInvocationContext(context.Background(), icontext.InvocationContextParams{}) - toolCtx := toolinternal.NewToolContext(invCtx, "", nil, nil) + toolCtx := agent.NewToolContext(invCtx, "", nil, nil) input := map[string]any{"count": 10.0} // JSON numbers often come as float64 got, err := toolInstance.Run(toolCtx, input) @@ -329,7 +328,7 @@ func TestSetModelResponseTool(t *testing.T) { t.Run("RunValidationFailure_Type", func(t *testing.T) { invCtx := icontext.NewInvocationContext(context.Background(), icontext.InvocationContextParams{}) - toolCtx := toolinternal.NewToolContext(invCtx, "", nil, nil) + toolCtx := agent.NewToolContext(invCtx, "", nil, nil) input := map[string]any{"count": "not a number"} _, err := toolInstance.Run(toolCtx, input) @@ -340,7 +339,7 @@ func TestSetModelResponseTool(t *testing.T) { t.Run("RunValidationFailure_MissingRequired", func(t *testing.T) { invCtx := icontext.NewInvocationContext(context.Background(), icontext.InvocationContextParams{}) - toolCtx := toolinternal.NewToolContext(invCtx, "", nil, nil) + toolCtx := agent.NewToolContext(invCtx, "", nil, nil) input := map[string]any{"other": 123} _, err := toolInstance.Run(toolCtx, input) diff --git a/internal/toolinternal/context.go b/internal/toolinternal/context.go deleted file mode 100644 index 541b061d0..000000000 --- a/internal/toolinternal/context.go +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package toolinternal - -import ( - "context" - "fmt" - - "github.com/google/uuid" - "google.golang.org/genai" - - "google.golang.org/adk/agent" - "google.golang.org/adk/artifact" - contextinternal "google.golang.org/adk/internal/context" - "google.golang.org/adk/memory" - "google.golang.org/adk/session" - "google.golang.org/adk/tool" - "google.golang.org/adk/tool/toolconfirmation" -) - -type internalArtifacts struct { - agent.Artifacts - eventActions *session.EventActions -} - -func (ia *internalArtifacts) Save(ctx context.Context, name string, data *genai.Part) (*artifact.SaveResponse, error) { - resp, err := ia.Artifacts.Save(ctx, name, data) - if err != nil { - return resp, err - } - if ia.eventActions != nil { - if ia.eventActions.ArtifactDelta == nil { - ia.eventActions.ArtifactDelta = make(map[string]int64) - } - // TODO: RWLock, check the version stored is newer in case multiple tools save the same file. - ia.eventActions.ArtifactDelta[name] = resp.Version - } - return resp, nil -} - -func NewToolContext(ctx agent.InvocationContext, functionCallID string, actions *session.EventActions, confirmation *toolconfirmation.ToolConfirmation) tool.Context { - if functionCallID == "" { - functionCallID = uuid.NewString() - } - if actions == nil { - actions = &session.EventActions{StateDelta: make(map[string]any)} - } - if actions.StateDelta == nil { - actions.StateDelta = make(map[string]any) - } - if actions.ArtifactDelta == nil { - actions.ArtifactDelta = make(map[string]int64) - } - cbCtx := contextinternal.NewCallbackContextWithDelta(ctx, actions.StateDelta, actions.ArtifactDelta) - - return &toolContext{ - CallbackContext: cbCtx, - invocationContext: ctx, - functionCallID: functionCallID, - eventActions: actions, - artifacts: &internalArtifacts{ - Artifacts: ctx.Artifacts(), - eventActions: actions, - }, - toolConfirmation: confirmation, - } -} - -type toolContext struct { - agent.CallbackContext - invocationContext agent.InvocationContext - functionCallID string - eventActions *session.EventActions - artifacts *internalArtifacts - toolConfirmation *toolconfirmation.ToolConfirmation -} - -func (c *toolContext) Artifacts() agent.Artifacts { - return c.artifacts -} - -func (c *toolContext) FunctionCallID() string { - return c.functionCallID -} - -func (c *toolContext) Actions() *session.EventActions { - return c.eventActions -} - -func (c *toolContext) AgentName() string { - return c.invocationContext.Agent().Name() -} - -func (c *toolContext) SearchMemory(ctx context.Context, query string) (*memory.SearchResponse, error) { - if c.invocationContext.Memory() == nil { - return nil, fmt.Errorf("memory service is not set") - } - return c.invocationContext.Memory().SearchMemory(ctx, query) -} - -func (c *toolContext) ToolConfirmation() *toolconfirmation.ToolConfirmation { - return c.toolConfirmation -} - -func (c *toolContext) RequestConfirmation(hint string, payload any) error { - if c.functionCallID == "" { - return fmt.Errorf("error function call id not set when requesting confirmation for tool") - } - if c.eventActions.RequestedToolConfirmations == nil { - c.eventActions.RequestedToolConfirmations = make(map[string]toolconfirmation.ToolConfirmation) - } - c.eventActions.RequestedToolConfirmations[c.functionCallID] = toolconfirmation.ToolConfirmation{ - Hint: hint, - Confirmed: false, - Payload: payload, - } - // SkipSummarization stops the agent loop after this tool call. Without it, - // the function response event becomes lastEvent and IsFinalResponse() returns - // false (hasFunctionResponses == true), causing the loop to continue. - // This matches the behavior of the built-in RequireConfirmation path in - // functiontool (function.go). - c.eventActions.SkipSummarization = true - return nil -} diff --git a/tool/agenttool/agent_tool_test.go b/tool/agenttool/agent_tool_test.go index cf1dc6cfd..a17ffa061 100644 --- a/tool/agenttool/agent_tool_test.go +++ b/tool/agenttool/agent_tool_test.go @@ -364,5 +364,5 @@ func createToolContext(t *testing.T, testAgent agent.Agent) tool.Context { Session: createResponse.Session, }) - return toolinternal.NewToolContext(ctx, "", &session.EventActions{}, nil) + return agent.NewToolContext(ctx, "", &session.EventActions{}, nil) } diff --git a/tool/context.go b/tool/context.go new file mode 100644 index 000000000..6ca0f9bd8 --- /dev/null +++ b/tool/context.go @@ -0,0 +1,30 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tool + +import ( + "google.golang.org/adk/agent" + "google.golang.org/adk/session" + "google.golang.org/adk/tool/toolconfirmation" +) + +// NewToolContext constructs a ToolContext for a tool execution. +// +// Deprecated: use agent.NewToolContext directly. This wrapper exists only +// to minimize churn during the migration and will be removed in a future +// release. +func NewToolContext(ic agent.InvocationContext, functionCallID string, actions *session.EventActions, confirmation *toolconfirmation.ToolConfirmation) Context { + return agent.NewToolContext(ic, functionCallID, actions, confirmation) +} diff --git a/internal/toolinternal/context_test.go b/tool/context_test.go similarity index 75% rename from internal/toolinternal/context_test.go rename to tool/context_test.go index a846e78e3..2596941ed 100644 --- a/internal/toolinternal/context_test.go +++ b/tool/context_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 Google LLC +// Copyright 2026 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,19 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -package toolinternal +package tool_test import ( "testing" "google.golang.org/adk/agent" - contextinternal "google.golang.org/adk/internal/context" + icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/session" ) -func TestToolContext(t *testing.T) { - inv := contextinternal.NewInvocationContext(t.Context(), contextinternal.InvocationContextParams{}) - toolCtx := NewToolContext(inv, "fn1", &session.EventActions{}, nil) +func TestNewToolContext_Interfaces(t *testing.T) { + inv := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) + toolCtx := agent.NewToolContext(inv, "fn1", &session.EventActions{}, nil) if _, ok := toolCtx.(agent.ReadonlyContext); !ok { t.Errorf("ToolContext(%+T) is unexpectedly not a ReadonlyContext", toolCtx) @@ -34,10 +34,10 @@ func TestToolContext(t *testing.T) { } } -func TestRequestConfirmation_SetsSkipSummarization(t *testing.T) { - inv := contextinternal.NewInvocationContext(t.Context(), contextinternal.InvocationContextParams{}) +func TestNewToolContext_RequestConfirmation_SetsSkipSummarization(t *testing.T) { + inv := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) actions := &session.EventActions{} - toolCtx := NewToolContext(inv, "fn1", actions, nil) + toolCtx := agent.NewToolContext(inv, "fn1", actions, nil) err := toolCtx.RequestConfirmation("please confirm", map[string]any{"key": "value"}) if err != nil { @@ -63,11 +63,11 @@ func TestRequestConfirmation_SetsSkipSummarization(t *testing.T) { } } -func TestRequestConfirmation_AutoGeneratesIDWhenEmpty(t *testing.T) { - inv := contextinternal.NewInvocationContext(t.Context(), contextinternal.InvocationContextParams{}) +func TestNewToolContext_RequestConfirmation_AutoGeneratesIDWhenEmpty(t *testing.T) { + inv := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) actions := &session.EventActions{} // NewToolContext auto-generates a UUID when functionCallID is empty. - toolCtx := NewToolContext(inv, "", actions, nil) + toolCtx := agent.NewToolContext(inv, "", actions, nil) err := toolCtx.RequestConfirmation("hint", nil) if err != nil { diff --git a/tool/functiontool/function_test.go b/tool/functiontool/function_test.go index 7a8517979..1c9371df5 100644 --- a/tool/functiontool/function_test.go +++ b/tool/functiontool/function_test.go @@ -28,6 +28,7 @@ import ( "github.com/google/jsonschema-go/jsonschema" "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/internal/testutil" @@ -66,7 +67,7 @@ func ExampleNew() { func createToolContext(t *testing.T) tool.Context { invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) - return toolinternal.NewToolContext(invCtx, "", &session.EventActions{}, nil) + return agent.NewToolContext(invCtx, "", &session.EventActions{}, nil) } //go:generate go test -v -httprecord=.* diff --git a/tool/loadartifactstool/load_artifacts_tool_test.go b/tool/loadartifactstool/load_artifacts_tool_test.go index 93366776a..7b23e1d8e 100644 --- a/tool/loadartifactstool/load_artifacts_tool_test.go +++ b/tool/loadartifactstool/load_artifacts_tool_test.go @@ -21,6 +21,7 @@ import ( "github.com/google/go-cmp/cmp" "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/artifact" artifactinternal "google.golang.org/adk/internal/artifact" icontext "google.golang.org/adk/internal/context" @@ -291,5 +292,5 @@ func createToolContext(t *testing.T) tool.Context { Artifacts: artifacts, }) - return toolinternal.NewToolContext(ctx, "", nil, nil) + return agent.NewToolContext(ctx, "", nil, nil) } diff --git a/tool/loadmemorytool/tool_test.go b/tool/loadmemorytool/tool_test.go index bee0082bb..fdc979274 100644 --- a/tool/loadmemorytool/tool_test.go +++ b/tool/loadmemorytool/tool_test.go @@ -22,6 +22,7 @@ import ( "google.golang.org/genai" + "google.golang.org/adk/agent" icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/internal/toolinternal" "google.golang.org/adk/memory" @@ -177,5 +178,5 @@ func createToolContext(t *testing.T, mem *mockMemory) tool.Context { Memory: mem, }) - return toolinternal.NewToolContext(ctx, "", nil, nil) + return agent.NewToolContext(ctx, "", nil, nil) } diff --git a/tool/mcptoolset/set_test.go b/tool/mcptoolset/set_test.go index bcd9e38b5..abe30593b 100644 --- a/tool/mcptoolset/set_test.go +++ b/tool/mcptoolset/set_test.go @@ -30,6 +30,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/internal/httprr" @@ -307,7 +308,7 @@ func TestCallToolReconnection(t *testing.T) { invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) ctx := icontext.NewReadonlyContext(invCtx) - toolCtx := toolinternal.NewToolContext(invCtx, "", nil, nil) + toolCtx := agent.NewToolContext(invCtx, "", nil, nil) // Get tools first to establish a session. tools, err := ts.Tools(ctx) diff --git a/tool/preloadmemorytool/tool_test.go b/tool/preloadmemorytool/tool_test.go index 20b77d98a..d68901b8e 100644 --- a/tool/preloadmemorytool/tool_test.go +++ b/tool/preloadmemorytool/tool_test.go @@ -23,8 +23,8 @@ import ( "google.golang.org/genai" + "google.golang.org/adk/agent" icontext "google.golang.org/adk/internal/context" - "google.golang.org/adk/internal/toolinternal" "google.golang.org/adk/memory" "google.golang.org/adk/model" "google.golang.org/adk/session" @@ -215,5 +215,5 @@ func createToolContext(t *testing.T, mem *mockMemory, userContent *genai.Content UserContent: userContent, }) - return toolinternal.NewToolContext(ctx, "", nil, nil) + return agent.NewToolContext(ctx, "", nil, nil) } diff --git a/tool/skilltoolset/internal/skilltool/tools_test.go b/tool/skilltoolset/internal/skilltool/tools_test.go index d181e55f3..50ff32812 100644 --- a/tool/skilltoolset/internal/skilltool/tools_test.go +++ b/tool/skilltoolset/internal/skilltool/tools_test.go @@ -22,6 +22,7 @@ import ( "github.com/google/go-cmp/cmp" + "google.golang.org/adk/agent" icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/internal/toolinternal" "google.golang.org/adk/tool" @@ -76,7 +77,7 @@ func (m *mockSource) LoadResource(ctx context.Context, name, resourcePath string func createToolContext(t *testing.T) tool.Context { invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) - return toolinternal.NewToolContext(invCtx, "", nil, nil) + return agent.NewToolContext(invCtx, "", nil, nil) } func TestListSkills(t *testing.T) { diff --git a/tool/tool.go b/tool/tool.go index 19a72ac8e..a73e1ef2d 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -18,7 +18,6 @@ package tool import ( - "context" "errors" "fmt" @@ -26,10 +25,7 @@ import ( "google.golang.org/adk/agent" "google.golang.org/adk/internal/toolinternal/toolutils" - "google.golang.org/adk/memory" "google.golang.org/adk/model" - "google.golang.org/adk/session" - "google.golang.org/adk/tool/toolconfirmation" ) // ErrConfirmationRequired indicates that the tool requires confirmation. @@ -49,57 +45,12 @@ type Tool interface { IsLongRunning() bool } -// Context defines the interface for the context passed to a tool when it's -// called. It provides access to invocation-specific information and allows -// the tool to interact with the agent's state and memory. -type Context interface { - agent.CallbackContext - // FunctionCallID returns the unique identifier of the function call - // that triggered this tool execution. - FunctionCallID() string - - // Actions returns the EventActions for the current event. This can be - // used by the tool to modify the agent's state, transfer to another - // agent, or perform other actions. - Actions() *session.EventActions - // SearchMemory performs a semantic search on the agent's memory. - SearchMemory(context.Context, string) (*memory.SearchResponse, error) - - // ToolConfirmation returns a handler for checking the Human-in-the-Loop - // confirmation status for the current tool context. This should be used within a tool's logic - // *before* performing any sensitive operations that require user approval. - // - // Example Usage: - // if confirmation := ctx.ToolConfirmation(); confirmation == nil { - // // Confirmation required, create confirmation or handle appropriately - // ctx.RequestConfirmation("hint", payload) - // } - // - // The returned *toolconfirmation.ToolConfirmation object provides methods to check the actual - // confirmation state. - ToolConfirmation() *toolconfirmation.ToolConfirmation - - // RequestConfirmation initiates the Human-in-the-Loop (HITL) process to ask the user for approval - // before the tool proceeds with a specific action. Call this method when a tool needs - // explicit user consent. - // - // This will typically result in the ADK emitting a special event - // (e.g., a FunctionCall like "adk_request_confirmation") to the client application/UI, - // prompting the user for a decision. - // - // Args: - // - hint: A human-readable string explaining why confirmation is needed. This is usually - // displayed to the user in the confirmation prompt. - // - payload: Any additional data or context about the action requiring confirmation. - // - // Returns: - // - nil: If the confirmation request was successfully enqueued or initiated within the ADK. - // This indicates that the process of asking the user has begun. It does NOT mean the action - // is approved. The tool's execution will likely pause or be suspended until the user responds. - // - error: If there was a failure in initiating the confirmation process itself (e.g., invalid - // arguments, issue with the event system). The request to ask the user has not been sent. - RequestConfirmation(hint string, payload any) error -} +// Context is an alias for agent.ToolContext. +// +// Deprecated: use agent.Context directly. This alias exists only to +// minimize churn during the migration and will be removed in a future +// release. +type Context = agent.ToolContext // Toolset is an interface for a collection of tools. It allows grouping // related tools together and providing them to an agent. From 6516f76c75f3ba652e2e96b3cf62b824dd35e283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Fri, 29 May 2026 10:55:35 +0100 Subject: [PATCH 63/86] feat: Add conformance test recording plugin (#890) * Add conformance test recording plugin. * lint fix * lint fix * lint fix --- cmd/internal/adkcli/main.go | 2 + .../configurable/conformance/functions.go | 21 +- .../recordplugin/invocation_record_state.go | 114 ++++ .../conformance/recordplugin/record_plugin.go | 625 ++++++++++++++++++ .../recordplugin/record_plugin_test.go | 522 +++++++++++++++ .../recordplugin/yaml_clean_test.go | 413 ++++++++++++ .../replayplugin/recording/recording.go | 16 +- .../conformance/replayplugin/replay_plugin.go | 3 +- .../conformance/replayplugin/yaml_utils.go | 74 ++- .../replayplugin/yaml_utils_test.go | 173 +++++ 10 files changed, 1910 insertions(+), 53 deletions(-) create mode 100644 internal/configurable/conformance/recordplugin/invocation_record_state.go create mode 100644 internal/configurable/conformance/recordplugin/record_plugin.go create mode 100644 internal/configurable/conformance/recordplugin/record_plugin_test.go create mode 100644 internal/configurable/conformance/recordplugin/yaml_clean_test.go create mode 100644 internal/configurable/conformance/replayplugin/yaml_utils_test.go diff --git a/cmd/internal/adkcli/main.go b/cmd/internal/adkcli/main.go index 990643dc3..7714042b1 100644 --- a/cmd/internal/adkcli/main.go +++ b/cmd/internal/adkcli/main.go @@ -27,6 +27,7 @@ import ( "google.golang.org/adk/cmd/launcher/full" "google.golang.org/adk/internal/configurable" "google.golang.org/adk/internal/configurable/conformance" + "google.golang.org/adk/internal/configurable/conformance/recordplugin" "google.golang.org/adk/internal/configurable/conformance/replayplugin" "google.golang.org/adk/plugin" "google.golang.org/adk/runner" @@ -113,6 +114,7 @@ func main() { PluginConfig: runner.PluginConfig{ Plugins: []*plugin.Plugin{ replayplugin.MustNew(cwd), + recordplugin.MustNew(cwd), }, }, } diff --git a/internal/configurable/conformance/functions.go b/internal/configurable/conformance/functions.go index a89eceff6..1b4c663f4 100644 --- a/internal/configurable/conformance/functions.go +++ b/internal/configurable/conformance/functions.go @@ -58,15 +58,17 @@ func getUserID(ctx tool.Context, args ValidateEmailArgs) (int, error) { return int(result % 10000), nil } -func createBooking(ctx tool.Context, args ValidateEmailArgs) (map[string]any, error) { - userID, err := getUserID(ctx, args) - if err != nil { - return nil, err - } +type CreateBookingArgs struct { + UserID int `json:"user_id"` + IsConfirmed bool `json:"is_confirmed"` + Details string `json:"details"` +} + +func createBooking(ctx tool.Context, args CreateBookingArgs) (map[string]any, error) { return map[string]any{ - "user_id": userID, - "is_confirmed": true, - "details": "Booking created for user " + args.Email, + "user_id": args.UserID, + "is_confirmed": args.IsConfirmed, + "details": args.Details, "user_id_type": "int", "is_confirmed_type": "bool", "details_type": "string", @@ -243,8 +245,7 @@ Args: Returns: A dictionary containing the booking information and the types of the - received arguments. -`, + received arguments.`, }, createBooking) if err != nil { return fmt.Errorf("error creating create booking tool: %w", err) diff --git a/internal/configurable/conformance/recordplugin/invocation_record_state.go b/internal/configurable/conformance/recordplugin/invocation_record_state.go new file mode 100644 index 000000000..ed776e624 --- /dev/null +++ b/internal/configurable/conformance/recordplugin/invocation_record_state.go @@ -0,0 +1,114 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package recordplugin + +import ( + "sync" + + "google.golang.org/genai" + + "google.golang.org/adk/internal/configurable/conformance/replayplugin/recording" + "google.golang.org/adk/model" +) + +type invocationRecordState struct { + mu sync.Mutex + caseDir string + userMessageIndex int + streamingMode string + + // Completed recordings for the current invocation turn + recordings []recording.Recording + + // Active/pending LLM request mappings + pendingLLMRequest *model.LLMRequest + pendingLLMResponses []*model.LLMResponse + + // Active/pending Tool call mappings keyed by function_call_id + pendingToolCalls map[string]*genai.FunctionCall +} + +func newInvocationRecordState(caseDir string, msgIndex int, streamingMode string) *invocationRecordState { + return &invocationRecordState{ + caseDir: caseDir, + userMessageIndex: msgIndex, + streamingMode: streamingMode, + pendingToolCalls: make(map[string]*genai.FunctionCall), + } +} + +func (s *invocationRecordState) StartLLMRecording(req *model.LLMRequest) { + s.mu.Lock() + defer s.mu.Unlock() + + s.pendingLLMRequest = req + s.pendingLLMResponses = nil +} + +func (s *invocationRecordState) AppendLLMResponse(resp *model.LLMResponse) { + s.mu.Lock() + defer s.mu.Unlock() + + s.pendingLLMResponses = append(s.pendingLLMResponses, resp) +} + +func (s *invocationRecordState) CompleteLLMRecording(agentName string) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.pendingLLMRequest == nil { + return + } + + s.recordings = append(s.recordings, recording.Recording{ + UserMessageIndex: s.userMessageIndex, + AgentName: agentName, + LLMRecording: &recording.LLMRecording{ + LLMRequest: s.pendingLLMRequest, + LLMResponses: s.pendingLLMResponses, + }, + }) + + s.pendingLLMRequest = nil + s.pendingLLMResponses = nil +} + +func (s *invocationRecordState) StartToolRecording(id string, fc *genai.FunctionCall) { + s.mu.Lock() + defer s.mu.Unlock() + + s.pendingToolCalls[id] = fc +} + +func (s *invocationRecordState) CompleteToolRecording(id, agentName string, resp *genai.FunctionResponse) { + s.mu.Lock() + defer s.mu.Unlock() + + fc, ok := s.pendingToolCalls[id] + if !ok { + return + } + + s.recordings = append(s.recordings, recording.Recording{ + UserMessageIndex: s.userMessageIndex, + AgentName: agentName, + ToolRecording: &recording.ToolRecording{ + ToolCall: fc, + ToolResponse: resp, + }, + }) + + delete(s.pendingToolCalls, id) +} diff --git a/internal/configurable/conformance/recordplugin/record_plugin.go b/internal/configurable/conformance/recordplugin/record_plugin.go new file mode 100644 index 000000000..054ebd054 --- /dev/null +++ b/internal/configurable/conformance/recordplugin/record_plugin.go @@ -0,0 +1,625 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package recordplugin + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "google.golang.org/genai" + "gopkg.in/yaml.v3" + + "google.golang.org/adk/agent" + "google.golang.org/adk/internal/configurable/conformance/replayplugin/recording" + "google.golang.org/adk/model" + "google.golang.org/adk/plugin" + "google.golang.org/adk/session" + "google.golang.org/adk/tool" +) + +type recordPlugin struct { + mu sync.Mutex + invocationStates map[string]*invocationRecordState + allowedBaseDir string +} + +// New creates a new conformance record plugin. +func New(allowedBaseDir string) (*plugin.Plugin, error) { + p := &recordPlugin{ + invocationStates: make(map[string]*invocationRecordState), + allowedBaseDir: allowedBaseDir, + } + return plugin.New(plugin.Config{ + Name: "record_plugin", + BeforeRunCallback: p.beforeRun, + AfterRunCallback: p.afterRun, + BeforeModelCallback: p.beforeModel, + AfterModelCallback: p.afterModel, + BeforeToolCallback: p.beforeTool, + AfterToolCallback: p.afterTool, + }) +} + +// MustNew is like New but panics on error. +func MustNew(allowedBaseDir string) *plugin.Plugin { + p, err := New(allowedBaseDir) + if err != nil { + panic(err) + } + return p +} + +func (p *recordPlugin) beforeRun(ctx agent.InvocationContext) (*genai.Content, error) { + if ctx.Session() == nil { + return nil, nil + } + + on, err := p.isRecordModeOn(ctx.Session().State()) + if err != nil { + return nil, err + } + if !on { + return nil, nil + } + + // Create fresh record state for this invocation + _, err = p.createInvocationState(ctx) + return nil, err +} + +func (p *recordPlugin) beforeModel(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) { + on, err := p.isRecordModeOn(ctx.State()) + if err != nil || !on { + return nil, nil + } + + state, err := p.getInvocationState(ctx.InvocationID()) + if err != nil { + return nil, err + } + + // Sanitize and clone the request to prevent YAML serialization panics caused by HTTPOptions.ExtrasRequestProvider + sanitizedReq := sanitizeLLMRequest(req) + + state.StartLLMRecording(sanitizedReq) + return nil, nil +} + +func (p *recordPlugin) afterModel(ctx agent.CallbackContext, resp *model.LLMResponse, err error) (*model.LLMResponse, error) { + on, recordErr := p.isRecordModeOn(ctx.State()) + if recordErr != nil || !on { + return nil, nil + } + + if err != nil || resp == nil { + return nil, nil + } + + state, stateErr := p.getInvocationState(ctx.InvocationID()) + if stateErr != nil { + return nil, stateErr + } + + state.AppendLLMResponse(resp) + + // If it is a final non-partial response, complete the recording + if !resp.Partial { + state.CompleteLLMRecording(ctx.AgentName()) + } + + return nil, nil +} + +func (p *recordPlugin) beforeTool(ctx tool.Context, t tool.Tool, args map[string]any) (map[string]any, error) { + on, err := p.isRecordModeOn(ctx.State()) + if err != nil || !on { + return nil, nil + } + + state, err := p.getInvocationState(ctx.InvocationID()) + if err != nil { + return nil, err + } + + fc := &genai.FunctionCall{ + ID: ctx.FunctionCallID(), + Name: t.Name(), + Args: args, + } + + state.StartToolRecording(ctx.FunctionCallID(), fc) + return nil, nil +} + +func (p *recordPlugin) afterTool(ctx tool.Context, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + on, recordErr := p.isRecordModeOn(ctx.State()) + if recordErr != nil || !on { + return nil, nil + } + + state, stateErr := p.getInvocationState(ctx.InvocationID()) + if stateErr != nil { + return nil, stateErr + } + + resp := &genai.FunctionResponse{ + ID: ctx.FunctionCallID(), + Name: t.Name(), + Response: result, + } + + state.CompleteToolRecording(ctx.FunctionCallID(), ctx.AgentName(), resp) + return nil, nil +} + +func (p *recordPlugin) afterRun(ctx agent.InvocationContext) { + if ctx.Session() == nil { + return + } + + on, err := p.isRecordModeOn(ctx.Session().State()) + if err != nil || !on { + return + } + + state, err := p.getInvocationState(ctx.InvocationID()) + if err != nil { + return + } + + defer func() { + p.mu.Lock() + delete(p.invocationStates, ctx.InvocationID()) + p.mu.Unlock() + }() + + // Serialize and save + p.saveRecordings(state) +} + +func (p *recordPlugin) parseRecordConfig(sessionState session.State) (string, int, string, error) { + if sessionState == nil { + return "", 0, "", nil + } + + configVal, err := sessionState.Get("_adk_recordings_config") + if err != nil { + return "", 0, "", nil + } + + config, ok := configVal.(map[string]any) + if !ok { + return "", 0, "", nil + } + + caseDir, ok := config["dir"].(string) + if !ok || caseDir == "" { + return "", 0, "", nil + } + + basePath, err := filepath.Abs(p.allowedBaseDir) + if err != nil { + return "", 0, "", fmt.Errorf("invalid path format: %v", err) + } + requestedAbsPath, err := filepath.Abs(caseDir) + if err != nil { + return "", 0, "", fmt.Errorf("invalid path format: %v", err) + } + rel, err := filepath.Rel(basePath, requestedAbsPath) + if err != nil { + return "", 0, "", fmt.Errorf("invalid path format: %v", err) + } + if strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return "", 0, "", fmt.Errorf("record config error: 'dir' is not within the allowed base directory") + } + + msgIndexVal, ok := config["user_message_index"] + if !ok || msgIndexVal == nil { + return "", 0, "", nil + } + + var msgIndex int + switch v := msgIndexVal.(type) { + case int: + msgIndex = v + case float64: + msgIndex = int(v) + default: + return "", 0, "", fmt.Errorf("record config 'user_message_index' is not a number") + } + + streamingMode, _ := config["streaming_mode"].(string) + if streamingMode == "" { + streamingMode = "none" + } + + return caseDir, msgIndex, streamingMode, nil +} + +func (p *recordPlugin) isRecordModeOn(sessionState session.State) (bool, error) { + caseDir, _, _, err := p.parseRecordConfig(sessionState) + if err != nil { + return false, err + } + return caseDir != "", nil +} + +func (p *recordPlugin) getInvocationState(id string) (*invocationRecordState, error) { + p.mu.Lock() + defer p.mu.Unlock() + + state, ok := p.invocationStates[id] + if !ok { + return nil, fmt.Errorf("record state not initialized for invocation: %s", id) + } + return state, nil +} + +func (p *recordPlugin) createInvocationState(ctx agent.InvocationContext) (*invocationRecordState, error) { + caseDir, msgIndex, streamingMode, err := p.parseRecordConfig(ctx.Session().State()) + if err != nil { + return nil, err + } + if caseDir == "" { + return nil, fmt.Errorf("record state not configured") + } + + state := newInvocationRecordState(caseDir, msgIndex, streamingMode) + + p.mu.Lock() + p.invocationStates[ctx.InvocationID()] = state + p.mu.Unlock() + + return state, nil +} + +func (p *recordPlugin) saveRecordings(state *invocationRecordState) { + var filename string + if state.streamingMode == "sse" { + filename = "generated-recordings-sse.yaml" + } else { + filename = "generated-recordings.yaml" + } + + filePath := filepath.Join(state.caseDir, filename) + + // Load existing recordings if the file exists + var existingRecordings recording.Recordings + if _, err := os.Stat(filePath); err == nil { + data, err := os.ReadFile(filePath) + if err == nil { + _ = yaml.Unmarshal(data, &existingRecordings) + } + } + + // Filter out any existing recordings for the CURRENT user_message_index + var filtered []recording.Recording + for _, r := range existingRecordings.Recordings { + if r.UserMessageIndex != state.userMessageIndex { + filtered = append(filtered, r) + } + } + + // Append newly completed recordings in this turn + state.mu.Lock() + filtered = append(filtered, state.recordings...) + state.mu.Unlock() + + outputRecordings := recording.Recordings{ + Recordings: filtered, + } + + // Write YAML back to disk using AST traversal to prune nulls/defaults + var node yaml.Node + if err := node.Encode(&outputRecordings); err != nil { + return + } + + cleanYAMLNode(&node, false) + + out, err := yaml.Marshal(&node) + if err != nil { + return + } + + _ = os.WriteFile(filePath, out, 0o644) +} + +func shouldPruneYAMLField(key string, value *yaml.Node, inToolArgs bool) bool { + if key == "usermessageindex" || key == "user_message_index" { + return false + } + if !inToolArgs { + if key == "property_order" || key == "response_json_schema" { + return true + } + } + if value.Kind == yaml.ScalarNode { + if value.Tag == "!!null" || value.Value == "null" || value.Value == "~" { + return true + } + if !inToolArgs { + if (value.Tag == "!!int" || value.Tag == "") && value.Value == "0" { + return true + } + if (value.Tag == "!!bool" || value.Tag == "") && value.Value == "false" { + return true + } + if value.Value == "" { + return true + } + } + } + if value.Kind == yaml.SequenceNode && len(value.Content) == 0 { + return true + } + if value.Kind == yaml.MappingNode && len(value.Content) == 0 { + if inToolArgs { + return false + } + switch key { + case "google_search", "code_execution", "retrieval": + return false + default: + return true + } + } + return false +} + +var snakeCaseFieldMap = make(map[string]string) + +func init() { + snakeCaseKeys := []string{ + // Top-level and Recording structs + "user_message_index", "agent_name", "llm_recording", "llm_request", "llm_responses", + "tool_recording", "tool_call", "tool_response", + + // LLMResponse fields + "citation_metadata", "grounding_metadata", "usage_metadata", "custom_metadata", + "logprobs_result", "input_transcription", "output_transcription", "model_version", + "session_resumption_handle", "error_code", "error_message", "finish_reason", "avg_logprobs", + + // GenerateContentConfig fields + "http_options", "system_instruction", "candidate_count", "max_output_tokens", + "stop_sequences", "response_logprobs", "presence_penalty", "frequency_penalty", + "response_mime_type", "response_schema", "response_json_schema", "routing_config", + "model_selection_config", "safety_settings", "tool_config", "cached_content", + "response_modalities", "media_resolution", "speech_config", "audio_timestamp", + "thinking_config", "image_config", "enable_enhanced_civic_answers", "model_armor_config", + "service_tier", + + // Tool fields + "google_search", "blocking_confidence", "function_declarations", "parameters_json_schema", + "additional_properties", "property_order", + + // Part / Content fields + "part_metadata", "video_metadata", "code_execution_result", "executable_code", + "file_data", "function_call", "function_call_id", "function_response", "inline_data", + "thought_signature", + + // GroundingMetadata fields + "grounding_chunks", "grounding_supports", "retrieval_metadata", "search_entry_point", + "web_search_queries", + + // GroundingChunk / Support / Segment / SearchEntryPoint fields + "grounding_chunk_indices", "confidence_score", "start_index", "end_index", + "rendered_content", "sdk_active_queries", + + // Token / usage details + "google_maps_widget_context_token", "candidates_token_count", "prompt_token_count", + "prompt_tokens_details", "token_count", "thoughts_token_count", "tool_use_prompt_token_count", + "tool_use_prompt_tokens_details", "total_token_count", "traffic_type", + } + + for _, key := range snakeCaseKeys { + cleaned := strings.ReplaceAll(key, "_", "") + snakeCaseFieldMap[cleaned] = key + } + + // Manual edge cases + snakeCaseFieldMap["propertyordering"] = "property_order" +} + +// findKeyNode searches a MappingNode's children to locate a value node associated +// with the specified key string. Returns nil if the node is not a mapping or if the key is not found. +func findKeyNode(node *yaml.Node, key string) *yaml.Node { + if node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +// cleanYAMLNode is the a post-processor for the recorded YAML representation. +// It performs a deep recursive AST traversal to: +// +// Normalize Go-serialized lowercase keys back to their strict canonical snake_case. +// Auto-extract and simplify standard system_instruction structures to single scalar strings. +// Coalesce raw integer-sequence thought_signatures back to compact Base64 strings. +// Prune all default empty fields, empty sequences, and null properties outside user tool arguments. +func cleanYAMLNode(node *yaml.Node, inToolArgs bool) { + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + cleanYAMLNode(child, inToolArgs) + } + case yaml.MappingNode: + // If this mapping represents an OpenAPI function declaration, recursively inject parameter schema titles. + // This ensures the generated schemas exactly match Pydantic reference standards. + var toolName string + if nameNode := findKeyNode(node, "name"); nameNode != nil && nameNode.Kind == yaml.ScalarNode { + toolName = nameNode.Value + } + var paramSchemaNode *yaml.Node + for _, key := range []string{"parameters", "parameters_json_schema", "parametersjsonschema"} { + if p := findKeyNode(node, key); p != nil && p.Kind == yaml.MappingNode { + paramSchemaNode = p + break + } + } + if toolName != "" && paramSchemaNode != nil { + injectSchemaTitles(paramSchemaNode, toolName) + } + + var newContent []*yaml.Node + for i := 0; i < len(node.Content); i += 2 { + k := node.Content[i] + v := node.Content[i+1] + + // Translate lowercase serialized struct fields back to canonical snake_case + if mappedKey, exists := snakeCaseFieldMap[k.Value]; exists { + k.Value = mappedKey + } + + // Set insideToolArgs context flag so we avoid pruning user data and parameter payloads + nextInToolArgs := inToolArgs + if k.Value == "args" || k.Value == "response" || k.Value == "tool_call" || k.Value == "tool_response" { + nextInToolArgs = true + } + + // Extract structured system instructions (Content with Parts) into simple string scalars + if k.Value == "system_instruction" && v.Kind == yaml.MappingNode { + if parts := findKeyNode(v, "parts"); parts != nil && parts.Kind == yaml.SequenceNode && len(parts.Content) > 0 { + part := parts.Content[0] + if text := findKeyNode(part, "text"); text != nil && text.Kind == yaml.ScalarNode { + v.Kind = yaml.ScalarNode + v.Tag = "!!str" + v.Style = text.Style + v.Value = text.Value + v.Content = nil + } + } + } + + // Coalesce raw integer sequences representing byte signatures back to standard Base64 string representation + if k.Value == "thought_signature" && v.Kind == yaml.SequenceNode && len(v.Content) > 0 { + var bytes []byte + for _, child := range v.Content { + if child.Kind == yaml.ScalarNode && (child.Tag == "!!int" || child.Tag == "") { + var b int + _, err := fmt.Sscan(child.Value, &b) + if err == nil { + bytes = append(bytes, byte(b)) + } + } + } + v.Kind = yaml.ScalarNode + v.Style = yaml.DoubleQuotedStyle + v.Tag = "!!str" + v.Value = base64.StdEncoding.EncodeToString(bytes) + v.Content = nil + } + + cleanYAMLNode(v, nextInToolArgs) + + // Drop empty, null, or redundant fields outside tool parameters + if shouldPruneYAMLField(k.Value, v, nextInToolArgs) { + continue + } + + newContent = append(newContent, k, v) + } + node.Content = newContent + } +} + +// injectSchemaTitles recursively verifies and inserts correct parameter schema titles +// for OpenAPI declarations, such as mapping "validate_email" to "validate_emailParams" +// and camelCasing individual property titles (e.g. "email_address" -> "Email Address"). +func injectSchemaTitles(schemaNode *yaml.Node, toolName string) { + if schemaNode.Kind != yaml.MappingNode { + return + } + + // Check or inject schema top-level title + titleNode := findKeyNode(schemaNode, "title") + if titleNode != nil { + if titleNode.Value == "" || titleNode.Tag == "!!null" || titleNode.Value == "null" { + titleNode.Kind = yaml.ScalarNode + titleNode.Tag = "!!str" + titleNode.Style = yaml.DoubleQuotedStyle + titleNode.Value = toolName + "Params" + titleNode.Content = nil + } + } else { + titleKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "title"} + titleValue := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: toolName + "Params", Style: yaml.DoubleQuotedStyle} + schemaNode.Content = append([]*yaml.Node{titleKey, titleValue}, schemaNode.Content...) + } + + // Recursively check and inject titles for individual properties + if propertiesNode := findKeyNode(schemaNode, "properties"); propertiesNode != nil && propertiesNode.Kind == yaml.MappingNode { + for i := 0; i < len(propertiesNode.Content); i += 2 { + propNameNode := propertiesNode.Content[i] + propDefNode := propertiesNode.Content[i+1] + if propDefNode.Kind == yaml.MappingNode { + propTitle := findKeyNode(propDefNode, "title") + if propTitle != nil { + if propTitle.Value == "" || propTitle.Tag == "!!null" || propTitle.Value == "null" { + propTitle.Kind = yaml.ScalarNode + propTitle.Tag = "!!str" + propTitle.Style = yaml.DoubleQuotedStyle + propTitle.Value = toCamelCaseTitle(propNameNode.Value) + propTitle.Content = nil + } + } else { + titleKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "title"} + titleValue := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: toCamelCaseTitle(propNameNode.Value), Style: yaml.DoubleQuotedStyle} + propDefNode.Content = append([]*yaml.Node{titleKey, titleValue}, propDefNode.Content...) + } + } + } + } +} + +// toCamelCaseTitle translates a snake_case property key (e.g. "phone_number") +// into a human-readable Title Case string (e.g. "Phone Number") for schema metadata. +func toCamelCaseTitle(s string) string { + parts := strings.Split(s, "_") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + return strings.Join(parts, " ") +} + +// sanitizeLLMRequest creates a shallow copy of the LLMRequest and strips HTTPOptions +// and internal Tool registries to prevent YAML serialization panics and leaked internal state. +func sanitizeLLMRequest(req *model.LLMRequest) *model.LLMRequest { + if req == nil { + return nil + } + + reqCopy := *req + reqCopy.Tools = nil + + if req.Config != nil { + configCopy := *req.Config + configCopy.HTTPOptions = nil + reqCopy.Config = &configCopy + } + + return &reqCopy +} diff --git a/internal/configurable/conformance/recordplugin/record_plugin_test.go b/internal/configurable/conformance/recordplugin/record_plugin_test.go new file mode 100644 index 000000000..5afda16f9 --- /dev/null +++ b/internal/configurable/conformance/recordplugin/record_plugin_test.go @@ -0,0 +1,522 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package recordplugin_test + +import ( + "context" + "iter" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "google.golang.org/genai" + "gopkg.in/yaml.v3" + + "google.golang.org/adk/agent" + "google.golang.org/adk/internal/configurable/conformance/recordplugin" + "google.golang.org/adk/internal/configurable/conformance/replayplugin/recording" + "google.golang.org/adk/memory" + "google.golang.org/adk/model" + "google.golang.org/adk/plugin" + "google.golang.org/adk/session" + "google.golang.org/adk/tool/toolconfirmation" +) + +func TestRecordPlugin(t *testing.T) { + setup := func(t *testing.T, baseDir string) (*plugin.Plugin, *MockSession, *MockState) { + p := recordplugin.MustNew(baseDir) + sessionState := make(map[string]any) + mockState := &MockState{data: sessionState} + mockSession := &MockSession{state: mockState} + return p, mockSession, mockState + } + + t.Run("RecordLlmAndToolCallsAndSaveYaml", func(t *testing.T) { + tempDir := t.TempDir() + p, mockSession, _ := setup(t, tempDir) + + err := mockSession.State().Set("_adk_recordings_config", map[string]any{ + "dir": tempDir, + "user_message_index": 0, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + invContext := &MockInvocationContext{ + session: mockSession, + invocationID: "test-invocation", + } + + // 1. beforeRun + _, err = p.BeforeRunCallback()(invContext) + if err != nil { + t.Fatalf("beforeRun failed: %v", err) + } + + // 2. beforeModel (LLM request) + cbContext := &MockCallbackContext{ + state: mockSession.State(), + invocationID: "test-invocation", + agentName: "test_agent", + } + req := &model.LLMRequest{ + Model: "gemini-2.0-flash", + Contents: []*genai.Content{ + { + Role: "user", + Parts: []*genai.Part{{Text: "Hello agent"}}, + }, + }, + Config: &genai.GenerateContentConfig{ + HTTPOptions: &genai.HTTPOptions{ + ExtrasRequestProvider: func(body map[string]any) map[string]any { + return body + }, + }, + }, + Tools: map[string]any{ + "test_tool": "dummy_val", + }, + } + _, err = p.BeforeModelCallback()(cbContext, req) + if err != nil { + t.Fatalf("beforeModel failed: %v", err) + } + + // 3. afterModel (LLM response) + resp := &model.LLMResponse{ + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{{Text: "Hello user, calling tool now"}}, + }, + ModelVersion: "gemini-2.0-flash", + Partial: false, + } + _, err = p.AfterModelCallback()(cbContext, resp, nil) + if err != nil { + t.Fatalf("afterModel failed: %v", err) + } + + // 4. beforeTool + toolContext := &MockToolContext{ + state: mockSession.State(), + invocationID: "test-invocation", + agentName: "test_agent", + functionCallID: "call-123", + } + mockTool := &MockTool{NameVal: "test_tool"} + toolArgs := map[string]any{"param": "test"} + _, err = p.BeforeToolCallback()(toolContext, mockTool, toolArgs) + if err != nil { + t.Fatalf("beforeTool failed: %v", err) + } + + // 5. afterTool + toolResult := map[string]any{"result": "test_success"} + _, err = p.AfterToolCallback()(toolContext, mockTool, toolArgs, toolResult, nil) + if err != nil { + t.Fatalf("afterTool failed: %v", err) + } + + // 6. afterRun + p.AfterRunCallback()(invContext) + + // Verify created YAML content + filePath := filepath.Join(tempDir, "generated-recordings.yaml") + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("failed to read recordings file: %v", err) + } + + var recordings recording.Recordings + err = yaml.Unmarshal(data, &recordings) + if err != nil { + t.Fatalf("failed to unmarshal yaml recordings: %v", err) + } + + // Assert that raw YAML string does not contain nulls, empty collections, or default-zero/false properties + yamlStr := string(data) + if strings.Contains(yamlStr, "null") { + t.Errorf("YAML contains unexpected 'null' fields:\n%s", yamlStr) + } + if strings.Contains(yamlStr, "candidatecount") { + t.Errorf("YAML contains unexpected 'candidatecount' field:\n%s", yamlStr) + } + if strings.Contains(yamlStr, "thoughtsignature") { + t.Errorf("YAML contains unexpected 'thoughtsignature' field:\n%s", yamlStr) + } + if strings.Contains(yamlStr, "thought: false") { + t.Errorf("YAML contains unexpected 'thought: false' field:\n%s", yamlStr) + } + if strings.Contains(yamlStr, "tools:") { + t.Errorf("YAML contains unexpected 'tools:' field:\n%s", yamlStr) + } + + if len(recordings.Recordings) != 2 { + t.Fatalf("expected exactly 2 recordings, got %d", len(recordings.Recordings)) + } + + // First recording: LLM + r1 := recordings.Recordings[0] + if r1.UserMessageIndex != 0 { + t.Errorf("expected index 0, got %d", r1.UserMessageIndex) + } + if r1.AgentName != "test_agent" { + t.Errorf("expected agent 'test_agent', got %q", r1.AgentName) + } + if r1.LLMRecording == nil { + t.Fatal("expected LLM recording to be present") + } + if r1.LLMRecording.LLMRequest.Model != "gemini-2.0-flash" { + t.Errorf("expected model 'gemini-2.0-flash', got %q", r1.LLMRecording.LLMRequest.Model) + } + + // Second recording: Tool + r2 := recordings.Recordings[1] + if r2.ToolRecording == nil { + t.Fatal("expected Tool recording to be present") + } + if r2.ToolRecording.ToolCall.Name != "test_tool" { + t.Errorf("expected tool name 'test_tool', got %q", r2.ToolRecording.ToolCall.Name) + } + if r2.ToolRecording.ToolResponse.Response["result"] != "test_success" { + t.Errorf("expected response 'test_success', got %v", r2.ToolRecording.ToolResponse.Response["result"]) + } + }) + + t.Run("PathValidation", func(t *testing.T) { + tempDir := t.TempDir() + safeDir := filepath.Join(tempDir, "safe") + _ = os.Mkdir(safeDir, 0o755) + + p, mockSession, _ := setup(t, safeDir) + + tests := []struct { + name string + dir string + expectError bool + }{ + { + name: "ValidPath_InsideBaseDir", + dir: safeDir, + expectError: false, + }, + { + name: "InvalidPath_ParentTraversal", + dir: filepath.Join(safeDir, ".."), + expectError: true, + }, + { + name: "InvalidPath_AbsoluteOutside", + dir: "/etc", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := mockSession.State().Set("_adk_recordings_config", map[string]any{ + "dir": tt.dir, + "user_message_index": 0, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + invContext := &MockInvocationContext{ + session: mockSession, + invocationID: "test-invocation-" + tt.name, + } + + _, err = p.BeforeRunCallback()(invContext) + if tt.expectError { + if err == nil { + t.Errorf("expected path error for %q, got nil", tt.dir) + } + } else { + if err != nil { + t.Errorf("unexpected path error for %q: %v", tt.dir, err) + } + } + }) + } + }) + + t.Run("MultiTurnAppendAndDeduplication", func(t *testing.T) { + tempDir := t.TempDir() + p, mockSession, _ := setup(t, tempDir) + + // --- Turn 0 --- + err := mockSession.State().Set("_adk_recordings_config", map[string]any{ + "dir": tempDir, + "user_message_index": 0, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + invContext1 := &MockInvocationContext{session: mockSession, invocationID: "inv-0"} + _, _ = p.BeforeRunCallback()(invContext1) + + cbContext1 := &MockCallbackContext{state: mockSession.State(), invocationID: "inv-0", agentName: "test_agent"} + _, _ = p.BeforeModelCallback()(cbContext1, &model.LLMRequest{Model: "model-0"}) + _, _ = p.AfterModelCallback()(cbContext1, &model.LLMResponse{Content: &genai.Content{Parts: []*genai.Part{{Text: "Response 0"}}}, Partial: false}, nil) + p.AfterRunCallback()(invContext1) + + // --- Turn 1 Append --- + err = mockSession.State().Set("_adk_recordings_config", map[string]any{ + "dir": tempDir, + "user_message_index": 1, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + invContext2 := &MockInvocationContext{session: mockSession, invocationID: "inv-1"} + _, _ = p.BeforeRunCallback()(invContext2) + + cbContext2 := &MockCallbackContext{state: mockSession.State(), invocationID: "inv-1", agentName: "test_agent"} + _, _ = p.BeforeModelCallback()(cbContext2, &model.LLMRequest{Model: "model-1"}) + _, _ = p.AfterModelCallback()(cbContext2, &model.LLMResponse{Content: &genai.Content{Parts: []*genai.Part{{Text: "Response 1"}}}, Partial: false}, nil) + p.AfterRunCallback()(invContext2) + + // Read and verify both turns are recorded + filePath := filepath.Join(tempDir, "generated-recordings.yaml") + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("failed to read recordings file: %v", err) + } + + var recs recording.Recordings + _ = yaml.Unmarshal(data, &recs) + if len(recs.Recordings) != 2 { + t.Fatalf("expected 2 recordings after multi-turn run, got %d", len(recs.Recordings)) + } + if recs.Recordings[0].UserMessageIndex != 0 || recs.Recordings[1].UserMessageIndex != 1 { + t.Errorf("unexpected sequence indexes: turn 0 = %d, turn 1 = %d", recs.Recordings[0].UserMessageIndex, recs.Recordings[1].UserMessageIndex) + } + + // --- Same-Turn Deduplication/Overwrite --- + err = mockSession.State().Set("_adk_recordings_config", map[string]any{ + "dir": tempDir, + "user_message_index": 0, // Overwrite turn 0 + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + invContext3 := &MockInvocationContext{session: mockSession, invocationID: "inv-0-new"} + _, _ = p.BeforeRunCallback()(invContext3) + + cbContext3 := &MockCallbackContext{state: mockSession.State(), invocationID: "inv-0-new", agentName: "test_agent"} + _, _ = p.BeforeModelCallback()(cbContext3, &model.LLMRequest{Model: "model-0"}) + _, _ = p.AfterModelCallback()(cbContext3, &model.LLMResponse{Content: &genai.Content{Parts: []*genai.Part{{Text: "Response 0 Updated"}}}, Partial: false}, nil) + p.AfterRunCallback()(invContext3) + + data, _ = os.ReadFile(filePath) + _ = yaml.Unmarshal(data, &recs) + + // Verify we still have exactly 2 recordings, but the turn 0 content is updated! + if len(recs.Recordings) != 2 { + t.Fatalf("expected exactly 2 recordings after deduplication, got %d", len(recs.Recordings)) + } + var foundTurn0 bool + for _, r := range recs.Recordings { + if r.UserMessageIndex == 0 { + foundTurn0 = true + gotText := r.LLMRecording.LLMResponses[0].Content.Parts[0].Text + if gotText != "Response 0 Updated" { + t.Errorf("expected overwritten text 'Response 0 Updated', got %q", gotText) + } + } + } + if !foundTurn0 { + t.Error("turn 0 recording was unexpectedly lost during deduplication") + } + }) + + t.Run("StreamingChunkAccumulation", func(t *testing.T) { + tempDir := t.TempDir() + p, mockSession, _ := setup(t, tempDir) + + err := mockSession.State().Set("_adk_recordings_config", map[string]any{ + "dir": tempDir, + "user_message_index": 0, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + invContext := &MockInvocationContext{session: mockSession, invocationID: "inv-stream"} + _, _ = p.BeforeRunCallback()(invContext) + + cbContext := &MockCallbackContext{state: mockSession.State(), invocationID: "inv-stream", agentName: "test_agent"} + _, _ = p.BeforeModelCallback()(cbContext, &model.LLMRequest{Model: "model-stream"}) + + // 3 Partial responses + _, _ = p.AfterModelCallback()(cbContext, &model.LLMResponse{Content: &genai.Content{Parts: []*genai.Part{{Text: "Chunk 1"}}}, Partial: true}, nil) + _, _ = p.AfterModelCallback()(cbContext, &model.LLMResponse{Content: &genai.Content{Parts: []*genai.Part{{Text: "Chunk 2"}}}, Partial: true}, nil) + _, _ = p.AfterModelCallback()(cbContext, &model.LLMResponse{Content: &genai.Content{Parts: []*genai.Part{{Text: "Chunk 3"}}}, Partial: true}, nil) + + // 1 Final non-partial response + _, _ = p.AfterModelCallback()(cbContext, &model.LLMResponse{Content: &genai.Content{Parts: []*genai.Part{{Text: "Final Chunk"}}}, Partial: false}, nil) + p.AfterRunCallback()(invContext) + + filePath := filepath.Join(tempDir, "generated-recordings.yaml") + data, _ := os.ReadFile(filePath) + var recs recording.Recordings + _ = yaml.Unmarshal(data, &recs) + + if len(recs.Recordings) != 1 { + t.Fatalf("expected 1 recording, got %d", len(recs.Recordings)) + } + llmRec := recs.Recordings[0].LLMRecording + if len(llmRec.LLMResponses) != 4 { + t.Fatalf("expected all 4 stream chunks to be accumulated, got %d", len(llmRec.LLMResponses)) + } + if llmRec.LLMResponses[3].Content.Parts[0].Text != "Final Chunk" { + t.Errorf("unexpected final response chunk text: %q", llmRec.LLMResponses[3].Content.Parts[0].Text) + } + }) + + t.Run("DisabledBypass", func(t *testing.T) { + tempDir := t.TempDir() + p, mockSession, _ := setup(t, tempDir) + + // We don't set any recording configs in mockSession state + + invContext := &MockInvocationContext{session: mockSession, invocationID: "inv-bypass"} + _, err := p.BeforeRunCallback()(invContext) + if err != nil { + t.Fatalf("beforeRun should not error on empty config, got: %v", err) + } + + cbContext := &MockCallbackContext{state: mockSession.State(), invocationID: "inv-bypass", agentName: "test_agent"} + _, err = p.BeforeModelCallback()(cbContext, &model.LLMRequest{Model: "model-bypass"}) + if err != nil { + t.Fatalf("beforeModel should not error on empty config, got: %v", err) + } + + p.AfterRunCallback()(invContext) + + filePath := filepath.Join(tempDir, "generated-recordings.yaml") + if _, err := os.Stat(filePath); err == nil { + t.Error("recordings file was unexpectedly created even though the plugin was disabled") + } + }) +} + +// --- Mock Implementations --- + +type MockState struct { + data map[string]any +} + +func (m *MockState) Set(key string, val any) error { m.data[key] = val; return nil } +func (m *MockState) Get(key string) (any, error) { return m.data[key], nil } +func (m *MockState) All() iter.Seq2[string, any] { return nil } + +type MockSession struct { + state *MockState +} + +func (m *MockSession) ID() string { return "mock-session-id" } +func (m *MockSession) AppName() string { return "mock-app" } +func (m *MockSession) UserID() string { return "mock-user" } +func (m *MockSession) State() session.State { return m.state } +func (m *MockSession) Events() session.Events { return nil } +func (m *MockSession) LastUpdateTime() time.Time { return time.Now() } + +type MockInvocationContext struct { + session *MockSession + invocationID string +} + +func (m *MockInvocationContext) Session() session.Session { return m.session } +func (m *MockInvocationContext) InvocationID() string { return m.invocationID } +func (m *MockInvocationContext) Agent() agent.Agent { return nil } +func (m *MockInvocationContext) Artifacts() agent.Artifacts { return nil } +func (m *MockInvocationContext) Memory() agent.Memory { return nil } +func (m *MockInvocationContext) Branch() string { return "" } +func (m *MockInvocationContext) UserContent() *genai.Content { return nil } +func (m *MockInvocationContext) RunConfig() *agent.RunConfig { return nil } +func (m *MockInvocationContext) EndInvocation() {} +func (m *MockInvocationContext) Ended() bool { return false } +func (m *MockInvocationContext) WithContext(ctx context.Context) agent.InvocationContext { return m } +func (m *MockInvocationContext) Value(key any) any { return nil } +func (m *MockInvocationContext) Deadline() (deadline time.Time, ok bool) { return time.Time{}, false } +func (m *MockInvocationContext) Done() <-chan struct{} { return nil } +func (m *MockInvocationContext) Err() error { return nil } + +type MockCallbackContext struct { + state session.State + invocationID string + agentName string +} + +func (m *MockCallbackContext) State() session.State { return m.state } +func (m *MockCallbackContext) ReadonlyState() session.ReadonlyState { return m.state } +func (m *MockCallbackContext) InvocationID() string { return m.invocationID } +func (m *MockCallbackContext) AgentName() string { return m.agentName } +func (m *MockCallbackContext) AppName() string { return "mock-app" } +func (m *MockCallbackContext) Branch() string { return "" } +func (m *MockCallbackContext) SessionID() string { return "mock-session-id" } +func (m *MockCallbackContext) UserID() string { return "mock-user" } +func (m *MockCallbackContext) UserContent() *genai.Content { return nil } +func (m *MockCallbackContext) Artifacts() agent.Artifacts { return nil } +func (m *MockCallbackContext) Value(key any) any { return nil } +func (m *MockCallbackContext) Deadline() (deadline time.Time, ok bool) { return time.Time{}, false } +func (m *MockCallbackContext) Done() <-chan struct{} { return nil } +func (m *MockCallbackContext) Err() error { return nil } + +type MockToolContext struct { + state session.State + invocationID string + agentName string + functionCallID string +} + +func (m *MockToolContext) State() session.State { return m.state } +func (m *MockToolContext) ReadonlyState() session.ReadonlyState { return m.state } +func (m *MockToolContext) InvocationID() string { return m.invocationID } +func (m *MockToolContext) AgentName() string { return m.agentName } +func (m *MockToolContext) FunctionCallID() string { return m.functionCallID } +func (m *MockToolContext) Actions() *session.EventActions { return nil } +func (m *MockToolContext) SearchMemory(ctx context.Context, query string) (*memory.SearchResponse, error) { + return nil, nil +} +func (m *MockToolContext) ToolConfirmation() *toolconfirmation.ToolConfirmation { return nil } +func (m *MockToolContext) RequestConfirmation(hint string, payload any) error { return nil } +func (m *MockToolContext) AppName() string { return "mock-app" } +func (m *MockToolContext) Branch() string { return "" } +func (m *MockToolContext) SessionID() string { return "mock-session-id" } +func (m *MockToolContext) UserID() string { return "mock-user" } +func (m *MockToolContext) UserContent() *genai.Content { return nil } +func (m *MockToolContext) Artifacts() agent.Artifacts { return nil } +func (m *MockToolContext) Value(key any) any { return nil } +func (m *MockToolContext) Deadline() (deadline time.Time, ok bool) { return time.Time{}, false } +func (m *MockToolContext) Done() <-chan struct{} { return nil } +func (m *MockToolContext) Err() error { return nil } + +type MockTool struct { + NameVal string +} + +func (m *MockTool) Name() string { return m.NameVal } +func (m *MockTool) Description() string { return "mock tool" } +func (m *MockTool) IsLongRunning() bool { return false } diff --git a/internal/configurable/conformance/recordplugin/yaml_clean_test.go b/internal/configurable/conformance/recordplugin/yaml_clean_test.go new file mode 100644 index 000000000..c2a6ac3e3 --- /dev/null +++ b/internal/configurable/conformance/recordplugin/yaml_clean_test.go @@ -0,0 +1,413 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package recordplugin + +import ( + "testing" + + "google.golang.org/genai" + "gopkg.in/yaml.v3" +) + +func TestCleanYAMLNode(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "prunes null fields", + input: ` +field1: null +field2: ~ +field3: "value" +`, + expected: `field3: "value" +`, + }, + { + name: "prunes default zero values and false booleans outside tool args", + input: ` +candidate_count: 0 +max_output_tokens: 0 +thought: false +text: "keep me" +`, + expected: `text: "keep me" +`, + }, + { + name: "retains zero values and false booleans inside tool args", + input: ` +tool_call: + name: "my_tool" + args: + param1: 0 + param2: false +`, + expected: `tool_call: + name: "my_tool" + args: + param1: 0 + param2: false +`, + }, + { + name: "retains zero values and false booleans inside tool responses", + input: ` +tool_response: + response: + success: false + count: 0 +`, + expected: `tool_response: + response: + success: false + count: 0 +`, + }, + { + name: "prunes empty sequences but retains empty mappings", + input: ` +thought_signature: [] +google_search: {} +text: "test" +`, + expected: `google_search: {} +text: "test" +`, + }, + { + name: "retains thought_signature base64 scalar as string", + input: ` +text: "test" +thought_signature: "SGVsbG8=" +`, + expected: `text: "test" +thought_signature: "SGVsbG8=" +`, + }, + { + name: "retains thoughtsignature base64 scalar as string", + input: ` +text: "test" +thoughtsignature: "SGVsbG8=" +`, + expected: `text: "test" +thought_signature: "SGVsbG8=" +`, + }, + { + name: "converts thought_signature sequence of integers to base64 string", + input: ` +text: "test" +thought_signature: + - 72 + - 101 + - 108 + - 108 + - 111 +`, + expected: `text: "test" +thought_signature: "SGVsbG8=" +`, + }, + { + name: "converts thoughtsignature sequence of integers to base64 string", + input: ` +text: "test" +thoughtsignature: + - 72 + - 101 + - 108 + - 108 + - 111 +`, + expected: `text: "test" +thought_signature: "SGVsbG8=" +`, + }, + { + name: "deep nested pruning", + input: ` +recordings: + - user_message_index: 0 + agent_name: "search" + llm_recording: + llm_request: + model: "gemini" + contents: + - parts: + - media_resolution: null + text: "hello" + thought_signature: [] +`, + expected: `recordings: + - user_message_index: 0 + agent_name: "search" + llm_recording: + llm_request: + model: "gemini" + contents: + - parts: + - text: "hello" +`, + }, + { + name: "cleans bloated parameters schema and translates keys", + input: ` +functiondeclarations: + - name: "validate_email" + description: "checks email" + parametersjsonschema: + id: "" + schema: "" + ref: "" + comment: "" + defs: {} + definitions: {} + dependentrequired: {} + extra: {} + type: "object" + required: + - "email" + properties: + email: + id: "" + type: "string" + description: "the email address" + dependentrequired: {} + responsejsonschema: + type: "boolean" +`, + expected: ` +function_declarations: + - name: "validate_email" + description: "checks email" + parameters_json_schema: + title: "validate_emailParams" + type: "object" + required: + - "email" + properties: + email: + title: "Email" + type: "string" + description: "the email address" +`, + }, + { + name: "cleans schema parameters mapping additionalproperties and propertyorder, and pruning unsupported openapi properties", + input: ` +functiondeclarations: + - name: "validate_email" + parametersjsonschema: + type: "object" + required: + - "email" + properties: + email: + type: "string" + additionalproperties: + not: {} + propertyorder: + - "email" + deprecated: false + readonly: false + writeonly: false + uniqueitems: false +`, + expected: ` +function_declarations: + - name: "validate_email" + parameters_json_schema: + title: "validate_emailParams" + type: "object" + required: + - "email" + properties: + email: + title: "Email" + type: "string" +`, + }, + { + name: "prunes empty strings for general fields but retains them inside tool args", + input: ` +llm_recording: + llm_responses: + - content: + parts: + - text: "done" + session_resumption_handle: "" + error_code: "" + error_message: "" +tool_call: + name: "my_tool" + args: + param_empty_str: "" +`, + expected: ` +llm_recording: + llm_responses: + - content: + parts: + - text: "done" +tool_call: + name: "my_tool" + args: + param_empty_str: "" +`, + }, + { + name: "prunes empty GenerateContentConfig and google search properties", + input: ` +llm_recording: + llm_request: + config: + response_mime_type: "" + cached_content: "" + media_resolution: "" + service_tier: "" + tools: + - google_search: + blocking_confidence: "" + labels: {} +`, + expected: ` +llm_recording: + llm_request: + config: + tools: + - google_search: {} +`, + }, + { + name: "normalizes raw string system_instruction to structured object", + input: ` +llm_request: + config: + system_instruction: "You are a booking assistant." +`, + expected: ` +llm_request: + config: + system_instruction: "You are a booking assistant." +`, + }, + { + name: "injects part_metadata to contents parts", + input: ` +llm_request: + contents: + - role: "user" + parts: + - text: "Hello" +`, + expected: ` +llm_request: + contents: + - role: "user" + parts: + - text: "Hello" +`, + }, + { + name: "preserves existing part_metadata if already present", + input: ` +llm_request: + contents: + - role: "user" + parts: + - text: "Hello" + part_metadata: + some_key: "some_value" +`, + expected: ` +llm_request: + contents: + - role: "user" + parts: + - text: "Hello" + part_metadata: + some_key: "some_value" +`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var node yaml.Node + err := yaml.Unmarshal([]byte(tc.input), &node) + if err != nil { + t.Fatalf("failed to unmarshal test input: %v", err) + } + + cleanYAMLNode(&node, false) + + output, err := yaml.Marshal(&node) + if err != nil { + t.Fatalf("failed to marshal cleaned node: %v", err) + } + + gotClean := normalizeYAML(string(output)) + expectedClean := normalizeYAML(tc.expected) + if gotClean != expectedClean { + t.Errorf("mismatch.\nGot:\n%s\nExpected:\n%s", gotClean, expectedClean) + } + }) + } +} + +func normalizeYAML(s string) string { + var node yaml.Node + if err := yaml.Unmarshal([]byte(s), &node); err != nil { + return s + } + out, _ := yaml.Marshal(&node) + return string(out) +} + +func TestFunctionDeclarationTitleInjection(t *testing.T) { + decl := &genai.FunctionDeclaration{ + Name: "validate_email", + Description: "checks email", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Required: []string{"email"}, + Properties: map[string]*genai.Schema{ + "email": { + Type: genai.TypeString, + Description: "the email address", + }, + }, + }, + } + + var node yaml.Node + err := node.Encode(decl) + if err != nil { + t.Fatalf("failed to encode: %v", err) + } + + cleanYAMLNode(&node, false) + + output, err := yaml.Marshal(&node) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + t.Logf("Serialized:\n%s", string(output)) +} diff --git a/internal/configurable/conformance/replayplugin/recording/recording.go b/internal/configurable/conformance/replayplugin/recording/recording.go index 98eaed777..99474b109 100644 --- a/internal/configurable/conformance/replayplugin/recording/recording.go +++ b/internal/configurable/conformance/replayplugin/recording/recording.go @@ -29,18 +29,18 @@ type Recordings struct { // Recording represents a single interaction recording, ordered by request timestamp. type Recording struct { // Index of the user message this recording belongs to (0-based). - UserMessageIndex int `yaml:"usermessageindex"` + UserMessageIndex int `yaml:"user_message_index"` // Name of the agent. - AgentName string `yaml:"agentname"` + AgentName string `yaml:"agent_name"` // oneof fields - start // LLM request-response pair. - LLMRecording *LLMRecording `yaml:"llmrecording,omitempty"` + LLMRecording *LLMRecording `yaml:"llm_recording,omitempty"` // Tool call-response pair. - ToolRecording *ToolRecording `yaml:"toolrecording,omitempty"` + ToolRecording *ToolRecording `yaml:"tool_recording,omitempty"` // oneof fields - end @@ -51,17 +51,17 @@ type Recording struct { // LLMRecording represents a paired LLM request and response. type LLMRecording struct { // Required. The LLM request. - LLMRequest *model.LLMRequest `yaml:"llmrequest,omitempty"` + LLMRequest *model.LLMRequest `yaml:"llm_request,omitempty"` // Required. The LLM response. - LLMResponses []*model.LLMResponse `yaml:"llmresponses,omitempty"` + LLMResponses []*model.LLMResponse `yaml:"llm_responses,omitempty"` } // ToolRecording represents a paired tool call and response. type ToolRecording struct { // Required. The tool call. - ToolCall *genai.FunctionCall `yaml:"toolcall,omitempty"` + ToolCall *genai.FunctionCall `yaml:"tool_call,omitempty"` // Required. The tool response. - ToolResponse *genai.FunctionResponse `yaml:"toolresponse,omitempty"` + ToolResponse *genai.FunctionResponse `yaml:"tool_response,omitempty"` } diff --git a/internal/configurable/conformance/replayplugin/replay_plugin.go b/internal/configurable/conformance/replayplugin/replay_plugin.go index 1ab82abcb..db67ba269 100644 --- a/internal/configurable/conformance/replayplugin/replay_plugin.go +++ b/internal/configurable/conformance/replayplugin/replay_plugin.go @@ -316,8 +316,7 @@ func (p *replayPlugin) loadInvocationState(ctx agent.InvocationContext) (*invoca return nil, fmt.Errorf("failed to parse recordings from %s: %w", recordingsPath, err) } - removeUnderscores(&root) - fixTypeMismatches(&root) + normalizeYAMLNode(&root) var recordings recording.Recordings if err := root.Decode(&recordings); err != nil { diff --git a/internal/configurable/conformance/replayplugin/yaml_utils.go b/internal/configurable/conformance/replayplugin/yaml_utils.go index 353936fcf..1dddcbec6 100644 --- a/internal/configurable/conformance/replayplugin/yaml_utils.go +++ b/internal/configurable/conformance/replayplugin/yaml_utils.go @@ -20,45 +20,39 @@ import ( "gopkg.in/yaml.v3" ) -var toIgnore = map[string]struct{}{"thought_signature": {}, "http_options": {}, "args": {}, "response": {}} +var toIgnore = map[string]struct{}{ + "thought_signature": {}, + "http_options": {}, + "args": {}, + "response": {}, +} + +var toIgnoreButRecurse = map[string]struct{}{ + "user_message_index": {}, + "agent_name": {}, + "llm_recording": {}, + "llm_request": {}, + "llm_responses": {}, + "tool_recording": {}, + "tool_call": {}, + "tool_response": {}, +} -func removeUnderscores(node *yaml.Node) { +// normalizeYAMLNode standardizes the recorded YAML AST in a single tree traversal. +// It strips underscores from structural Go field keys to match case-insensitive fields, +// while preserving user freeform tool arguments/responses, and resolves type mismatches. +func normalizeYAMLNode(node *yaml.Node) { switch node.Kind { case yaml.DocumentNode, yaml.SequenceNode: - // If it's a document or a list, just pass through to its children for _, child := range node.Content { - removeUnderscores(child) + normalizeYAMLNode(child) } case yaml.MappingNode: - // A MappingNode's content is a flat array alternating [key, value, key, value...] for i := 0; i < len(node.Content); i += 2 { keyNode := node.Content[i] valueNode := node.Content[i+1] - if _, ok := toIgnore[keyNode.Value]; ok { - continue - } - - // Strip the underscore from the key (e.g., "first_name" -> "firstname") - keyNode.Value = strings.ReplaceAll(keyNode.Value, "_", "") - - // Continue walking down into the value in case of nested objects - removeUnderscores(valueNode) - } - } -} - -func fixTypeMismatches(n *yaml.Node) { - switch n.Kind { - case yaml.DocumentNode, yaml.SequenceNode: - for _, child := range n.Content { - fixTypeMismatches(child) - } - - case yaml.MappingNode: - for i := 0; i < len(n.Content); i += 2 { - keyNode := n.Content[i] - valueNode := n.Content[i+1] + // 1. Fix known type mismatches and singular/plural fields switch keyNode.Value { case "systeminstruction": if valueNode.Kind == yaml.ScalarNode { @@ -86,7 +80,7 @@ func fixTypeMismatches(n *yaml.Node) { }, } } - case "llmresponse": + case "llmresponse", "llm_response": if valueNode.Kind == yaml.MappingNode { origCopy := *valueNode valueNode.Kind = yaml.SequenceNode @@ -94,11 +88,25 @@ func fixTypeMismatches(n *yaml.Node) { valueNode.Value = "" valueNode.Content = []*yaml.Node{&origCopy} } - keyNode.Value = "llmresponses" + keyNode.Value = "llm_responses" } - // Recurse into the value to catch nested structures - fixTypeMismatches(valueNode) + // 2. Skip processing/recursion for ignored freeform keys (like tool args or response payload) + if _, ok := toIgnore[keyNode.Value]; ok { + continue + } + + // 3. For standard metadata fields, preserve snake_case but recurse into value + if _, ok := toIgnoreButRecurse[keyNode.Value]; ok { + normalizeYAMLNode(valueNode) + continue + } + + // 4. Strip underscores to match camelCase Go struct field names case-insensitively + keyNode.Value = strings.ReplaceAll(keyNode.Value, "_", "") + + // 5. Recurse to process nested fields + normalizeYAMLNode(valueNode) } } } diff --git a/internal/configurable/conformance/replayplugin/yaml_utils_test.go b/internal/configurable/conformance/replayplugin/yaml_utils_test.go new file mode 100644 index 000000000..e36336596 --- /dev/null +++ b/internal/configurable/conformance/replayplugin/yaml_utils_test.go @@ -0,0 +1,173 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package replayplugin + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestRemoveUnderscores(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "strips underscores from standard keys", + input: ` +first_name: "John" +last_name: "Doe" +`, + expected: ` +firstname: "John" +lastname: "Doe" +`, + }, + { + name: "ignores fields specified in toIgnore", + input: ` +thought_signature: "SGVsbG8=" +http_options: "options" +args: "arguments" +response: "result" +`, + expected: ` +thought_signature: "SGVsbG8=" +http_options: "options" +args: "arguments" +response: "result" +`, + }, + { + name: "ignores but recurses fields specified in toIgnoreButRecurse", + input: ` +user_message_index: 0 +agent_name: "test" +llm_recording: + nested_field_with_underscore: "nested" +`, + expected: ` +user_message_index: 0 +agent_name: "test" +llm_recording: + nestedfieldwithunderscore: "nested" +`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var node yaml.Node + err := yaml.Unmarshal([]byte(tc.input), &node) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + normalizeYAMLNode(&node) + + output, err := yaml.Marshal(&node) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + gotClean := normalizeYAML(string(output)) + expectedClean := normalizeYAML(tc.expected) + if gotClean != expectedClean { + t.Errorf("mismatch.\nGot:\n%s\nExpected:\n%s", gotClean, expectedClean) + } + }) + } +} + +func TestFixTypeMismatches(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "wraps llmresponse mapping into sequence llm_responses", + input: ` +llmresponse: + content: + role: "model" +`, + expected: ` +llm_responses: + - content: + role: "model" +`, + }, + { + name: "wraps llm_response mapping into sequence llm_responses", + input: ` +llm_response: + content: + role: "model" +`, + expected: ` +llm_responses: + - content: + role: "model" +`, + }, + { + name: "normalizes systeminstruction scalar string to structured object", + input: ` +systeminstruction: "You are a helpful assistant." +`, + expected: ` +systeminstruction: + role: user + parts: + - text: You are a helpful assistant. +`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var node yaml.Node + err := yaml.Unmarshal([]byte(tc.input), &node) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + normalizeYAMLNode(&node) + + output, err := yaml.Marshal(&node) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + gotClean := normalizeYAML(string(output)) + expectedClean := normalizeYAML(tc.expected) + if gotClean != expectedClean { + t.Errorf("mismatch.\nGot:\n%s\nExpected:\n%s", gotClean, expectedClean) + } + }) + } +} + +func normalizeYAML(s string) string { + var node yaml.Node + if err := yaml.Unmarshal([]byte(s), &node); err != nil { + return s + } + out, _ := yaml.Marshal(&node) + return string(out) +} From 2472d61f7f05e27056101711b0a741c7bbc3e90c Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Fri, 29 May 2026 13:31:12 +0000 Subject: [PATCH 64/86] fix: accept metadata-only SSE chunks instead of aborting stream in StreamingResponseAggregator (#918) * fix: skip metadata-only SSE chunks instead of aborting stream in StreamingResponseAggregator ## Link to Issue or Description of Change Signed-off-by: anish k * Fix for SSE chunks with no candidates * Linter fix --------- Signed-off-by: anish k Co-authored-by: anish k --- internal/llminternal/converters/converters.go | 12 +- internal/llminternal/stream_aggregator.go | 12 +- .../llminternal/stream_aggregator_test.go | 103 ++++++++++++++++++ 3 files changed, 117 insertions(+), 10 deletions(-) diff --git a/internal/llminternal/converters/converters.go b/internal/llminternal/converters/converters.go index e9759447f..7a30fd1ac 100644 --- a/internal/llminternal/converters/converters.go +++ b/internal/llminternal/converters/converters.go @@ -56,9 +56,17 @@ func Genai2LLMResponse(res *genai.GenerateContentResponse) *model.LLMResponse { ModelVersion: res.ModelVersion, } } + // no candidates, no prompt feedback. + // Sometimes gemini-3* invoked via aiplatform (VertexAI) sends empty entries at the beginning. + // sample stream of SSE chunks (first 3): + // data: {"candidates": [{"content": {"role": "model","parts": [{"text": ""}]}}],"usageMetadata": {"trafficType": "ON_DEMAND"},"modelVersion": "gemini-3.1-flash-lite","createTime": "2026-05-28T09:40:03.380865Z","responseId": "REDACTED"} + // + // data: {"usageMetadata": {"trafficType": "ON_DEMAND"},"modelVersion": "gemini-3.1-flash-lite","createTime": "2026-05-28T09:40:03.380865Z","responseId": "REDACTED"} + // + // data: {"usageMetadata": {"trafficType": "ON_DEMAND"},"modelVersion": "gemini-3.1-flash-lite","createTime": "2026-05-28T09:40:03.380865Z","responseId": "REDACTED"} + // we should treat them as valid, empty responses and let the downstream to process usageMetadata return &model.LLMResponse{ - ErrorCode: "UNKNOWN_ERROR", - ErrorMessage: "Unknown error.", + Content: &genai.Content{Parts: []*genai.Part{}, Role: "model"}, UsageMetadata: usageMetadata, ModelVersion: res.ModelVersion, } diff --git a/internal/llminternal/stream_aggregator.go b/internal/llminternal/stream_aggregator.go index 6ab0e5550..a105d5ac6 100644 --- a/internal/llminternal/stream_aggregator.go +++ b/internal/llminternal/stream_aggregator.go @@ -16,7 +16,6 @@ package llminternal import ( "context" - "fmt" "iter" "maps" "reflect" @@ -59,14 +58,11 @@ func NewStreamingResponseAggregator() *streamingResponseAggregator { // also yielding an aggregated response if the GenerateContentResponse has zero parts or is audio data func (s *streamingResponseAggregator) ProcessResponse(ctx context.Context, genResp *genai.GenerateContentResponse) iter.Seq2[*model.LLMResponse, error] { return func(yield func(*model.LLMResponse, error) bool) { - if len(genResp.Candidates) == 0 { - // shouldn't happen? - yield(nil, fmt.Errorf("empty response")) - return - } - candidate := genResp.Candidates[0] resp := converters.Genai2LLMResponse(genResp) - resp.TurnComplete = candidate.FinishReason != "" + if len(genResp.Candidates) > 0 { + candidate := genResp.Candidates[0] + resp.TurnComplete = candidate.FinishReason != "" + } // Aggregate the response and check if an intermediate event to yield was created if aggrResp := s.aggregateResponse(resp); aggrResp != nil { if !yield(aggrResp, nil) { diff --git a/internal/llminternal/stream_aggregator_test.go b/internal/llminternal/stream_aggregator_test.go index 87c23f764..b71d9ec01 100644 --- a/internal/llminternal/stream_aggregator_test.go +++ b/internal/llminternal/stream_aggregator_test.go @@ -1001,6 +1001,109 @@ func TestPartialFunctionCallsNotExecutedInNoneStreamingMode(t *testing.T) { } } +func TestMetadataVertexAISSEStream(t *testing.T) { + aggregator := llminternal.NewStreamingResponseAggregator() + ctx := t.Context() + + emptyTextChunk := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + {Content: genai.NewContentFromText("", "model")}, + }, + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{}, + } + + // Simulate the leading metadata-only SSE chunk that Vertex AI emits for + // gemini-3-flash-preview + googleSearch grounding: no Candidates at all. + metadataChunk := &genai.GenerateContentResponse{ + // Candidates is intentionally nil / empty. + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{}, + } + + // The real content arrives in the next chunk. + contentChunk := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + { + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{{Text: "Here are some movie recommendations."}}, + }, + FinishReason: genai.FinishReasonStop, + }, + }, + } + + stream := []*genai.GenerateContentResponse{emptyTextChunk, metadataChunk, metadataChunk, emptyTextChunk, metadataChunk, metadataChunk, contentChunk} + + for _, chunk := range stream { + for resp, err := range aggregator.ProcessResponse(ctx, chunk) { + if err != nil { + t.Fatalf("unexpected error processing chunk: %v", err) + } + _ = resp + } + } + + finalResponse := aggregator.Close() + if finalResponse == nil { + t.Fatal("expected a final aggregated response, got nil") + } + + if len(finalResponse.Content.Parts) == 0 { + t.Fatal("expected content parts in the final response, got none") + } + + if finalResponse.Content.Parts[0].Text != "Here are some movie recommendations." { + t.Errorf("unexpected text in final response: %q", finalResponse.Content.Parts[0].Text) + } +} + +func TestMetadataOnlyChunkDoesNotAbortStream(t *testing.T) { + aggregator := llminternal.NewStreamingResponseAggregator() + ctx := t.Context() + + // Simulate the leading metadata-only SSE chunk that Vertex AI emits for + // gemini-3-flash-preview + googleSearch grounding: no Candidates at all. + metadataChunk := &genai.GenerateContentResponse{ + // Candidates is intentionally nil / empty. + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{}, + } + + // The real content arrives in the next chunk. + contentChunk := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + { + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{{Text: "Here are some movie recommendations."}}, + }, + FinishReason: genai.FinishReasonStop, + }, + }, + } + + for _, chunk := range []*genai.GenerateContentResponse{metadataChunk, contentChunk} { + for resp, err := range aggregator.ProcessResponse(ctx, chunk) { + if err != nil { + t.Fatalf("unexpected error processing chunk: %v", err) + } + _ = resp + } + } + + finalResponse := aggregator.Close() + if finalResponse == nil { + t.Fatal("expected a final aggregated response, got nil") + } + + if len(finalResponse.Content.Parts) == 0 { + t.Fatal("expected content parts in the final response, got none") + } + + if finalResponse.Content.Parts[0].Text != "Here are some movie recommendations." { + t.Errorf("unexpected text in final response: %q", finalResponse.Content.Parts[0].Text) + } +} + func TestFinishReasonUnexpectedToolCallPreservesErrorCode(t *testing.T) { aggregator := llminternal.NewStreamingResponseAggregator() ctx := t.Context() From f771a3344c3187a650f6042f0bc0467c97a0a8ea Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Mon, 1 Jun 2026 13:23:40 +0000 Subject: [PATCH 65/86] Add v2 checks (#937) --- .github/workflows/go.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index fedc37dc9..121284133 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -7,10 +7,10 @@ name: Go on: push: - branches: [ "main" ] + branches: [ "main", "v2" ] pull_request: - branches: [ "main" ] + branches: [ "main", "v2" ] workflow_dispatch: From 81a63d8feb7d713b1731f0c740d95574eb64dafa Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Tue, 2 Jun 2026 07:21:35 +0000 Subject: [PATCH 66/86] Context unification: switch all to not deprecated (#935) * Replaced tool.Context with agent.ToolContext * fixed runnableTool --- agent/llmagent/llmagent.go | 6 +- agent/llmagent/llmagent_test.go | 28 +++--- agent/llmagent/state_agent_test.go | 18 ++-- agent/remoteagent/v2/a2a_e2e_test.go | 4 +- agent/workflowagents/loopagent/agent_test.go | 4 +- .../parallelagent/agent_test.go | 4 +- agent/workflowagents/sequentialagent/agent.go | 3 +- examples/agentengine/main.go | 6 +- examples/bidi/main.go | 2 +- examples/bidi/streamingtool/main.go | 6 +- examples/toolconfirmation/main.go | 2 +- examples/tools/multipletools/main.go | 2 +- examples/vertexai/imagegenerator/main.go | 4 +- examples/web/agents/image_generator.go | 2 +- .../configurable/conformance/functions.go | 15 +-- .../conformance/recordplugin/record_plugin.go | 4 +- .../conformance/replayplugin/replay_plugin.go | 2 +- internal/llminternal/agent_transfer.go | 4 +- internal/llminternal/agent_transfer_test.go | 2 +- internal/llminternal/base_flow.go | 28 +++--- internal/llminternal/base_flow_test.go | 94 +++++++++---------- internal/llminternal/functions.go | 2 +- .../handle_function_calls_async_test.go | 2 +- .../llminternal/outputschema_processor.go | 3 +- .../outputschema_processor_test.go | 2 +- .../parallel_function_call_hitl_test.go | 2 +- .../parallel_function_call_test.go | 2 +- .../request_confirmation_processor_test.go | 2 +- .../llminternal/stream_aggregator_test.go | 4 +- internal/llminternal/streaming_tool_test.go | 4 +- internal/plugininternal/plugin_manager.go | 6 +- internal/toolinternal/tool.go | 7 +- plugin/functioncallmodifier/plugin_test.go | 2 +- plugin/loggingplugin/logging_plugin.go | 6 +- plugin/plugin_manager_test.go | 88 ++++++++--------- plugin/retryandreflect/plugin.go | 11 ++- plugin/retryandreflect/plugin_test.go | 4 +- tool/agenttool/agent_tool.go | 4 +- tool/agenttool/agent_tool_test.go | 3 +- tool/exampletool/tool.go | 4 +- tool/exitlooptool/tool.go | 3 +- tool/functiontool/function.go | 9 +- tool/functiontool/function_test.go | 28 +++--- .../long_running_function_test.go | 11 ++- tool/functiontool/streaming_function.go | 7 +- tool/geminitool/google_search.go | 4 +- tool/geminitool/tool.go | 3 +- tool/loadartifactstool/load_artifacts_tool.go | 8 +- .../load_artifacts_tool_test.go | 3 +- tool/loadmemorytool/tool.go | 6 +- tool/loadmemorytool/tool_test.go | 3 +- tool/mcptoolset/tool.go | 5 +- tool/preloadmemorytool/tool.go | 4 +- tool/preloadmemorytool/tool_test.go | 3 +- .../internal/skilltool/list_skills.go | 5 +- .../internal/skilltool/load_skill.go | 5 +- .../internal/skilltool/load_skill_resource.go | 5 +- .../internal/skilltool/tools_test.go | 3 +- tool/skilltoolset/toolset.go | 2 +- tool/tool.go | 6 +- tool/tool_test.go | 4 +- 61 files changed, 265 insertions(+), 260 deletions(-) diff --git a/agent/llmagent/llmagent.go b/agent/llmagent/llmagent.go index 634ded2da..d2773dfc2 100644 --- a/agent/llmagent/llmagent.go +++ b/agent/llmagent/llmagent.go @@ -310,7 +310,7 @@ type OnModelErrorCallback func(ctx agent.CallbackContext, llmRequest *model.LLMR // // To modify tool arguments and still run the tool, // update args in place and return (nil, nil). -type BeforeToolCallback func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) +type BeforeToolCallback func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) // AfterToolCallback is a function type executed after a tool's Run method has completed, // regardless of whether the tool returned a result or an error. @@ -319,13 +319,13 @@ type BeforeToolCallback func(ctx tool.Context, tool tool.Tool, args map[string]a // If a callback returns a non-nil result or an error: // - execution of remaining callbacks stops // - the returned result and/or error is used as the final tool output -type AfterToolCallback func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) +type AfterToolCallback func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) // OnToolErrorCallback that is called when receiving an error response from tool execution. // // If it returns non-nil LLMResponse or error, the actual model response/error // is replaced with the returned response/error. -type OnToolErrorCallback func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) +type OnToolErrorCallback func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) // IncludeContents controls what parts of prior conversation history is received by llmagent. type IncludeContents string diff --git a/agent/llmagent/llmagent_test.go b/agent/llmagent/llmagent_test.go index 1d434ad19..a82a4930f 100644 --- a/agent/llmagent/llmagent_test.go +++ b/agent/llmagent/llmagent_test.go @@ -449,7 +449,7 @@ func TestToolCallback(t *testing.T) { Number int `json:"number"` } - handler := func(_ tool.Context, input Args) (Result, error) { + handler := func(_ agent.ToolContext, input Args) (Result, error) { return Result{Number: 1}, nil } rand, _ := functiontool.New(functiontool.Config{ @@ -468,10 +468,10 @@ func TestToolCallback(t *testing.T) { DisallowTransferToPeers: true, Tools: []tool.Tool{rand}, BeforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, nil }, - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return map[string]any{"number": "7"}, nil }, }, @@ -504,10 +504,10 @@ func TestToolCallback(t *testing.T) { Tools: []tool.Tool{rand}, BeforeToolCallbacks: []llmagent.BeforeToolCallback{ // Since it retursn non nil, the next callback won't be executed. - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return map[string]any{"number": "3"}, nil }, - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return map[string]any{"number": "7"}, nil }, }, @@ -539,10 +539,10 @@ func TestToolCallback(t *testing.T) { DisallowTransferToPeers: true, Tools: []tool.Tool{rand}, AfterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return nil, nil }, - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return map[string]any{"number": "7"}, nil }, }, @@ -575,10 +575,10 @@ func TestToolCallback(t *testing.T) { Tools: []tool.Tool{rand}, AfterToolCallbacks: []llmagent.AfterToolCallback{ // Since it retursn non nil, the next callback won't be executed. - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return map[string]any{"number": "3"}, nil }, - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return map[string]any{"number": "7"}, nil }, }, @@ -610,12 +610,12 @@ func TestToolCallback(t *testing.T) { DisallowTransferToPeers: true, Tools: []tool.Tool{rand}, BeforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return map[string]any{"number": "3"}, nil }, }, AfterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return map[string]any{"number": "7"}, nil }, }, @@ -647,12 +647,12 @@ func TestToolCallback(t *testing.T) { DisallowTransferToPeers: true, Tools: []tool.Tool{rand}, BeforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, nil }, }, AfterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return nil, nil }, }, @@ -857,7 +857,7 @@ func TestFunctionTool(t *testing.T) { } prompt := "what is the sum of 1 + 2?" - handler := func(_ tool.Context, input Args) (Result, error) { + handler := func(_ agent.ToolContext, input Args) (Result, error) { if input.A != 1 || input.B != 2 { t.Errorf("handler received %+v, want {a: 1, b: 2}", input) } diff --git a/agent/llmagent/state_agent_test.go b/agent/llmagent/state_agent_test.go index fef38ba37..9256e9dca 100644 --- a/agent/llmagent/state_agent_test.go +++ b/agent/llmagent/state_agent_test.go @@ -263,7 +263,7 @@ type WeatherResult struct { Timestamp time.Time `json:"timestamp"` } -func GetWeather(ctx tool.Context, args WeatherArgs) (WeatherResult, error) { +func GetWeather(ctx agent.ToolContext, args WeatherArgs) (WeatherResult, error) { // Simulate weather data temperatures := []int{-10, -5, 0, 5, 10, 15, 20, 25, 30, 35} conditions := []string{"sunny", "cloudy", "rainy", "snowy", "windy"} @@ -291,7 +291,7 @@ type CalculationResult struct { Timestamp time.Time `json:"timestamp"` } -func Calculate(ctx tool.Context, args CalculationArgs) (CalculationResult, error) { +func Calculate(ctx agent.ToolContext, args CalculationArgs) (CalculationResult, error) { operations := map[string]float64{ "add": args.X + args.Y, "subtract": args.X - args.Y, @@ -341,7 +341,7 @@ type LogActivityResult struct { err error } -func LogActivity(ctx tool.Context, params LogActivityParams) (LogActivityResult, error) { +func LogActivity(ctx agent.ToolContext, params LogActivityParams) (LogActivityResult, error) { var activityLog []LogEntry val, err := ctx.State().Get("activity_log") if err == nil { @@ -366,7 +366,7 @@ func LogActivity(ctx tool.Context, params LogActivityParams) (LogActivityResult, // --- Before Tool Callbacks --- -func beforeToolAuditCallback(ctx tool.Context, t tool.Tool, args map[string]any) (map[string]any, error) { +func beforeToolAuditCallback(ctx agent.ToolContext, t tool.Tool, args map[string]any) (map[string]any, error) { fmt.Printf("🔍 AUDIT: About to call tool '%s' with args: %v\n", t.Name(), args) var auditLog []map[string]any @@ -387,7 +387,7 @@ func beforeToolAuditCallback(ctx tool.Context, t tool.Tool, args map[string]any) return nil, nil // Continue execution } -func beforeToolSecurityCallback(ctx tool.Context, t tool.Tool, args map[string]any) (map[string]any, error) { +func beforeToolSecurityCallback(ctx agent.ToolContext, t tool.Tool, args map[string]any) (map[string]any, error) { if t.Name() == "get_weather" { location := "" if loc, ok := args["location"].(string); ok { @@ -411,7 +411,7 @@ func beforeToolSecurityCallback(ctx tool.Context, t tool.Tool, args map[string]a return nil, nil // Continue execution } -func beforeToolValidationCallback(ctx tool.Context, t tool.Tool, args map[string]any) (map[string]any, error) { +func beforeToolValidationCallback(ctx agent.ToolContext, t tool.Tool, args map[string]any) (map[string]any, error) { if t.Name() == "calculate" { operation, _ := args["operation"].(string) y, yOK := args["y"].(float64) @@ -430,7 +430,7 @@ func beforeToolValidationCallback(ctx tool.Context, t tool.Tool, args map[string // --- After Tool Callbacks --- -func afterToolEnhancementCallback(ctx tool.Context, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) { +func afterToolEnhancementCallback(ctx agent.ToolContext, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err != nil { return result, err // Don't enhance if there was an error } @@ -444,7 +444,7 @@ func afterToolEnhancementCallback(ctx tool.Context, t tool.Tool, args, result ma return enhancedResponse, nil } -func afterToolAsyncCallback(ctx tool.Context, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) { +func afterToolAsyncCallback(ctx agent.ToolContext, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err != nil { return result, err } @@ -653,7 +653,7 @@ func TestToolCallbacksAgent(t *testing.T) { type mockToolset struct{} -func (m *mockToolset) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (m *mockToolset) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { utils.AppendInstructions(req, "Extra instruction from mockToolset") return nil } diff --git a/agent/remoteagent/v2/a2a_e2e_test.go b/agent/remoteagent/v2/a2a_e2e_test.go index 7702b3bad..7421a23e5 100644 --- a/agent/remoteagent/v2/a2a_e2e_test.go +++ b/agent/remoteagent/v2/a2a_e2e_test.go @@ -886,7 +886,7 @@ func newLongRunningTool(t *testing.T) tool.Tool { Name: approvalToolName, Description: "Request approval before proceeding.", IsLongRunning: true, - }, func(ctx tool.Context, x map[string]any) (approval, error) { + }, func(ctx agent.ToolContext, x map[string]any) (approval, error) { return approval{Status: approvalStatusPending, TicketID: a2a.NewContextID()}, nil }) if err != nil { @@ -901,7 +901,7 @@ func newToolConfirmation(t *testing.T) tool.Tool { requestApproval, err := functiontool.New(functiontool.Config{ Name: approvalToolName, Description: "Request approval before proceeding.", - }, func(ctx tool.Context, x map[string]any) (approval, error) { + }, func(ctx agent.ToolContext, x map[string]any) (approval, error) { confirmation := ctx.ToolConfirmation() if confirmation == nil { ticketID := a2a.NewContextID() diff --git a/agent/workflowagents/loopagent/agent_test.go b/agent/workflowagents/loopagent/agent_test.go index 690b12418..d21f361da 100644 --- a/agent/workflowagents/loopagent/agent_test.go +++ b/agent/workflowagents/loopagent/agent_test.go @@ -306,13 +306,13 @@ func (a *customAgent) Run(agent.InvocationContext) iter.Seq2[*session.Event, err type EmptyArgs struct{} -func exampleFunctionThatEscalates(ctx tool.Context, myArgs EmptyArgs) (map[string]string, error) { +func exampleFunctionThatEscalates(ctx agent.ToolContext, myArgs EmptyArgs) (map[string]string, error) { ctx.Actions().Escalate = true ctx.Actions().SkipSummarization = false return map[string]string{}, nil } -func exampleFunctionThatEscalatesAndSkips(ctx tool.Context, myArgs EmptyArgs) (map[string]string, error) { +func exampleFunctionThatEscalatesAndSkips(ctx agent.ToolContext, myArgs EmptyArgs) (map[string]string, error) { ctx.Actions().Escalate = true ctx.Actions().SkipSummarization = true return map[string]string{}, nil diff --git a/agent/workflowagents/parallelagent/agent_test.go b/agent/workflowagents/parallelagent/agent_test.go index bda8f709d..1617ad2e0 100644 --- a/agent/workflowagents/parallelagent/agent_test.go +++ b/agent/workflowagents/parallelagent/agent_test.go @@ -298,7 +298,7 @@ func createAgentWithGemini(t *testing.T, name string) agent.Agent { Name: fmt.Sprintf("search_tool_%s", name), Description: "Search for information on the web", }, - func(ctx tool.Context, args struct{ Query string }) (string, error) { + func(ctx agent.ToolContext, args struct{ Query string }) (string, error) { return fmt.Sprintf("search result for '%s' from %s", args.Query, name), nil }, ) @@ -311,7 +311,7 @@ func createAgentWithGemini(t *testing.T, name string) agent.Agent { Name: fmt.Sprintf("analyze_tool_%s", name), Description: "Analyze data and return insights", }, - func(ctx tool.Context, args struct{ Data string }) (string, error) { + func(ctx agent.ToolContext, args struct{ Data string }) (string, error) { return fmt.Sprintf("analysis result for '%s' from %s", args.Data, name), nil }, ) diff --git a/agent/workflowagents/sequentialagent/agent.go b/agent/workflowagents/sequentialagent/agent.go index 008d1a9b2..0c648175e 100644 --- a/agent/workflowagents/sequentialagent/agent.go +++ b/agent/workflowagents/sequentialagent/agent.go @@ -25,7 +25,6 @@ import ( agentinternal "google.golang.org/adk/internal/agent" "google.golang.org/adk/internal/llminternal" "google.golang.org/adk/session" - "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" ) @@ -138,7 +137,7 @@ func (a *sequentialAgent) RunLive(ctx agent.InvocationContext) (agent.LiveSessio taskCompletedTool, err := functiontool.New(functiontool.Config{ Name: "task_completed", Description: "Signals that the agent has successfully completed the user's question or task.", - }, func(ctx tool.Context, args taskCompletedArgs) (taskCompletedResults, error) { + }, func(ctx agent.ToolContext, args taskCompletedArgs) (taskCompletedResults, error) { return taskCompletedResults{Result: "Task completion signaled."}, nil }) if err != nil { diff --git a/examples/agentengine/main.go b/examples/agentengine/main.go index a9446b4f9..9f56ef889 100644 --- a/examples/agentengine/main.go +++ b/examples/agentengine/main.go @@ -53,8 +53,8 @@ const ( ) // memorySearchToolFunc is the implementation of the memory search tool. -// This function demonstrates accessing memory via tool.Context. -func memorySearchToolFunc(tctx tool.Context, args Args) (Result, error) { +// This function demonstrates accessing memory via agent.ToolContext. +func memorySearchToolFunc(tctx agent.ToolContext, args Args) (Result, error) { // The SearchMemory function is available on the context. searchResults, err := tctx.SearchMemory(tctx, args.Query) if err != nil { @@ -98,7 +98,7 @@ func main() { type Output struct { Result int `json:"result"` } - handler := func(ctx tool.Context, input Input) (Output, error) { + handler := func(ctx agent.ToolContext, input Input) (Output, error) { return Output{ Result: input.Min + rand.IntN(input.Max-input.Min+1), }, nil diff --git a/examples/bidi/main.go b/examples/bidi/main.go index 9fcef6418..68b6f8545 100644 --- a/examples/bidi/main.go +++ b/examples/bidi/main.go @@ -56,7 +56,7 @@ func main() { cameraTool, err := functiontool.New(functiontool.Config{ Name: "camera_toggle", Description: "Turns the camera on or off.", - }, func(ctx tool.Context, args EmptyArgs) (MessageResult, error) { + }, func(ctx agent.ToolContext, args EmptyArgs) (MessageResult, error) { fmt.Println("Camera tool was called!") return MessageResult{Message: "Camera tool called successfully!"}, nil }) diff --git a/examples/bidi/streamingtool/main.go b/examples/bidi/streamingtool/main.go index 6f0300de7..93d029f18 100644 --- a/examples/bidi/streamingtool/main.go +++ b/examples/bidi/streamingtool/main.go @@ -53,7 +53,7 @@ func main() { counterTool, err := functiontool.NewStreaming(functiontool.Config{ Name: "count_to", Description: "Counts to a specified number, yielding each number with a delay.", - }, func(ctx tool.Context, args struct { + }, func(ctx agent.ToolContext, args struct { N int `json:"n"` }, ) iter.Seq2[string, error] { @@ -78,7 +78,7 @@ func main() { stopTool, err := functiontool.New(functiontool.Config{ Name: "stop_streaming", Description: "Stops a running streaming function.", - }, func(ctx tool.Context, args struct { + }, func(ctx agent.ToolContext, args struct { FunctionName string `json:"function_name"` }, ) (map[string]any, error) { @@ -92,7 +92,7 @@ func main() { checkDivisibleTool, err := functiontool.New(functiontool.Config{ Name: "check_divisible", Description: "Checks if a number is divisible by another number.", - }, func(ctx tool.Context, args struct { + }, func(ctx agent.ToolContext, args struct { Number int `json:"number"` Divisor int `json:"divisor"` }, diff --git a/examples/toolconfirmation/main.go b/examples/toolconfirmation/main.go index fc3a0333f..c1c498210 100644 --- a/examples/toolconfirmation/main.go +++ b/examples/toolconfirmation/main.go @@ -126,7 +126,7 @@ func main() { } // requestVacationDays simulates the *initiation* of a long-running ticket creation task. -func requestVacationDays(ctx tool.Context, args RequestVacationArgs) (*RequestVacationResults, error) { +func requestVacationDays(ctx agent.ToolContext, args RequestVacationArgs) (*RequestVacationResults, error) { log.Printf("TOOL_EXEC: 'requestVacationDays' called with days: %d for user %s (Call ID: %s)\n", args.Days, args.UserID, ctx.FunctionCallID()) if args.Days <= 0 { diff --git a/examples/tools/multipletools/main.go b/examples/tools/multipletools/main.go index ef318bdb0..406e4b578 100644 --- a/examples/tools/multipletools/main.go +++ b/examples/tools/multipletools/main.go @@ -68,7 +68,7 @@ func main() { type Output struct { Poem string `json:"poem"` } - handler := func(ctx tool.Context, input Input) (Output, error) { + handler := func(ctx agent.ToolContext, input Input) (Output, error) { return Output{ Poem: strings.Repeat("A line of a poem,", input.LineCount) + "\n", }, nil diff --git a/examples/vertexai/imagegenerator/main.go b/examples/vertexai/imagegenerator/main.go index 13d45b20c..ec8e4bfb7 100644 --- a/examples/vertexai/imagegenerator/main.go +++ b/examples/vertexai/imagegenerator/main.go @@ -91,7 +91,7 @@ func main() { } // This is a function tool to generate images using Vertex AI's Imagen model. -func generateImage(ctx tool.Context, input generateImageInput) (generateImageResult, error) { +func generateImage(ctx agent.ToolContext, input generateImageInput) (generateImageResult, error) { client, err := genai.NewClient(ctx, &genai.ClientConfig{ Project: os.Getenv("GOOGLE_CLOUD_PROJECT"), Location: os.Getenv("GOOGLE_CLOUD_LOCATION"), @@ -129,7 +129,7 @@ type generateImageResult struct { // This is function tool that loads image from the artifacts service and // saves is to the local filesystem. -func saveImage(ctx tool.Context, input saveImageInput) (saveImageResult, error) { +func saveImage(ctx agent.ToolContext, input saveImageInput) (saveImageResult, error) { filename := input.Filename resp, err := ctx.Artifacts().Load(ctx, filename) if err != nil { diff --git a/examples/web/agents/image_generator.go b/examples/web/agents/image_generator.go index a325cdd6e..ffb1d76ac 100644 --- a/examples/web/agents/image_generator.go +++ b/examples/web/agents/image_generator.go @@ -30,7 +30,7 @@ import ( "google.golang.org/adk/tool/loadartifactstool" ) -func generateImage(ctx tool.Context, input generateImageInput) (generateImageResult, error) { +func generateImage(ctx agent.ToolContext, input generateImageInput) (generateImageResult, error) { client, err := genai.NewClient(ctx, &genai.ClientConfig{ Project: os.Getenv("GOOGLE_CLOUD_PROJECT"), Location: os.Getenv("GOOGLE_CLOUD_LOCATION"), diff --git a/internal/configurable/conformance/functions.go b/internal/configurable/conformance/functions.go index 1b4c663f4..22445a57e 100644 --- a/internal/configurable/conformance/functions.go +++ b/internal/configurable/conformance/functions.go @@ -21,6 +21,7 @@ import ( "math" "regexp" + "google.golang.org/adk/agent" "google.golang.org/adk/internal/configurable" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" @@ -32,11 +33,11 @@ type ValidateEmailArgs struct { var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) -func validateEmail(ctx tool.Context, args ValidateEmailArgs) (bool, error) { +func validateEmail(ctx agent.ToolContext, args ValidateEmailArgs) (bool, error) { return emailRegex.MatchString(args.Email), nil } -func getUserID(ctx tool.Context, args ValidateEmailArgs) (int, error) { +func getUserID(ctx agent.ToolContext, args ValidateEmailArgs) (int, error) { valid, err := validateEmail(ctx, args) if err != nil { return 0, err @@ -64,7 +65,7 @@ type CreateBookingArgs struct { Details string `json:"details"` } -func createBooking(ctx tool.Context, args CreateBookingArgs) (map[string]any, error) { +func createBooking(ctx agent.ToolContext, args CreateBookingArgs) (map[string]any, error) { return map[string]any{ "user_id": args.UserID, "is_confirmed": args.IsConfirmed, @@ -94,7 +95,7 @@ type SearchFlightsArgs struct { Preferences *FlightPreferences `json:"preferences"` } -func searchFlights(ctx tool.Context, args SearchFlightsArgs) (map[string]any, error) { +func searchFlights(ctx agent.ToolContext, args SearchFlightsArgs) (map[string]any, error) { if args.Preferences == nil { args.Preferences = &FlightPreferences{ CabinClass: "Economy", @@ -152,7 +153,7 @@ type CalculateTripCostArgs struct { BaggageCount *int `json:"baggage_count"` } -func calculateTripCost(ctx tool.Context, args CalculateTripCostArgs) (map[string]any, error) { +func calculateTripCost(ctx agent.ToolContext, args CalculateTripCostArgs) (map[string]any, error) { // Handle Python's default num_passengers=1 logic // In Go, if the caller passes 0, we should ensure at least 1 // or handle it based on your specific business logic. @@ -200,7 +201,7 @@ type reimburseArgs struct { Amount float64 `json:"amount"` } -func reimburse(ctx tool.Context, args reimburseArgs) (map[string]any, error) { +func reimburse(ctx agent.ToolContext, args reimburseArgs) (map[string]any, error) { return map[string]any{ "status": "ok", }, nil @@ -211,7 +212,7 @@ type askForApprovalArgs struct { Amount float64 `json:"amount"` } -func askForApproval(ctx tool.Context, args askForApprovalArgs) (map[string]any, error) { +func askForApproval(ctx agent.ToolContext, args askForApprovalArgs) (map[string]any, error) { return map[string]any{ "status": "pending", "amount": args.Amount, diff --git a/internal/configurable/conformance/recordplugin/record_plugin.go b/internal/configurable/conformance/recordplugin/record_plugin.go index 054ebd054..ee73d4697 100644 --- a/internal/configurable/conformance/recordplugin/record_plugin.go +++ b/internal/configurable/conformance/recordplugin/record_plugin.go @@ -126,7 +126,7 @@ func (p *recordPlugin) afterModel(ctx agent.CallbackContext, resp *model.LLMResp return nil, nil } -func (p *recordPlugin) beforeTool(ctx tool.Context, t tool.Tool, args map[string]any) (map[string]any, error) { +func (p *recordPlugin) beforeTool(ctx agent.ToolContext, t tool.Tool, args map[string]any) (map[string]any, error) { on, err := p.isRecordModeOn(ctx.State()) if err != nil || !on { return nil, nil @@ -147,7 +147,7 @@ func (p *recordPlugin) beforeTool(ctx tool.Context, t tool.Tool, args map[string return nil, nil } -func (p *recordPlugin) afterTool(ctx tool.Context, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) { +func (p *recordPlugin) afterTool(ctx agent.ToolContext, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) { on, recordErr := p.isRecordModeOn(ctx.State()) if recordErr != nil || !on { return nil, nil diff --git a/internal/configurable/conformance/replayplugin/replay_plugin.go b/internal/configurable/conformance/replayplugin/replay_plugin.go index db67ba269..4efeb1f52 100644 --- a/internal/configurable/conformance/replayplugin/replay_plugin.go +++ b/internal/configurable/conformance/replayplugin/replay_plugin.go @@ -137,7 +137,7 @@ func (p *replayPlugin) beforeModel(ctx agent.CallbackContext, req *model.LLMRequ } // beforeTool intercepts tool calls, verifies them against the recording, and returns the recorded response. -func (p *replayPlugin) beforeTool(ctx tool.Context, t tool.Tool, args map[string]any) (map[string]any, error) { +func (p *replayPlugin) beforeTool(ctx agent.ToolContext, t tool.Tool, args map[string]any) (map[string]any, error) { on, err := p.isReplayModeOn(ctx.State()) if err != nil { return nil, err diff --git a/internal/llminternal/agent_transfer.go b/internal/llminternal/agent_transfer.go index b44247f0b..a58449d3b 100644 --- a/internal/llminternal/agent_transfer.go +++ b/internal/llminternal/agent_transfer.go @@ -159,12 +159,12 @@ func (t *TransferToAgentTool) enums() []string { } // ProcessRequest implements types.Tool. -func (t *TransferToAgentTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (t *TransferToAgentTool) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { return appendTools(req, t) } // Run implements types.Tool. -func (t *TransferToAgentTool) Run(ctx tool.Context, args any) (map[string]any, error) { +func (t *TransferToAgentTool) Run(ctx agent.ToolContext, args any) (map[string]any, error) { if args == nil { return nil, fmt.Errorf("missing argument") } diff --git a/internal/llminternal/agent_transfer_test.go b/internal/llminternal/agent_transfer_test.go index f8b473c4b..67ae41099 100644 --- a/internal/llminternal/agent_transfer_test.go +++ b/internal/llminternal/agent_transfer_test.go @@ -435,7 +435,7 @@ func TestAgentTransfer_ProcessRequest(t *testing.T) { x int } var req model.LLMRequest - handler := func(ctx tool.Context, input Input) (int, error) { + handler := func(ctx agent.ToolContext, input Input) (int, error) { return input.x, nil } identityTool, err := functiontool.New(functiontool.Config{ diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index a0ef15fce..e49bebb3f 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -53,11 +53,11 @@ type AfterModelCallback func(ctx agent.CallbackContext, llmResponse *model.LLMRe type OnModelErrorCallback func(ctx agent.CallbackContext, llmRequest *model.LLMRequest, llmResponseError error) (*model.LLMResponse, error) -type BeforeToolCallback func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) +type BeforeToolCallback func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) -type AfterToolCallback func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) +type AfterToolCallback func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) -type OnToolErrorCallback func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) +type OnToolErrorCallback func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) type Flow struct { Model model.LLM @@ -985,7 +985,7 @@ Suggested fixes: } type cancelledToolContext struct { - tool.Context + agent.ToolContext cancelCtx context.Context } @@ -1068,8 +1068,8 @@ func (f *Flow) handleFunctionCalls(ctx agent.InvocationContext, toolsDict map[st result = map[string]any{"status": "The function is running asynchronously and the results are pending."} cancelCtx, cancel := context.WithCancel(toolCtx) cancelToolCtx := &cancelledToolContext{ - Context: toolCtx, - cancelCtx: cancelCtx, + ToolContext: toolCtx, + cancelCtx: cancelCtx, } if impl, ok := liveSess.(*liveSessionImpl); ok { impl.RegisterStreamingTool(streamTool.Name(), fnCall.ID, cancel) @@ -1179,7 +1179,7 @@ func (f *Flow) handleFunctionCalls(ctx agent.InvocationContext, toolsDict map[st return mergedEvent, nil } -func (f *Flow) runOnToolErrorCallbacks(toolCtx tool.Context, tool tool.Tool, fArgs map[string]any, err error) (map[string]any, error) { +func (f *Flow) runOnToolErrorCallbacks(toolCtx agent.ToolContext, tool tool.Tool, fArgs map[string]any, err error) (map[string]any, error) { pluginManager := pluginManagerFromContext(toolCtx) if pluginManager != nil { result, err := pluginManager.RunOnToolErrorCallback(toolCtx, tool, fArgs, err) @@ -1190,7 +1190,7 @@ func (f *Flow) runOnToolErrorCallbacks(toolCtx tool.Context, tool tool.Tool, fAr return f.invokeOnToolErrorCallbacks(toolCtx, tool, fArgs, err) } -func (f *Flow) callTool(toolCtx tool.Context, tool toolinternal.FunctionTool, fArgs map[string]any) map[string]any { +func (f *Flow) callTool(toolCtx agent.ToolContext, tool toolinternal.FunctionTool, fArgs map[string]any) map[string]any { var response map[string]any var err error pluginManager := pluginManagerFromContext(toolCtx) @@ -1237,7 +1237,7 @@ func (f *Flow) callTool(toolCtx tool.Context, tool toolinternal.FunctionTool, fA return response } -func (f *Flow) invokeBeforeToolCallbacks(toolCtx tool.Context, tool tool.Tool, fArgs map[string]any) (map[string]any, error) { +func (f *Flow) invokeBeforeToolCallbacks(toolCtx agent.ToolContext, tool tool.Tool, fArgs map[string]any) (map[string]any, error) { for _, callback := range f.BeforeToolCallbacks { result, err := callback(toolCtx, tool, fArgs) if err != nil { @@ -1252,7 +1252,7 @@ func (f *Flow) invokeBeforeToolCallbacks(toolCtx tool.Context, tool tool.Tool, f return nil, nil } -func (f *Flow) invokeAfterToolCallbacks(toolCtx tool.Context, tool toolinternal.FunctionTool, fArgs, fResult map[string]any, fErr error) (map[string]any, error) { +func (f *Flow) invokeAfterToolCallbacks(toolCtx agent.ToolContext, tool toolinternal.FunctionTool, fArgs, fResult map[string]any, fErr error) (map[string]any, error) { for _, callback := range f.AfterToolCallbacks { result, err := callback(toolCtx, tool, fArgs, fResult, fErr) if err != nil { @@ -1268,7 +1268,7 @@ func (f *Flow) invokeAfterToolCallbacks(toolCtx tool.Context, tool toolinternal. return fResult, fErr } -func (f *Flow) invokeOnToolErrorCallbacks(toolCtx tool.Context, tool tool.Tool, fArgs map[string]any, fErr error) (map[string]any, error) { +func (f *Flow) invokeOnToolErrorCallbacks(toolCtx agent.ToolContext, tool tool.Tool, fArgs map[string]any, fErr error) (map[string]any, error) { for _, callback := range f.OnToolErrorCallbacks { result, err := callback(toolCtx, tool, fArgs, fErr) if err != nil { @@ -1370,7 +1370,7 @@ type pluginManager interface { RunBeforeModelCallback(cctx agent.CallbackContext, llmRequest *model.LLMRequest) (*model.LLMResponse, error) RunAfterModelCallback(cctx agent.CallbackContext, llmResponse *model.LLMResponse, llmResponseError error) (*model.LLMResponse, error) RunOnModelErrorCallback(ctx agent.CallbackContext, llmRequest *model.LLMRequest, llmResponseError error) (*model.LLMResponse, error) - RunBeforeToolCallback(ctx tool.Context, t tool.Tool, args map[string]any) (map[string]any, error) - RunAfterToolCallback(ctx tool.Context, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) - RunOnToolErrorCallback(ctx tool.Context, t tool.Tool, args map[string]any, err error) (map[string]any, error) + RunBeforeToolCallback(ctx agent.ToolContext, t tool.Tool, args map[string]any) (map[string]any, error) + RunAfterToolCallback(ctx agent.ToolContext, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) + RunOnToolErrorCallback(ctx agent.ToolContext, t tool.Tool, args map[string]any, err error) (map[string]any, error) } diff --git a/internal/llminternal/base_flow_test.go b/internal/llminternal/base_flow_test.go index 27f06a6a9..6d18e43d8 100644 --- a/internal/llminternal/base_flow_test.go +++ b/internal/llminternal/base_flow_test.go @@ -31,7 +31,7 @@ import ( type mockFunctionTool struct { name string - runFunc func(tool.Context, map[string]any) (map[string]any, error) + runFunc func(agent.ToolContext, map[string]any) (map[string]any, error) } func (m *mockFunctionTool) Name() string { @@ -54,11 +54,11 @@ func (m *mockFunctionTool) IsLongRunning() bool { return false } -func (m *mockFunctionTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (m *mockFunctionTool) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { return nil } -func (m *mockFunctionTool) Run(ctx tool.Context, args any) (map[string]any, error) { +func (m *mockFunctionTool) Run(ctx agent.ToolContext, args any) (map[string]any, error) { if m.runFunc != nil { return m.runFunc(ctx, args.(map[string]any)) } @@ -80,10 +80,10 @@ func (m *mockToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { type mockRequestProcessorToolset struct { name string - process func(ctx tool.Context, req *model.LLMRequest) error + process func(ctx agent.ToolContext, req *model.LLMRequest) error } -func (m *mockRequestProcessorToolset) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (m *mockRequestProcessorToolset) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { if m.process != nil { return m.process(ctx, req) } @@ -110,7 +110,7 @@ func TestCallTool(t *testing.T) { name: "tool runs successfully", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return map[string]any{"result": "success"}, nil }, }, @@ -121,7 +121,7 @@ func TestCallTool(t *testing.T) { name: "tool error", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return nil, errors.New("tool error") }, }, @@ -132,16 +132,16 @@ func TestCallTool(t *testing.T) { name: "before callback returns result", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, }, beforeToolCallbacks: []BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return map[string]any{"result": "intercepted"}, nil }, - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return map[string]any{"result": "2nd callback should not be called"}, nil }, }, @@ -151,16 +151,16 @@ func TestCallTool(t *testing.T) { name: "before callback returns error", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, }, beforeToolCallbacks: []BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("before callback error") }, - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("unexpected error") }, }, @@ -170,12 +170,12 @@ func TestCallTool(t *testing.T) { name: "after callback modifies result", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return map[string]any{"result": "original"}, nil }, }, afterToolCallbacks: []AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return map[string]any{"result": "modified"}, nil }, }, @@ -185,18 +185,18 @@ func TestCallTool(t *testing.T) { name: "after callback handles error", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return nil, errors.New("tool error") }, }, afterToolCallbacks: []AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err != nil { return map[string]any{"result": "error handled"}, nil } return nil, nil }, - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return map[string]any{"result": "unexpected output"}, nil }, }, @@ -206,15 +206,15 @@ func TestCallTool(t *testing.T) { name: "after callback returns error", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return map[string]any{"result": "success"}, nil }, }, afterToolCallbacks: []AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return nil, errors.New("after callback error") }, - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return nil, errors.New("unexpected error") }, }, @@ -224,17 +224,17 @@ func TestCallTool(t *testing.T) { name: "no-op callbacks return func results", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return map[string]any{"result": "success"}, nil }, }, beforeToolCallbacks: []BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, nil }, }, afterToolCallbacks: []AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return nil, nil }, }, @@ -244,18 +244,18 @@ func TestCallTool(t *testing.T) { name: "before callback result passed to after callback", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, }, beforeToolCallbacks: []BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return map[string]any{"result": "from_before"}, nil }, }, afterToolCallbacks: []AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if val, ok := result["result"]; !ok || val != "from_before" { return nil, errors.New("unexpected result in after callback") } @@ -268,18 +268,18 @@ func TestCallTool(t *testing.T) { name: "before callback error passed to after callback", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, }, beforeToolCallbacks: []BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("error_from_before") }, }, afterToolCallbacks: []AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_before" { return nil, errors.New("unexpected error in after callback") } @@ -292,18 +292,18 @@ func TestCallTool(t *testing.T) { name: "before callback error passed to on tool error callback", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, }, beforeToolCallbacks: []BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("error_from_before") }, }, onToolErrorCallbacks: []OnToolErrorCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_before" { t.Error("unexpected error in on tool error callback") return nil, errors.New("unexpected error in on tool error callback") @@ -317,18 +317,18 @@ func TestCallTool(t *testing.T) { name: "before callback error passed to on tool error callback and after tool called", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, }, beforeToolCallbacks: []BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("error_from_before") }, }, onToolErrorCallbacks: []OnToolErrorCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_before" { t.Error("unexpected error in on tool error callback") return nil, errors.New("unexpected error in on tool error callback") @@ -337,7 +337,7 @@ func TestCallTool(t *testing.T) { }, }, afterToolCallbacks: []AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err != nil { return nil, errors.New("unexpected error in after callback") } @@ -350,18 +350,18 @@ func TestCallTool(t *testing.T) { name: "before callback error passed to on tool error callback and passed to after tool called", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, }, beforeToolCallbacks: []BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("error_from_before") }, }, onToolErrorCallbacks: []OnToolErrorCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_before" { t.Error("unexpected error in on tool error callback") return nil, errors.New("unexpected error in on tool error callback") @@ -370,7 +370,7 @@ func TestCallTool(t *testing.T) { }, }, afterToolCallbacks: []AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_on_tool_error" { return nil, errors.New("unexpected error in after callback") } @@ -383,18 +383,18 @@ func TestCallTool(t *testing.T) { name: "before callback error passed to on tool error callback and passed to after tool called and handled", tool: &mockFunctionTool{ name: "testTool", - runFunc: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + runFunc: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, }, beforeToolCallbacks: []BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("error_from_before") }, }, onToolErrorCallbacks: []OnToolErrorCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_before" { t.Error("unexpected error in on tool error callback") return nil, errors.New("unexpected error in on tool error callback") @@ -403,7 +403,7 @@ func TestCallTool(t *testing.T) { }, }, afterToolCallbacks: []AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_on_tool_error" { return nil, errors.New("unexpected error in after callback") } @@ -630,7 +630,7 @@ func TestPreprocess_Toolset(t *testing.T) { s: &State{ Toolsets: []tool.Toolset{&mockRequestProcessorToolset{ name: "toolset", - process: func(_ tool.Context, _ *model.LLMRequest) error { + process: func(_ agent.ToolContext, _ *model.LLMRequest) error { return errors.New("process error") }, }}, @@ -646,7 +646,7 @@ func TestPreprocess_Toolset(t *testing.T) { &mockToolset{name: "toolset_without_processor"}, &mockRequestProcessorToolset{ name: "toolset_with_processor", - process: func(_ tool.Context, req *model.LLMRequest) error { + process: func(_ agent.ToolContext, req *model.LLMRequest) error { req.Model = "modified-model" return nil }, diff --git a/internal/llminternal/functions.go b/internal/llminternal/functions.go index 7034fb5c7..e9f6b1ecc 100644 --- a/internal/llminternal/functions.go +++ b/internal/llminternal/functions.go @@ -26,7 +26,7 @@ import ( // generateRequestConfirmationEvent creates a new Event containing // adk_request_confirmation function calls based on the requested confirmations. -// NOTE: The trigger for this in ADK Go is usually a tool.Context.RequestConfirmation call, +// NOTE: The trigger for this in ADK Go is usually a agent.ToolContext.RequestConfirmation call, // not parsing a function_response_event like in the Python example. // This function assumes you have a list of confirmations to process. func generateRequestConfirmationEvent( diff --git a/internal/llminternal/handle_function_calls_async_test.go b/internal/llminternal/handle_function_calls_async_test.go index 6137ac89a..5ab985e0b 100644 --- a/internal/llminternal/handle_function_calls_async_test.go +++ b/internal/llminternal/handle_function_calls_async_test.go @@ -38,7 +38,7 @@ type SleepResult struct { Success bool `json:"success"` } -func sleepFunc(ctx tool.Context, input SleepArgs) (SleepResult, error) { +func sleepFunc(ctx agent.ToolContext, input SleepArgs) (SleepResult, error) { time.Sleep(time.Duration(input.DurationMS) * time.Millisecond) return SleepResult{Success: true}, nil } diff --git a/internal/llminternal/outputschema_processor.go b/internal/llminternal/outputschema_processor.go index e64847532..5f73b48eb 100644 --- a/internal/llminternal/outputschema_processor.go +++ b/internal/llminternal/outputschema_processor.go @@ -27,7 +27,6 @@ import ( "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" "google.golang.org/adk/session" - "google.golang.org/adk/tool" ) const ( @@ -129,7 +128,7 @@ func (t *setModelResponseTool) Declaration() *genai.FunctionDeclaration { } } -func (t *setModelResponseTool) Run(ctx tool.Context, args any) (map[string]any, error) { +func (t *setModelResponseTool) Run(ctx agent.ToolContext, args any) (map[string]any, error) { m, ok := args.(map[string]any) if !ok { return nil, fmt.Errorf("unexpected args type for set_model_response: %T", args) diff --git a/internal/llminternal/outputschema_processor_test.go b/internal/llminternal/outputschema_processor_test.go index 6970fe784..aeb97e665 100644 --- a/internal/llminternal/outputschema_processor_test.go +++ b/internal/llminternal/outputschema_processor_test.go @@ -41,7 +41,7 @@ func (m *mockTool) IsLongRunning() bool { return false } func (m *mockTool) Declaration() *genai.FunctionDeclaration { return &genai.FunctionDeclaration{Name: m.name} } -func (m *mockTool) Run(ctx tool.Context, args any) (map[string]any, error) { return nil, nil } +func (m *mockTool) Run(ctx agent.ToolContext, args any) (map[string]any, error) { return nil, nil } type mockLLM struct { model.LLM diff --git a/internal/llminternal/parallel_function_call_hitl_test.go b/internal/llminternal/parallel_function_call_hitl_test.go index 7bf438725..00fbc47c6 100644 --- a/internal/llminternal/parallel_function_call_hitl_test.go +++ b/internal/llminternal/parallel_function_call_hitl_test.go @@ -42,7 +42,7 @@ type SecureActionResult struct { Executed bool `json:"executed"` } -func secureActionFunc(ctx tool.Context, input SecureActionArgs) (SecureActionResult, error) { +func secureActionFunc(ctx agent.ToolContext, input SecureActionArgs) (SecureActionResult, error) { return SecureActionResult{Executed: true}, nil } diff --git a/internal/llminternal/parallel_function_call_test.go b/internal/llminternal/parallel_function_call_test.go index 80e1a767e..c115af3f4 100644 --- a/internal/llminternal/parallel_function_call_test.go +++ b/internal/llminternal/parallel_function_call_test.go @@ -44,7 +44,7 @@ type SumResult struct { Sum int `json:"sum"` // the sum of two integers } -func sumFunc(ctx tool.Context, input SumArgs) (SumResult, error) { +func sumFunc(ctx agent.ToolContext, input SumArgs) (SumResult, error) { return SumResult{Sum: input.A + input.B}, nil } diff --git a/internal/llminternal/request_confirmation_processor_test.go b/internal/llminternal/request_confirmation_processor_test.go index 3a881d23c..bae287ad2 100644 --- a/internal/llminternal/request_confirmation_processor_test.go +++ b/internal/llminternal/request_confirmation_processor_test.go @@ -51,7 +51,7 @@ func (m *mockTool) Declaration() *genai.FunctionDeclaration { return &genai.FunctionDeclaration{Name: m.name} } -func (m *mockTool) Run(ctx tool.Context, args any) (map[string]any, error) { +func (m *mockTool) Run(ctx agent.ToolContext, args any) (map[string]any, error) { if ctx.ToolConfirmation() == nil || !ctx.ToolConfirmation().Confirmed { return map[string]any{"error": string("Tool execution not confirmed")}, nil } diff --git a/internal/llminternal/stream_aggregator_test.go b/internal/llminternal/stream_aggregator_test.go index b71d9ec01..3889b89f0 100644 --- a/internal/llminternal/stream_aggregator_test.go +++ b/internal/llminternal/stream_aggregator_test.go @@ -629,7 +629,7 @@ type GetWeatherArgs struct { Location string `json:"location"` } -func getWeather(ctx tool.Context, args GetWeatherArgs) (map[string]any, error) { +func getWeather(ctx agent.ToolContext, args GetWeatherArgs) (map[string]any, error) { return map[string]any{ "temperature": 22, "condition": "sunny", @@ -945,7 +945,7 @@ func TestPartialFunctionCallsNotExecutedInNoneStreamingMode(t *testing.T) { CallID string `json:"call_id"` } - trackExecution := func(ctx tool.Context, args TrackExecutionArgs) (string, error) { + trackExecution := func(ctx agent.ToolContext, args TrackExecutionArgs) (string, error) { executionLog = append(executionLog, args.CallID) return "Executed: " + args.CallID, nil } diff --git a/internal/llminternal/streaming_tool_test.go b/internal/llminternal/streaming_tool_test.go index 722f6c97e..763dc7e4f 100644 --- a/internal/llminternal/streaming_tool_test.go +++ b/internal/llminternal/streaming_tool_test.go @@ -49,7 +49,7 @@ func TestHandleFunctionCalls_Streaming(t *testing.T) { Count int `json:"count"` } - handler := func(ctx tool.Context, args Args) iter.Seq2[string, error] { + handler := func(ctx agent.ToolContext, args Args) iter.Seq2[string, error] { return func(yield func(string, error) bool) { for i := 0; i < args.Count; i++ { if !yield(fmt.Sprintf("chunk %d", i), nil) { @@ -187,7 +187,7 @@ func TestHandleFunctionCalls_LiveControlPlane(t *testing.T) { var cancelCount int var cancelMu sync.Mutex - handler := func(ctx tool.Context, args Args) iter.Seq2[string, error] { + handler := func(ctx agent.ToolContext, args Args) iter.Seq2[string, error] { return func(yield func(string, error) bool) { for i := 0; ; i++ { time.Sleep(time.Millisecond) diff --git a/internal/plugininternal/plugin_manager.go b/internal/plugininternal/plugin_manager.go index 97e855647..51347acbc 100644 --- a/internal/plugininternal/plugin_manager.go +++ b/internal/plugininternal/plugin_manager.go @@ -168,7 +168,7 @@ func (pm *PluginManager) RunAfterAgentCallback(cctx agent.CallbackContext) (*gen } // RunBeforeToolCallback runs the BeforeToolCallback for all plugins. -func (pm *PluginManager) RunBeforeToolCallback(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { +func (pm *PluginManager) RunBeforeToolCallback(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { for _, plugin := range pm.plugins { callback := plugin.BeforeToolCallback() if callback != nil { @@ -185,7 +185,7 @@ func (pm *PluginManager) RunBeforeToolCallback(ctx tool.Context, tool tool.Tool, } // RunAfterToolCallback runs the AfterToolCallback for all plugins. -func (pm *PluginManager) RunAfterToolCallback(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { +func (pm *PluginManager) RunAfterToolCallback(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { for _, plugin := range pm.plugins { callback := plugin.AfterToolCallback() if callback != nil { @@ -202,7 +202,7 @@ func (pm *PluginManager) RunAfterToolCallback(ctx tool.Context, tool tool.Tool, } // RunOnToolErrorCallback runs the OnToolErrorCallback for all plugins. -func (pm *PluginManager) RunOnToolErrorCallback(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { +func (pm *PluginManager) RunOnToolErrorCallback(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { for _, plugin := range pm.plugins { callback := plugin.OnToolErrorCallback() if callback != nil { diff --git a/internal/toolinternal/tool.go b/internal/toolinternal/tool.go index 64835e742..1655f0844 100644 --- a/internal/toolinternal/tool.go +++ b/internal/toolinternal/tool.go @@ -20,6 +20,7 @@ import ( "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/model" "google.golang.org/adk/tool" ) @@ -27,15 +28,15 @@ import ( type FunctionTool interface { tool.Tool Declaration() *genai.FunctionDeclaration - Run(ctx tool.Context, args any) (result map[string]any, err error) + Run(ctx agent.ToolContext, args any) (result map[string]any, err error) } type StreamingFunctionTool interface { tool.Tool Declaration() *genai.FunctionDeclaration - RunStream(ctx tool.Context, args any) iter.Seq2[string, error] + RunStream(ctx agent.ToolContext, args any) iter.Seq2[string, error] } type RequestProcessor interface { - ProcessRequest(ctx tool.Context, req *model.LLMRequest) error + ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error } diff --git a/plugin/functioncallmodifier/plugin_test.go b/plugin/functioncallmodifier/plugin_test.go index 4b16dd998..3eca6955f 100644 --- a/plugin/functioncallmodifier/plugin_test.go +++ b/plugin/functioncallmodifier/plugin_test.go @@ -41,7 +41,7 @@ type SimpleArgs struct { Num int } -func okFunc(_ tool.Context, _ SimpleArgs) (string, error) { +func okFunc(_ agent.ToolContext, _ SimpleArgs) (string, error) { return "ok", nil } diff --git a/plugin/loggingplugin/logging_plugin.go b/plugin/loggingplugin/logging_plugin.go index 83613a747..bd9e52aad 100644 --- a/plugin/loggingplugin/logging_plugin.go +++ b/plugin/loggingplugin/logging_plugin.go @@ -279,7 +279,7 @@ func (p *loggingPlugin) onModelError(ctx agent.CallbackContext, req *model.LLMRe return nil, nil } -func (p *loggingPlugin) beforeTool(ctx tool.Context, t tool.Tool, args map[string]any) (map[string]any, error) { +func (p *loggingPlugin) beforeTool(ctx agent.ToolContext, t tool.Tool, args map[string]any) (map[string]any, error) { p.log("🔧 TOOL STARTING") p.log(fmt.Sprintf(" Tool Name: %s", t.Name())) p.log(fmt.Sprintf(" Agent: %s", ctx.AgentName())) @@ -288,7 +288,7 @@ func (p *loggingPlugin) beforeTool(ctx tool.Context, t tool.Tool, args map[strin return nil, nil } -func (p *loggingPlugin) afterTool(ctx tool.Context, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) { +func (p *loggingPlugin) afterTool(ctx agent.ToolContext, t tool.Tool, args, result map[string]any, err error) (map[string]any, error) { p.log("🔧 TOOL COMPLETED") p.log(fmt.Sprintf(" Tool Name: %s", t.Name())) p.log(fmt.Sprintf(" Agent: %s", ctx.AgentName())) @@ -301,7 +301,7 @@ func (p *loggingPlugin) afterTool(ctx tool.Context, t tool.Tool, args, result ma return nil, nil } -func (p *loggingPlugin) onToolError(ctx tool.Context, t tool.Tool, args map[string]any, err error) (map[string]any, error) { +func (p *loggingPlugin) onToolError(ctx agent.ToolContext, t tool.Tool, args map[string]any, err error) (map[string]any, error) { p.log("🔧 TOOL ERROR") p.log(fmt.Sprintf(" Tool Name: %s", t.Name())) p.log(fmt.Sprintf(" Agent: %s", ctx.AgentName())) diff --git a/plugin/plugin_manager_test.go b/plugin/plugin_manager_test.go index c081bf6f3..81c256125 100644 --- a/plugin/plugin_manager_test.go +++ b/plugin/plugin_manager_test.go @@ -36,7 +36,7 @@ import ( type testCase struct { name string - tool func(tool.Context, map[string]any) (map[string]any, error) + tool func(agent.ToolContext, map[string]any) (map[string]any, error) args map[string]any beforeToolCallbacks []llmagent.BeforeToolCallback afterToolCallbacks []llmagent.AfterToolCallback @@ -51,7 +51,7 @@ func TestCallTool(t *testing.T) { testCases := []testCase{ { name: "tool runs successfully", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return map[string]any{"result": "success"}, nil }, args: map[string]any{"key": "value"}, @@ -60,7 +60,7 @@ func TestCallTool(t *testing.T) { }, { name: "tool error", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return nil, errors.New("tool error") }, args: map[string]any{"key": "value"}, @@ -68,15 +68,15 @@ func TestCallTool(t *testing.T) { }, { name: "before callback returns result", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, beforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return map[string]any{"result": "intercepted"}, nil }, - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return map[string]any{"result": "2nd callback should not be called"}, nil }, }, @@ -86,15 +86,15 @@ func TestCallTool(t *testing.T) { }, { name: "before callback returns error", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, beforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("before callback error") }, - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("unexpected error") }, }, @@ -103,11 +103,11 @@ func TestCallTool(t *testing.T) { }, { name: "after callback modifies result", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return map[string]any{"result": "original"}, nil }, afterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return map[string]any{"result": "modified"}, nil }, }, @@ -117,17 +117,17 @@ func TestCallTool(t *testing.T) { }, { name: "after callback handles error and are run in symmetrical order", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return nil, errors.New("tool error") }, afterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err != nil { return map[string]any{"result": "error handled"}, nil } return nil, nil }, - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { t.Errorf("unexpected call to after tool callback") return map[string]any{"result": "unexpected output"}, nil }, @@ -137,14 +137,14 @@ func TestCallTool(t *testing.T) { }, { name: "after callback returns error and are run in symmetrical order", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return map[string]any{"result": "success"}, nil }, afterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return nil, errors.New("after callback error") }, - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { t.Errorf("unexpected call to after tool callback") return nil, errors.New("unexpected error") }, @@ -155,16 +155,16 @@ func TestCallTool(t *testing.T) { }, { name: "no-op callbacks return func results", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { return map[string]any{"result": "success"}, nil }, beforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, nil }, }, afterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { return nil, nil }, }, @@ -173,17 +173,17 @@ func TestCallTool(t *testing.T) { }, { name: "before callback result passed to after callback", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, beforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return map[string]any{"result": "from_before"}, nil }, }, afterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if val, ok := result["result"]; !ok || val != "from_before" { return nil, errors.New("unexpected result in after callback") } @@ -197,17 +197,17 @@ func TestCallTool(t *testing.T) { }, { name: "before callback error passed to after callback", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, beforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("error_from_before") }, }, afterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_before" { return nil, errors.New("unexpected error in after callback") } @@ -220,17 +220,17 @@ func TestCallTool(t *testing.T) { }, { name: "before callback error passed to on tool error callback", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, beforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("error_from_before") }, }, onToolErrorCallbacks: []llmagent.OnToolErrorCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_before" { return nil, errors.New("unexpected error in on tool error callback") } @@ -243,17 +243,17 @@ func TestCallTool(t *testing.T) { }, { name: "before callback error passed to on tool error callback and after tool called", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, beforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("error_from_before") }, }, onToolErrorCallbacks: []llmagent.OnToolErrorCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_before" { return nil, errors.New("unexpected error in on tool error callback") } @@ -261,7 +261,7 @@ func TestCallTool(t *testing.T) { }, }, afterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err != nil { return nil, errors.New("unexpected error in after callback") } @@ -275,17 +275,17 @@ func TestCallTool(t *testing.T) { }, { name: "before callback error passed to on tool error callback and passed to after tool called", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, beforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("error_from_before") }, }, onToolErrorCallbacks: []llmagent.OnToolErrorCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_before" { return nil, errors.New("unexpected error in on tool error callback") } @@ -293,7 +293,7 @@ func TestCallTool(t *testing.T) { }, }, afterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_on_tool_error" { return nil, errors.New("unexpected error in after callback") } @@ -307,17 +307,17 @@ func TestCallTool(t *testing.T) { }, { name: "before callback error passed to on tool error callback and passed to after tool called and handled", - tool: func(ctx tool.Context, args map[string]any) (map[string]any, error) { + tool: func(ctx agent.ToolContext, args map[string]any) (map[string]any, error) { t.Error("tool should not be called") return nil, nil }, beforeToolCallbacks: []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { return nil, errors.New("error_from_before") }, }, onToolErrorCallbacks: []llmagent.OnToolErrorCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_before" { return nil, errors.New("unexpected error in on tool error callback") } @@ -325,7 +325,7 @@ func TestCallTool(t *testing.T) { }, }, afterToolCallbacks: []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err == nil || err.Error() != "error_from_on_tool_error" { return nil, errors.New("unexpected error in after tool callback") } @@ -386,7 +386,7 @@ func TestCallTool(t *testing.T) { beforeToolCallbacksCalled := false afterToolCallbacksCalled := false onToolErrorCallbacks := []llmagent.OnToolErrorCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { onToolErrorCallbacksCalled = true if tc.dontRunOnErrorCanonicalCallback { t.Error("on tool error should not be called") @@ -395,7 +395,7 @@ func TestCallTool(t *testing.T) { }, } beforeToolCallbacks := []llmagent.BeforeToolCallback{ - func(ctx tool.Context, tool tool.Tool, args map[string]any) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) { beforeToolCallbacksCalled = true if tc.dontRunBeforeCanonicalCallback { t.Error("before Tool Callback should not be called") @@ -404,7 +404,7 @@ func TestCallTool(t *testing.T) { }, } afterToolCallbacks := []llmagent.AfterToolCallback{ - func(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { + func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { afterToolCallbacksCalled = true if tc.dontRunAfterCanonicalCallback { t.Error("after Tool Callback should not be called") diff --git a/plugin/retryandreflect/plugin.go b/plugin/retryandreflect/plugin.go index 2fa5e4163..67dd1bdb6 100644 --- a/plugin/retryandreflect/plugin.go +++ b/plugin/retryandreflect/plugin.go @@ -28,6 +28,7 @@ import ( "sync" "text/template" + "google.golang.org/adk/agent" "google.golang.org/adk/plugin" "google.golang.org/adk/tool" @@ -124,7 +125,7 @@ func MustNew(opts ...PluginOption) *plugin.Plugin { return p } -func (r *retryAndReflect) afterTool(ctx tool.Context, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { +func (r *retryAndReflect) afterTool(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) { if err == nil { isReflectResponse := false if rt, ok := result["response_type"].(string); ok && rt == reflectAndRetryResponseType { @@ -139,11 +140,11 @@ func (r *retryAndReflect) afterTool(ctx tool.Context, tool tool.Tool, args, resu return nil, nil } -func (r *retryAndReflect) onToolError(ctx tool.Context, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { +func (r *retryAndReflect) onToolError(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) { return r.handleToolError(ctx, tool, args, err) } -func (r *retryAndReflect) handleToolError(ctx tool.Context, failedTool tool.Tool, args map[string]any, err error) (map[string]any, error) { +func (r *retryAndReflect) handleToolError(ctx agent.ToolContext, failedTool tool.Tool, args map[string]any, err error) (map[string]any, error) { // skip if the error is tool.ErrConfirmationRequired. if errors.Is(err, tool.ErrConfirmationRequired) || errors.Is(err, tool.ErrConfirmationRejected) { return nil, nil @@ -179,14 +180,14 @@ func (r *retryAndReflect) handleToolError(ctx tool.Context, failedTool tool.Tool return r.createToolRetryExceedMsg(failedTool, args, err), nil } -func (r *retryAndReflect) scopeKey(ctx tool.Context) string { +func (r *retryAndReflect) scopeKey(ctx agent.ToolContext) string { if r.scope == Global { return globalScopeKey } return ctx.InvocationID() } -func (r *retryAndReflect) resetFailuresForTool(ctx tool.Context, toolName string) { +func (r *retryAndReflect) resetFailuresForTool(ctx agent.ToolContext, toolName string) { scopeKey := r.scopeKey(ctx) r.mu.Lock() diff --git a/plugin/retryandreflect/plugin_test.go b/plugin/retryandreflect/plugin_test.go index 8fd4ec13d..e0605af6b 100644 --- a/plugin/retryandreflect/plugin_test.go +++ b/plugin/retryandreflect/plugin_test.go @@ -19,7 +19,7 @@ import ( "strings" "testing" - "google.golang.org/adk/tool" + "google.golang.org/adk/agent" ) type mockTool struct { @@ -31,7 +31,7 @@ func (m *mockTool) Description() string { return "" } func (m *mockTool) IsLongRunning() bool { return false } type mockContext struct { - tool.Context + agent.ToolContext invocationID string } diff --git a/tool/agenttool/agent_tool.go b/tool/agenttool/agent_tool.go index b11848b0d..949a28482 100644 --- a/tool/agenttool/agent_tool.go +++ b/tool/agenttool/agent_tool.go @@ -118,7 +118,7 @@ func (t *agentTool) Declaration() *genai.FunctionDeclaration { // Run executes the wrapped agent with the provided arguments. // It creates a new session for the sub-agent, runs the agent, and returns // the final result. -func (t *agentTool) Run(toolCtx tool.Context, args any) (map[string]any, error) { +func (t *agentTool) Run(toolCtx agent.ToolContext, args any) (map[string]any, error) { margs, ok := args.(map[string]any) if !ok { return nil, fmt.Errorf("agentTool expects map[string]any arguments, got %T", args) @@ -251,6 +251,6 @@ func (t *agentTool) Run(toolCtx tool.Context, args any) (map[string]any, error) } // ProcessRequest adds the agent tool's function declaration to the LLM request. -func (t *agentTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (t *agentTool) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { return toolutils.PackTool(req, t) } diff --git a/tool/agenttool/agent_tool_test.go b/tool/agenttool/agent_tool_test.go index a17ffa061..13a8180d3 100644 --- a/tool/agenttool/agent_tool_test.go +++ b/tool/agenttool/agent_tool_test.go @@ -29,7 +29,6 @@ import ( "google.golang.org/adk/model" "google.golang.org/adk/model/gemini" "google.golang.org/adk/session" - "google.golang.org/adk/tool" "google.golang.org/adk/tool/agenttool" ) @@ -347,7 +346,7 @@ func createAgentWithModel(t *testing.T, inputSchema, outputSchema *genai.Schema, return agent } -func createToolContext(t *testing.T, testAgent agent.Agent) tool.Context { +func createToolContext(t *testing.T, testAgent agent.Agent) agent.ToolContext { t.Helper() sessionService := session.InMemoryService() diff --git a/tool/exampletool/tool.go b/tool/exampletool/tool.go index cb67382e3..4592fe6f0 100644 --- a/tool/exampletool/tool.go +++ b/tool/exampletool/tool.go @@ -21,9 +21,9 @@ import ( "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" - "google.golang.org/adk/tool" ) type Example struct { @@ -55,7 +55,7 @@ func (s exampleTool) Description() string { } // ProcessRequest adds the exampleTool examples to the LLM request. -func (s exampleTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (s exampleTool) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { parts := ctx.UserContent().Parts if len(parts) == 0 || parts[0].Text == "" { return nil diff --git a/tool/exitlooptool/tool.go b/tool/exitlooptool/tool.go index bb42ad2a7..aea1d5c6e 100644 --- a/tool/exitlooptool/tool.go +++ b/tool/exitlooptool/tool.go @@ -18,11 +18,12 @@ package exitlooptool import ( "fmt" + "google.golang.org/adk/agent" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" ) -func exitLoop(ctx tool.Context, myArgs struct{}) (map[string]string, error) { +func exitLoop(ctx agent.ToolContext, myArgs struct{}) (map[string]string, error) { ctx.Actions().Escalate = true ctx.Actions().SkipSummarization = true return map[string]string{}, nil diff --git a/tool/functiontool/function.go b/tool/functiontool/function.go index e3367dd45..54e8ef055 100644 --- a/tool/functiontool/function.go +++ b/tool/functiontool/function.go @@ -24,6 +24,7 @@ import ( "github.com/google/jsonschema-go/jsonschema" "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/internal/toolinternal/toolutils" "google.golang.org/adk/internal/typeutil" "google.golang.org/adk/model" @@ -67,8 +68,8 @@ type Config struct { } // Func represents a Go function that can be wrapped in a tool. -// It takes a tool.Context and a generic argument type, and returns a generic result type. -type Func[TArgs, TResults any] func(tool.Context, TArgs) (TResults, error) +// It takes a agent.ToolContext and a generic argument type, and returns a generic result type. +type Func[TArgs, TResults any] func(agent.ToolContext, TArgs) (TResults, error) // ErrInvalidArgument indicates the input parameter type is invalid. var ErrInvalidArgument = errors.New("invalid argument") @@ -151,7 +152,7 @@ func (f *functionTool[TArgs, TResults]) IsLongRunning() bool { } // ProcessRequest packs the function tool's declaration into the LLM request. -func (f *functionTool[TArgs, TResults]) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (f *functionTool[TArgs, TResults]) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { return toolutils.PackTool(req, f) } @@ -181,7 +182,7 @@ func (f *functionTool[TArgs, TResults]) Declaration() *genai.FunctionDeclaration } // Run executes the tool with the provided context and yields events. -func (f *functionTool[TArgs, TResults]) Run(ctx tool.Context, args any) (result map[string]any, err error) { +func (f *functionTool[TArgs, TResults]) Run(ctx agent.ToolContext, args any) (result map[string]any, err error) { // TODO: Handle function call request from tc.InvocationContext. defer func() { if r := recover(); r != nil { diff --git a/tool/functiontool/function_test.go b/tool/functiontool/function_test.go index 1c9371df5..053083334 100644 --- a/tool/functiontool/function_test.go +++ b/tool/functiontool/function_test.go @@ -50,7 +50,7 @@ type SumResult struct { Sum int `json:"sum"` // the sum of two integers } -func sumFunc(ctx tool.Context, input SumArgs) (SumResult, error) { +func sumFunc(ctx agent.ToolContext, input SumArgs) (SumResult, error) { return SumResult{Sum: input.A + input.B}, nil } @@ -65,7 +65,7 @@ func ExampleNew() { _ = sumTool // use the tool } -func createToolContext(t *testing.T) tool.Context { +func createToolContext(t *testing.T) agent.ToolContext { invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) return agent.NewToolContext(invCtx, "", &session.EventActions{}, nil) } @@ -101,7 +101,7 @@ func TestFunctionTool_Simple(t *testing.T) { }, } - weatherReport := func(ctx tool.Context, input Args) (Result, error) { + weatherReport := func(ctx agent.ToolContext, input Args) (Result, error) { city := strings.ToLower(input.City) if ret, ok := resultSet[city]; ok { return ret, nil @@ -202,7 +202,7 @@ func TestFunctionTool_DifferentFunctionDeclarations_ConsolidatedInOneGenAiTool(t type IntOutput struct { Result int `json:"result"` } - identityFunc := func(ctx tool.Context, input IntInput) (IntOutput, error) { + identityFunc := func(ctx agent.ToolContext, input IntInput) (IntOutput, error) { return IntOutput{Result: input.X}, nil } identityTool, err := functiontool.New(functiontool.Config{ @@ -220,7 +220,7 @@ func TestFunctionTool_DifferentFunctionDeclarations_ConsolidatedInOneGenAiTool(t type StringOutput struct { Result string `json:"result"` } - stringIdentityFunc := func(ctx tool.Context, input StringInput) (StringOutput, error) { + stringIdentityFunc := func(ctx agent.ToolContext, input StringInput) (StringOutput, error) { return StringOutput{Result: input.Value}, nil } stringIdentityTool, err := functiontool.New( @@ -266,7 +266,7 @@ func TestFunctionTool_ReturnsBasicType(t *testing.T) { "paris": "The weather in Paris is sunny with a temperature of 25 derees Celsius.", } - weatherReport := func(ctx tool.Context, input Args) (string, error) { + weatherReport := func(ctx agent.ToolContext, input Args) (string, error) { city := strings.ToLower(input.City) if ret, ok := resultSet[city]; ok { return ret, nil @@ -356,7 +356,7 @@ func TestFunctionTool_MapInput(t *testing.T) { Name: "sum_map", Description: "sums numbers provided in a map input", }, - func(ctx tool.Context, input map[string]int) (Output, error) { + func(ctx agent.ToolContext, input map[string]int) (Output, error) { return Output{Sum: input["a"] + input["b"]}, nil }) if err != nil { @@ -441,7 +441,7 @@ func TestFunctionTool_CustomSchema(t *testing.T) { Name: "print_quantity", Description: "print the remaining quantity of the given fruit.", InputSchema: ischema, - }, func(ctx tool.Context, input Args) (any, error) { + }, func(ctx agent.ToolContext, input Args) (any, error) { fruit := strings.ToLower(input.Fruit) if fruit != "mandarin" && fruit != "kiwi" { t.Errorf("unexpected fruit: %q", fruit) @@ -553,7 +553,7 @@ type SimpleArgs struct { Num int } -func okFunc(_ tool.Context, _ SimpleArgs) (string, error) { +func okFunc(_ agent.ToolContext, _ SimpleArgs) (string, error) { return "ok", nil } @@ -927,7 +927,7 @@ type TestResult struct { func TestNew_RequireConfirmationProvider_Validation(t *testing.T) { // A dummy handler to satisfy the function signature - dummyHandler := func(_ tool.Context, _ TestArgs) (TestResult, error) { + dummyHandler := func(_ agent.ToolContext, _ TestArgs) (TestResult, error) { return TestResult{Value: 1}, nil } @@ -1038,7 +1038,7 @@ func TestNew_InvalidInputType(t *testing.T) { return functiontool.New(functiontool.Config{ Name: "string_tool", Description: "a tool with string input", - }, func(ctx tool.Context, input string) (string, error) { + }, func(ctx agent.ToolContext, input string) (string, error) { return input, nil }) }, @@ -1050,7 +1050,7 @@ func TestNew_InvalidInputType(t *testing.T) { return functiontool.New(functiontool.Config{ Name: "int_tool", Description: "a tool with int input", - }, func(ctx tool.Context, input int) (int, error) { + }, func(ctx agent.ToolContext, input int) (int, error) { return input, nil }) }, @@ -1062,7 +1062,7 @@ func TestNew_InvalidInputType(t *testing.T) { return functiontool.New(functiontool.Config{ Name: "bool_tool", Description: "a tool with bool input", - }, func(ctx tool.Context, input bool) (bool, error) { + }, func(ctx agent.ToolContext, input bool) (bool, error) { return input, nil }) }, @@ -1088,7 +1088,7 @@ func TestFunctionTool_PanicRecovery(t *testing.T) { Value string `json:"value"` } - panicHandler := func(ctx tool.Context, input Args) (string, error) { + panicHandler := func(ctx agent.ToolContext, input Args) (string, error) { panic("intentional panic for testing") } diff --git a/tool/functiontool/long_running_function_test.go b/tool/functiontool/long_running_function_test.go index 0868c7cfd..d81510801 100644 --- a/tool/functiontool/long_running_function_test.go +++ b/tool/functiontool/long_running_function_test.go @@ -23,6 +23,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" "google.golang.org/adk/internal/testutil" "google.golang.org/adk/internal/toolinternal" @@ -39,7 +40,7 @@ func TestNewLongRunningFunctionTool(t *testing.T) { Result string `json:"result"` // the operation result } - handler := func(ctx tool.Context, input SumArgs) (SumResult, error) { + handler := func(ctx agent.ToolContext, input SumArgs) (SumResult, error) { return SumResult{Result: "Processing sum"}, nil } sumTool, err := functiontool.New(functiontool.Config{ @@ -80,7 +81,7 @@ type IncArgs struct{} func TestLongRunningFunctionFlow(t *testing.T) { functionCalled := 0 - increaseByOne := func(ctx tool.Context, x IncArgs) (map[string]string, error) { + increaseByOne := func(ctx agent.ToolContext, x IncArgs) (map[string]string, error) { functionCalled++ return map[string]string{"status": "pending"}, nil } @@ -89,7 +90,7 @@ func TestLongRunningFunctionFlow(t *testing.T) { func TestLongRunningStringFunctionFlow(t *testing.T) { functionCalled := 0 - increaseByOne := func(ctx tool.Context, x IncArgs) (string, error) { + increaseByOne := func(ctx agent.ToolContext, x IncArgs) (string, error) { functionCalled++ return "pending", nil } @@ -97,7 +98,7 @@ func TestLongRunningStringFunctionFlow(t *testing.T) { } // --- Test Suite --- -func testLongRunningFunctionFlow[Out any](t *testing.T, increaseByOne func(ctx tool.Context, x IncArgs) (Out, error), resultKey string, callCount *int) { +func testLongRunningFunctionFlow[Out any](t *testing.T, increaseByOne func(ctx agent.ToolContext, x IncArgs) (Out, error), resultKey string, callCount *int) { // 1. Setup responses := []*genai.Content{ genai.NewContentFromFunctionCall("increaseByOne", map[string]any{}, "model"), @@ -266,7 +267,7 @@ func TestLongRunningToolIDsAreSet(t *testing.T) { type IncArgs struct{} - increaseByOne := func(ctx tool.Context, x IncArgs) (map[string]string, error) { + increaseByOne := func(ctx agent.ToolContext, x IncArgs) (map[string]string, error) { functionCalled++ return map[string]string{"status": "pending"}, nil } diff --git a/tool/functiontool/streaming_function.go b/tool/functiontool/streaming_function.go index 7a674f525..af3d16971 100644 --- a/tool/functiontool/streaming_function.go +++ b/tool/functiontool/streaming_function.go @@ -24,6 +24,7 @@ import ( "github.com/google/jsonschema-go/jsonschema" "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/internal/toolinternal/toolutils" "google.golang.org/adk/internal/typeutil" "google.golang.org/adk/model" @@ -31,7 +32,7 @@ import ( ) // StreamingFunc represents a Go function that streams results. -type StreamingFunc[TArgs any] func(tool.Context, TArgs) iter.Seq2[string, error] +type StreamingFunc[TArgs any] func(agent.ToolContext, TArgs) iter.Seq2[string, error] // NewStreaming creates a new streaming tool. func NewStreaming[TArgs any](cfg Config, handler StreamingFunc[TArgs]) (tool.Tool, error) { @@ -99,7 +100,7 @@ func (f *streamingFunctionTool[TArgs]) IsLongRunning() bool { } // ProcessRequest packs the function tool's declaration into the LLM request. -func (f *streamingFunctionTool[TArgs]) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (f *streamingFunctionTool[TArgs]) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { return toolutils.PackTool(req, f) } @@ -126,7 +127,7 @@ func (f *streamingFunctionTool[TArgs]) Declaration() *genai.FunctionDeclaration } // RunStream executes the tool with the provided context and yields events. -func (f *streamingFunctionTool[TArgs]) RunStream(ctx tool.Context, args any) iter.Seq2[string, error] { +func (f *streamingFunctionTool[TArgs]) RunStream(ctx agent.ToolContext, args any) iter.Seq2[string, error] { return func(yield func(string, error) bool) { defer func() { if r := recover(); r != nil { diff --git a/tool/geminitool/google_search.go b/tool/geminitool/google_search.go index 7002726fd..74bd3c3de 100644 --- a/tool/geminitool/google_search.go +++ b/tool/geminitool/google_search.go @@ -17,8 +17,8 @@ package geminitool import ( "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/model" - "google.golang.org/adk/tool" ) // GoogleSearch is a built-in tool that is automatically invoked by Gemini 2 @@ -38,7 +38,7 @@ func (s GoogleSearch) Description() string { } // ProcessRequest adds the GoogleSearch tool to the LLM request. -func (s GoogleSearch) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (s GoogleSearch) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { return setTool(req, &genai.Tool{ GoogleSearch: &genai.GoogleSearch{}, }) diff --git a/tool/geminitool/tool.go b/tool/geminitool/tool.go index 37e8199a5..117e01206 100644 --- a/tool/geminitool/tool.go +++ b/tool/geminitool/tool.go @@ -34,6 +34,7 @@ import ( "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/model" "google.golang.org/adk/tool" ) @@ -55,7 +56,7 @@ type geminiTool struct { } // ProcessRequest adds the Gemini tool to the LLM request. -func (t *geminiTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (t *geminiTool) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { return setTool(req, t.value) } diff --git a/tool/loadartifactstool/load_artifacts_tool.go b/tool/loadartifactstool/load_artifacts_tool.go index 80b034777..b912926ea 100644 --- a/tool/loadartifactstool/load_artifacts_tool.go +++ b/tool/loadartifactstool/load_artifacts_tool.go @@ -85,7 +85,7 @@ func (t *artifactsTool) Declaration() *genai.FunctionDeclaration { } // Run implements tool.Tool. -func (t *artifactsTool) Run(ctx tool.Context, args any) (map[string]any, error) { +func (t *artifactsTool) Run(ctx agent.ToolContext, args any) (map[string]any, error) { m, ok := args.(map[string]any) if !ok { return nil, fmt.Errorf("unexpected args type, got: %T", args) @@ -117,7 +117,7 @@ func (t *artifactsTool) Run(ctx tool.Context, args any) (map[string]any, error) // ProcessRequest processes the LLM request. It packs the tool, appends initial // instructions, and processes any load artifacts function calls. -func (t *artifactsTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (t *artifactsTool) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { if err := toolutils.PackTool(req, t); err != nil { return err } @@ -127,7 +127,7 @@ func (t *artifactsTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) return t.processLoadArtifactsFunctionCall(ctx, req) } -func (t *artifactsTool) appendInitialInstructions(ctx tool.Context, req *model.LLMRequest) error { +func (t *artifactsTool) appendInitialInstructions(ctx agent.ToolContext, req *model.LLMRequest) error { resp, err := ctx.Artifacts().List(ctx) if err != nil { return fmt.Errorf("failed to list artifacts: %w", err) @@ -151,7 +151,7 @@ func (t *artifactsTool) appendInitialInstructions(ctx tool.Context, req *model.L return nil } -func (t *artifactsTool) processLoadArtifactsFunctionCall(ctx tool.Context, req *model.LLMRequest) error { +func (t *artifactsTool) processLoadArtifactsFunctionCall(ctx agent.ToolContext, req *model.LLMRequest) error { if len(req.Contents) == 0 { return nil } diff --git a/tool/loadartifactstool/load_artifacts_tool_test.go b/tool/loadartifactstool/load_artifacts_tool_test.go index 7b23e1d8e..846fa0e70 100644 --- a/tool/loadartifactstool/load_artifacts_tool_test.go +++ b/tool/loadartifactstool/load_artifacts_tool_test.go @@ -27,7 +27,6 @@ import ( icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/internal/toolinternal" "google.golang.org/adk/model" - "google.golang.org/adk/tool" "google.golang.org/adk/tool/loadartifactstool" ) @@ -278,7 +277,7 @@ func TestLoadArtifactsTool_ProcessRequest_Artifacts_OtherFunctionCall(t *testing } } -func createToolContext(t *testing.T) tool.Context { +func createToolContext(t *testing.T) agent.ToolContext { t.Helper() artifacts := &artifactinternal.Artifacts{ diff --git a/tool/loadmemorytool/tool.go b/tool/loadmemorytool/tool.go index 6e29c143f..a0952e80c 100644 --- a/tool/loadmemorytool/tool.go +++ b/tool/loadmemorytool/tool.go @@ -22,12 +22,12 @@ import ( "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/internal/toolinternal" "google.golang.org/adk/internal/toolinternal/toolutils" "google.golang.org/adk/internal/utils" "google.golang.org/adk/memory" "google.golang.org/adk/model" - "google.golang.org/adk/tool" ) const memoryInstructions = `You have memory. You can use it to answer questions. If any questions need @@ -80,7 +80,7 @@ func (t *loadMemoryTool) Declaration() *genai.FunctionDeclaration { } // Run executes the tool with the provided context and arguments. -func (t *loadMemoryTool) Run(toolCtx tool.Context, args any) (map[string]any, error) { +func (t *loadMemoryTool) Run(toolCtx agent.ToolContext, args any) (map[string]any, error) { m, ok := args.(map[string]any) if !ok { return nil, fmt.Errorf("unexpected args type, got: %T", args) @@ -109,7 +109,7 @@ func (t *loadMemoryTool) Run(toolCtx tool.Context, args any) (map[string]any, er // ProcessRequest processes the LLM request by packing the tool and appending // memory-related instructions. -func (t *loadMemoryTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (t *loadMemoryTool) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { if err := toolutils.PackTool(req, t); err != nil { return err } diff --git a/tool/loadmemorytool/tool_test.go b/tool/loadmemorytool/tool_test.go index fdc979274..5bde45722 100644 --- a/tool/loadmemorytool/tool_test.go +++ b/tool/loadmemorytool/tool_test.go @@ -28,7 +28,6 @@ import ( "google.golang.org/adk/memory" "google.golang.org/adk/model" "google.golang.org/adk/session" - "google.golang.org/adk/tool" "google.golang.org/adk/tool/loadmemorytool" ) @@ -171,7 +170,7 @@ func TestLoadMemoryTool_ProcessRequest(t *testing.T) { } } -func createToolContext(t *testing.T, mem *mockMemory) tool.Context { +func createToolContext(t *testing.T, mem *mockMemory) agent.ToolContext { t.Helper() ctx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{ diff --git a/tool/mcptoolset/tool.go b/tool/mcptoolset/tool.go index 7dbb45d5f..70b6d9f89 100644 --- a/tool/mcptoolset/tool.go +++ b/tool/mcptoolset/tool.go @@ -22,6 +22,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "google.golang.org/genai" + "google.golang.org/adk/agent" "google.golang.org/adk/internal/toolinternal" "google.golang.org/adk/internal/toolinternal/toolutils" "google.golang.org/adk/model" @@ -82,7 +83,7 @@ func (t *mcpTool) IsLongRunning() bool { return false } -func (t *mcpTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (t *mcpTool) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { return toolutils.PackTool(req, t) } @@ -90,7 +91,7 @@ func (t *mcpTool) Declaration() *genai.FunctionDeclaration { return t.funcDeclaration } -func (t *mcpTool) Run(ctx tool.Context, args any) (map[string]any, error) { +func (t *mcpTool) Run(ctx agent.ToolContext, args any) (map[string]any, error) { if confirmation := ctx.ToolConfirmation(); confirmation != nil { if !confirmation.Confirmed { return nil, fmt.Errorf("error tool %q %w", t.Name(), tool.ErrConfirmationRejected) diff --git a/tool/preloadmemorytool/tool.go b/tool/preloadmemorytool/tool.go index ab5866d85..16e4206c0 100644 --- a/tool/preloadmemorytool/tool.go +++ b/tool/preloadmemorytool/tool.go @@ -26,10 +26,10 @@ import ( "strings" "time" + "google.golang.org/adk/agent" "google.golang.org/adk/internal/utils" "google.golang.org/adk/memory" "google.golang.org/adk/model" - "google.golang.org/adk/tool" ) const preloadInstructions = `The following content is from your previous conversations with the user. @@ -71,7 +71,7 @@ func (t *preloadMemoryTool) IsLongRunning() bool { // ProcessRequest processes the LLM request by searching memory using the user's // current query and injecting relevant past conversations into system instructions. -func (t *preloadMemoryTool) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (t *preloadMemoryTool) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { userContent := ctx.UserContent() if userContent == nil || len(userContent.Parts) == 0 || userContent.Parts[0] == nil || userContent.Parts[0].Text == "" { diff --git a/tool/preloadmemorytool/tool_test.go b/tool/preloadmemorytool/tool_test.go index d68901b8e..22fa3fd9a 100644 --- a/tool/preloadmemorytool/tool_test.go +++ b/tool/preloadmemorytool/tool_test.go @@ -28,7 +28,6 @@ import ( "google.golang.org/adk/memory" "google.golang.org/adk/model" "google.golang.org/adk/session" - "google.golang.org/adk/tool" "google.golang.org/adk/tool/preloadmemorytool" ) @@ -207,7 +206,7 @@ func TestPreloadMemoryTool_ProcessRequest(t *testing.T) { } } -func createToolContext(t *testing.T, mem *mockMemory, userContent *genai.Content) tool.Context { +func createToolContext(t *testing.T, mem *mockMemory, userContent *genai.Content) agent.ToolContext { t.Helper() ctx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{ diff --git a/tool/skilltoolset/internal/skilltool/list_skills.go b/tool/skilltoolset/internal/skilltool/list_skills.go index 88cae6923..1f24fb59c 100644 --- a/tool/skilltoolset/internal/skilltool/list_skills.go +++ b/tool/skilltoolset/internal/skilltool/list_skills.go @@ -18,6 +18,7 @@ import ( "html" "strings" + "google.golang.org/adk/agent" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" "google.golang.org/adk/tool/skilltoolset/skill" @@ -38,13 +39,13 @@ func ListSkills(source skill.Source) (tool.Tool, error) { Name: "list_skills", Description: "Lists all available skills with their names and descriptions.", }, - func(ctx tool.Context, args ListSkillsArgs) (*ListSkillsResult, error) { + func(ctx agent.ToolContext, args ListSkillsArgs) (*ListSkillsResult, error) { return listSkills(ctx, args, source) }, ) } -func listSkills(ctx tool.Context, _ ListSkillsArgs, source skill.Source) (*ListSkillsResult, error) { +func listSkills(ctx agent.ToolContext, _ ListSkillsArgs, source skill.Source) (*ListSkillsResult, error) { skills, err := source.ListFrontmatters(ctx) if err != nil { return nil, err diff --git a/tool/skilltoolset/internal/skilltool/load_skill.go b/tool/skilltoolset/internal/skilltool/load_skill.go index 7e2a787e2..f662a8572 100644 --- a/tool/skilltoolset/internal/skilltool/load_skill.go +++ b/tool/skilltoolset/internal/skilltool/load_skill.go @@ -18,6 +18,7 @@ package skilltool import ( "fmt" + "google.golang.org/adk/agent" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" "google.golang.org/adk/tool/skilltoolset/skill" @@ -51,13 +52,13 @@ func LoadSkill(source skill.Source) (tool.Tool, error) { Name: "load_skill", Description: "Loads the SKILL.md instructions for a given skill.", }, - func(ctx tool.Context, args LoadSkillArgs) (*LoadSkillResult, error) { + func(ctx agent.ToolContext, args LoadSkillArgs) (*LoadSkillResult, error) { return loadSkill(ctx, args, source) }, ) } -func loadSkill(ctx tool.Context, args LoadSkillArgs, source skill.Source) (*LoadSkillResult, error) { +func loadSkill(ctx agent.ToolContext, args LoadSkillArgs, source skill.Source) (*LoadSkillResult, error) { if args.Name == "" { return nil, fmt.Errorf("skill name is required to load a skill") } diff --git a/tool/skilltoolset/internal/skilltool/load_skill_resource.go b/tool/skilltoolset/internal/skilltool/load_skill_resource.go index 42abe0496..8eff8886c 100644 --- a/tool/skilltoolset/internal/skilltool/load_skill_resource.go +++ b/tool/skilltoolset/internal/skilltool/load_skill_resource.go @@ -18,6 +18,7 @@ import ( "fmt" "io" + "google.golang.org/adk/agent" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" "google.golang.org/adk/tool/skilltoolset/skill" @@ -45,13 +46,13 @@ func LoadSkillResource(source skill.Source) (tool.Tool, error) { Name: "load_skill_resource", Description: "Loads a resource file (e.g., from references/ or assets/) associated with the specified skill.", }, - func(ctx tool.Context, args LoadSkillResourceArgs) (*LoadSkillResourceResult, error) { + func(ctx agent.ToolContext, args LoadSkillResourceArgs) (*LoadSkillResourceResult, error) { return loadSkillResource(ctx, args, source) }, ) } -func loadSkillResource(ctx tool.Context, args LoadSkillResourceArgs, source skill.Source) (*LoadSkillResourceResult, error) { +func loadSkillResource(ctx agent.ToolContext, args LoadSkillResourceArgs, source skill.Source) (*LoadSkillResourceResult, error) { if args.SkillName == "" { return nil, fmt.Errorf("skill name is required to load a resource") } diff --git a/tool/skilltoolset/internal/skilltool/tools_test.go b/tool/skilltoolset/internal/skilltool/tools_test.go index 50ff32812..3c1f37275 100644 --- a/tool/skilltoolset/internal/skilltool/tools_test.go +++ b/tool/skilltoolset/internal/skilltool/tools_test.go @@ -25,7 +25,6 @@ import ( "google.golang.org/adk/agent" icontext "google.golang.org/adk/internal/context" "google.golang.org/adk/internal/toolinternal" - "google.golang.org/adk/tool" "google.golang.org/adk/tool/skilltoolset/internal/skilltool" "google.golang.org/adk/tool/skilltoolset/skill" ) @@ -75,7 +74,7 @@ func (m *mockSource) LoadResource(ctx context.Context, name, resourcePath string return io.NopCloser(strings.NewReader(res)), nil } -func createToolContext(t *testing.T) tool.Context { +func createToolContext(t *testing.T) agent.ToolContext { invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{}) return agent.NewToolContext(invCtx, "", nil, nil) } diff --git a/tool/skilltoolset/toolset.go b/tool/skilltoolset/toolset.go index 3e681ad16..7754a9fbd 100644 --- a/tool/skilltoolset/toolset.go +++ b/tool/skilltoolset/toolset.go @@ -104,7 +104,7 @@ func (ts *SkillToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { // ProcessRequest implements toolinternal.RequestProcessor. It attaches // the list of available skills and the system instruction explaining to the // agent what it can do with these skills. -func (ts *SkillToolset) ProcessRequest(ctx tool.Context, req *model.LLMRequest) error { +func (ts *SkillToolset) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { skills, err := ts.source.ListFrontmatters(ctx) if err != nil { return err diff --git a/tool/tool.go b/tool/tool.go index a73e1ef2d..091df8883 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -189,18 +189,18 @@ type confirmationTool struct { type runnableTool interface { Tool Declaration() *genai.FunctionDeclaration - Run(ctx Context, args any) (result map[string]any, err error) + Run(ctx agent.ToolContext, args any) (result map[string]any, err error) } func (t *confirmationTool) Declaration() *genai.FunctionDeclaration { return t.runnableTool.Declaration() } -func (t *confirmationTool) ProcessRequest(ctx Context, req *model.LLMRequest) error { +func (t *confirmationTool) ProcessRequest(ctx agent.ToolContext, req *model.LLMRequest) error { return toolutils.PackTool(req, t) } -func (t *confirmationTool) Run(ctx Context, args any) (map[string]any, error) { +func (t *confirmationTool) Run(ctx agent.ToolContext, args any) (map[string]any, error) { ft := t.runnableTool // Check for Human-in-the-Loop confirmation. diff --git a/tool/tool_test.go b/tool/tool_test.go index 3f5aa870e..4bc9ccce1 100644 --- a/tool/tool_test.go +++ b/tool/tool_test.go @@ -54,7 +54,7 @@ func TestTypes(t *testing.T) { { name: "FunctionTool", constructor: func() (tool.Tool, error) { - return functiontool.New(functiontool.Config{}, func(_ tool.Context, input intInput) (intOutput, error) { + return functiontool.New(functiontool.Config{}, func(_ agent.ToolContext, input intInput) (intOutput, error) { return intOutput(input), nil }) }, @@ -160,7 +160,7 @@ func (tts *testToolset) Tools(agent.ReadonlyContext) ([]tool.Tool, error) { func TestWithConfirmation(t *testing.T) { toolRan := false - noOpTool, err := functiontool.New(functiontool.Config{Name: "noOpTool"}, func(ctx tool.Context, input struct{}) (struct{}, error) { + noOpTool, err := functiontool.New(functiontool.Config{Name: "noOpTool"}, func(ctx agent.ToolContext, input struct{}) (struct{}, error) { toolRan = true return struct{}{}, nil }) From a9a99a7f759d6c03acd59fe5d505b488e464fc90 Mon Sep 17 00:00:00 2001 From: Karol Piotrowicz Date: Wed, 10 Jun 2026 09:18:18 +0200 Subject: [PATCH 67/86] fix: bump x/net and otel OTLP exporters to patch govulncheck advisories (#994) Resolves the nightly govulncheck failures (exit 3) by updating the modules flagged in the call graph to their fixed versions: - golang.org/x/net v0.54.0 -> v0.55.0 (GO-2026-5026) - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 -> v0.19.0 (GO-2026-4985) - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 -> v1.43.0 (GO-2026-4985) Core go.opentelemetry.io/otel stays at v1.43.0; companion indirect deps (otel/log, otel/sdk/log, otlptrace, proto/otlp, grpc-gateway, x/sys) move forward via go mod tidy. Verified locally: go build ./... and go test -race -mod=readonly -count=1 -shuffle=on ./... both pass, and govulncheck no longer reports GO-2026-5026 or GO-2026-4985. --- go.mod | 18 +++++++++--------- go.sum | 40 ++++++++++++++++++++-------------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index da474d43b..2f606175a 100644 --- a/go.mod +++ b/go.mod @@ -19,9 +19,9 @@ require ( github.com/spf13/cobra v1.10.2 go.opentelemetry.io/contrib/detectors/gcp v1.42.0 go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 - go.opentelemetry.io/otel/log v0.16.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 + go.opentelemetry.io/otel/log v0.19.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 golang.org/x/oauth2 v0.36.0 @@ -69,7 +69,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/gorilla/websocket v1.5.3 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -84,14 +84,14 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.16.0 + go.opentelemetry.io/otel/sdk/log v0.19.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect diff --git a/go.sum b/go.sum index a8e7b4930..46698917e 100644 --- a/go.sum +++ b/go.sum @@ -94,8 +94,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -149,30 +149,30 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8V go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= -go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= -go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= -go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= -go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4= -go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -180,15 +180,15 @@ golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= From 38b11f7b1f4548d4bb05786661c2b269408831af Mon Sep 17 00:00:00 2001 From: Karol Piotrowicz Date: Wed, 10 Jun 2026 15:13:38 +0200 Subject: [PATCH 68/86] chore: complete Node 24 migration for GitHub Actions (#996) Bump the remaining actions still on the deprecated Node 20 runtime: - actions/setup-go v5.5.0 -> v6.4.0 (shared setup composite) - actions/cache v4.2.3 -> v5.0.5 (shared setup composite) - golang/govulncheck-action v1.0.4 -> master HEAD setup-go only moved to Node 24 in v6.2.0, so v5.5.0 was still Node 20. The govulncheck-action latest release (v1.0.4) internally pins actions/checkout@v4.1.1 and actions/setup-go@v5.0.0 (Node 20); its master branch already uses Node 24 actions but has not been tagged, so it is pinned to master HEAD with a TODO to re-pin once a release past v1.0.4 is cut. A previous pass only bumped actions/checkout, leaving these behind. --- .github/actions/setup/action.yml | 4 ++-- .github/workflows/nightly.yml | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index efc6b5cd6..df4994cec 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -11,13 +11,13 @@ runs: steps: - name: Set up Go id: setup-go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' cache: false # wrapper for actions/cache that doesn't support all functionality - name: Load Go cache - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.cache/go-build diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9222fb13c..78c641520 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -22,7 +22,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Run govulncheck - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + # NOTE: pinned to master HEAD instead of a tag. The latest release (v1.0.4) + # internally uses actions/checkout@v4.1.1 and actions/setup-go@v5.0.0 (Node 20), + # which are deprecated. master uses checkout v6.0.2 + setup-go v6.2.0 (Node 24). + # TODO: re-pin to a tagged release once upstream tags one past v1.0.4. + uses: golang/govulncheck-action@31f7c5463448f83528bd771c2d978d940080c9fd # master, post-v1.0.4 (Node 24) with: go-version-file: go.mod repo-checkout: true \ No newline at end of file From c6f168f5fd62e1203af7616ed03d11e42eda286a Mon Sep 17 00:00:00 2001 From: David Hyde Date: Mon, 15 Jun 2026 01:01:17 -0500 Subject: [PATCH 69/86] feat: add platform clock and UUID provider seams for deterministic event creation (#964) Introduce a platform package whose current time and UUID generation can be overridden per-context, mirroring the ContextVar-based seams in ADK-Python: - platform.Now(ctx) / platform.WithTimeProvider(ctx, fn) - platform.NewUUID(ctx) / platform.WithUUIDProvider(ctx, fn) Both fall back to time.Now and uuid.NewString when no provider is installed, so default behavior is unchanged. To honor the API stability policy (no breaking changes in a minor release), the existing session.NewEvent(invocationID) signature is preserved and marked deprecated; it delegates to the new session.NewEventWithContext(ctx, invocationID), which sources the event ID and timestamp from the platform seams. All in-repo event/session creation call sites are threaded with context and use NewEventWithContext, so a host runtime can make event creation deterministic and replay-safe (e.g. a workflow engine that must reproduce an execution exactly on replay). --- agent/agent.go | 12 +- agent/callback_context.go | 4 +- agent/llmagent/dynamic_events_test.go | 4 +- agent/remoteagent/a2a_agent_compat_test.go | 4 +- agent/remoteagent/v2/a2a_agent_test.go | 2 +- agent/remoteagent/v2/utils.go | 2 +- .../sequentialagent/agent_test.go | 10 +- cmd/launcher/web/a2a/a2a_test.go | 2 +- examples/tools/loadmemory/main.go | 2 +- internal/context/context_test.go | 35 ++++++ internal/context/invocation_context.go | 4 +- internal/llminternal/audio_cache_manager.go | 12 +- .../llminternal/audio_cache_manager_test.go | 10 +- internal/llminternal/base_flow.go | 26 ++--- internal/llminternal/functions.go | 4 +- internal/llminternal/functions_test.go | 6 + .../llminternal/outputschema_processor.go | 2 +- internal/telemetry/telemetry_test.go | 2 +- internal/utils/utils.go | 15 ++- internal/utils/utils_test.go | 55 ++++++++- platform/doc.go | 29 +++++ platform/time.go | 50 ++++++++ platform/time_test.go | 109 ++++++++++++++++++ platform/uuid.go | 52 +++++++++ platform/uuid_test.go | 89 ++++++++++++++ runner/live_runner_test.go | 8 +- runner/runner.go | 8 +- server/adka2a/v2/events.go | 2 +- server/adkrest/controllers/runtime_test.go | 3 +- session/database/service.go | 8 +- session/database/service_test.go | 20 ++++ session/database/storage_session.go | 9 +- session/event_test.go | 99 ++++++++++++++++ session/inmemory.go | 6 +- session/inmemory_test.go | 19 +++ session/session.go | 23 +++- 36 files changed, 670 insertions(+), 77 deletions(-) create mode 100644 platform/doc.go create mode 100644 platform/time.go create mode 100644 platform/time_test.go create mode 100644 platform/uuid.go create mode 100644 platform/uuid_test.go create mode 100644 session/event_test.go diff --git a/agent/agent.go b/agent/agent.go index fecd476af..9407a3fd0 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -257,7 +257,7 @@ func runBeforeAgentCallbacks(ctx InvocationContext) (*session.Event, error) { return nil, fmt.Errorf("failed to run plugin before agent callback: %w", err) } if content != nil { - event := session.NewEvent(ctx.InvocationID()) + event := session.NewEventWithContext(ctx, ctx.InvocationID()) event.LLMResponse = model.LLMResponse{ Content: content, } @@ -278,7 +278,7 @@ func runBeforeAgentCallbacks(ctx InvocationContext) (*session.Event, error) { continue } - event := session.NewEvent(ctx.InvocationID()) + event := session.NewEventWithContext(ctx, ctx.InvocationID()) event.LLMResponse = model.LLMResponse{ Content: content, } @@ -291,7 +291,7 @@ func runBeforeAgentCallbacks(ctx InvocationContext) (*session.Event, error) { // check if has delta create event with it if len(actions.StateDelta) > 0 { - event := session.NewEvent(ctx.InvocationID()) + event := session.NewEventWithContext(ctx, ctx.InvocationID()) event.Author = agent.Name() event.Branch = ctx.Branch() event.Actions = *actions @@ -316,7 +316,7 @@ func runAfterAgentCallbacks(ctx InvocationContext) (*session.Event, error) { return nil, fmt.Errorf("failed to run plugin after agent callback: %w", err) } if content != nil { - event := session.NewEvent(ctx.InvocationID()) + event := session.NewEventWithContext(ctx, ctx.InvocationID()) event.LLMResponse = model.LLMResponse{ Content: content, } @@ -336,7 +336,7 @@ func runAfterAgentCallbacks(ctx InvocationContext) (*session.Event, error) { continue } - event := session.NewEvent(ctx.InvocationID()) + event := session.NewEventWithContext(ctx, ctx.InvocationID()) event.LLMResponse = model.LLMResponse{ Content: newContent, } @@ -350,7 +350,7 @@ func runAfterAgentCallbacks(ctx InvocationContext) (*session.Event, error) { // check if has delta create event with it if len(actions.StateDelta) > 0 { - event := session.NewEvent(ctx.InvocationID()) + event := session.NewEventWithContext(ctx, ctx.InvocationID()) event.Author = agent.Name() event.Branch = ctx.Branch() event.Actions = *actions diff --git a/agent/callback_context.go b/agent/callback_context.go index 9ba05844e..df923247a 100644 --- a/agent/callback_context.go +++ b/agent/callback_context.go @@ -19,11 +19,11 @@ import ( "fmt" "iter" - "github.com/google/uuid" "google.golang.org/genai" "google.golang.org/adk/artifact" "google.golang.org/adk/memory" + "google.golang.org/adk/platform" "google.golang.org/adk/session" "google.golang.org/adk/tool/toolconfirmation" ) @@ -66,7 +66,7 @@ func NewCallbackContextWithArtifactTracking(ic InvocationContext, actions *sessi // tracking, etc.) apply, plus the tool-specific extensions on ToolContext. func NewToolContext(ic InvocationContext, functionCallID string, actions *session.EventActions, confirmation *toolconfirmation.ToolConfirmation) ToolContext { if functionCallID == "" { - functionCallID = uuid.NewString() + functionCallID = platform.NewUUID(ic) } actions = prepareEventActions(actions) return &callbackContext{ diff --git a/agent/llmagent/dynamic_events_test.go b/agent/llmagent/dynamic_events_test.go index 91a99073e..76f9c6fbb 100644 --- a/agent/llmagent/dynamic_events_test.go +++ b/agent/llmagent/dynamic_events_test.go @@ -33,7 +33,7 @@ func TestSessionEvents_YieldedPresence(t *testing.T) { Run: func(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] { return func(yield func(*session.Event, error) bool) { // 1. Yield a test event - testEvent := session.NewEvent(ctx.InvocationID()) + testEvent := session.NewEventWithContext(ctx, ctx.InvocationID()) testEvent.Content = genai.NewContentFromText("Initial test event", genai.RoleModel) if !yield(testEvent, nil) { return @@ -53,7 +53,7 @@ func TestSessionEvents_YieldedPresence(t *testing.T) { } // 3. Yield the result of the check - resultEvent := session.NewEvent(ctx.InvocationID()) + resultEvent := session.NewEventWithContext(ctx, ctx.InvocationID()) if found { resultEvent.Content = genai.NewContentFromText("Found initial event in session", genai.RoleModel) } else { diff --git a/agent/remoteagent/a2a_agent_compat_test.go b/agent/remoteagent/a2a_agent_compat_test.go index 08dabf7d0..e753dcea9 100644 --- a/agent/remoteagent/a2a_agent_compat_test.go +++ b/agent/remoteagent/a2a_agent_compat_test.go @@ -181,7 +181,7 @@ func TestCompat_RemoteAgent(t *testing.T) { }, updateConfig: func(config *A2AConfig) { config.Converter = func(ctx agent.InvocationContext, req *legacyA2A.MessageSendParams, event legacyA2A.Event, err error) (*session.Event, error) { - ev := session.NewEvent("custom") + ev := session.NewEventWithContext(ctx, "custom") ev.Author = ctx.Agent().Name() ev.LLMResponse = model.LLMResponse{ Content: genai.NewContentFromText("converted", genai.RoleModel), @@ -563,7 +563,7 @@ func newInvocationContext(t *testing.T, events []*session.Event) agent.Invocatio } func newUserHello() *session.Event { - event := session.NewEvent("invocation") + event := session.NewEventWithContext(context.Background(), "invocation") event.Author = "user" event.LLMResponse = model.LLMResponse{ Content: genai.NewContentFromText("hello", genai.RoleUser), diff --git a/agent/remoteagent/v2/a2a_agent_test.go b/agent/remoteagent/v2/a2a_agent_test.go index 2e5607235..fabed995c 100644 --- a/agent/remoteagent/v2/a2a_agent_test.go +++ b/agent/remoteagent/v2/a2a_agent_test.go @@ -206,7 +206,7 @@ func newA2AEventReplay(t *testing.T, events []a2a.Event) a2asrv.AgentExecutor { } func newUserHello() *session.Event { - event := session.NewEvent("invocation") + event := session.NewEventWithContext(context.Background(), "invocation") event.Author = "user" event.Content = genai.NewContentFromText("hello", genai.RoleUser) return event diff --git a/agent/remoteagent/v2/utils.go b/agent/remoteagent/v2/utils.go index f9b75cd1c..ae02b157b 100644 --- a/agent/remoteagent/v2/utils.go +++ b/agent/remoteagent/v2/utils.go @@ -126,7 +126,7 @@ func toMissingRemoteSessionParts(ctx agent.InvocationContext, events session.Eve } func presentAsUserMessage(ctx agent.InvocationContext, agentEvent *session.Event) *session.Event { - event := session.NewEvent(ctx.InvocationID()) + event := session.NewEventWithContext(ctx, ctx.InvocationID()) event.Author = "user" if agentEvent.Content == nil { diff --git a/agent/workflowagents/sequentialagent/agent_test.go b/agent/workflowagents/sequentialagent/agent_test.go index 203ce15b8..a4a9cf7d7 100644 --- a/agent/workflowagents/sequentialagent/agent_test.go +++ b/agent/workflowagents/sequentialagent/agent_test.go @@ -19,6 +19,7 @@ import ( "fmt" "iter" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -387,6 +388,11 @@ func (m *mockInvocationContext) Context() context.Context { return m.ctx } +func (m *mockInvocationContext) Deadline() (time.Time, bool) { return m.ctx.Deadline() } +func (m *mockInvocationContext) Done() <-chan struct{} { return m.ctx.Done() } +func (m *mockInvocationContext) Err() error { return m.ctx.Err() } +func (m *mockInvocationContext) Value(key any) any { return m.ctx.Value(key) } + func TestSequentialAgent_RunLive_Injection(t *testing.T) { subAgent1 := newCustomAgent(t, 1) subAgent2 := newCustomAgent(t, 2) @@ -459,7 +465,7 @@ func TestSequentialAgent_RunLive_SequentialOrchestration(t *testing.T) { Agent: agent1, runLiveFn: func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { iterFn := func(yield func(*session.Event, error) bool) { - ev := session.NewEvent(ctx.InvocationID()) + ev := session.NewEventWithContext(ctx, ctx.InvocationID()) ev.Author = "sub_agent_1" yield(ev, nil) } @@ -472,7 +478,7 @@ func TestSequentialAgent_RunLive_SequentialOrchestration(t *testing.T) { Agent: agent2, runLiveFn: func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { iterFn := func(yield func(*session.Event, error) bool) { - ev := session.NewEvent(ctx.InvocationID()) + ev := session.NewEventWithContext(ctx, ctx.InvocationID()) ev.Author = "sub_agent_2" yield(ev, nil) } diff --git a/cmd/launcher/web/a2a/a2a_test.go b/cmd/launcher/web/a2a/a2a_test.go index 7b3131e36..77b436838 100644 --- a/cmd/launcher/web/a2a/a2a_test.go +++ b/cmd/launcher/web/a2a/a2a_test.go @@ -75,7 +75,7 @@ func TestWebLauncher_ServesA2A(t *testing.T) { Name: "HelloWorldAgent", Run: func(ic agent.InvocationContext) iter.Seq2[*session.Event, error] { return func(yield func(*session.Event, error) bool) { - event := session.NewEvent(ic.InvocationID()) + event := session.NewEventWithContext(ic, ic.InvocationID()) event.Content = genai.NewContentFromText(wantMessage, genai.RoleModel) yield(event, nil) } diff --git a/examples/tools/loadmemory/main.go b/examples/tools/loadmemory/main.go index 9c3905372..41d192c1d 100644 --- a/examples/tools/loadmemory/main.go +++ b/examples/tools/loadmemory/main.go @@ -170,7 +170,7 @@ func createPreviousSessionWithHistory( } for _, e := range events { - event := session.NewEvent("previous-session") + event := session.NewEventWithContext(ctx, "previous-session") event.Author = e.author event.LLMResponse = model.LLMResponse{ Content: genai.NewContentFromText(e.content, genai.Role(e.author)), diff --git a/internal/context/context_test.go b/internal/context/context_test.go index 8552375ac..6e615a294 100644 --- a/internal/context/context_test.go +++ b/internal/context/context_test.go @@ -16,12 +16,15 @@ package context import ( "context" + "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" "google.golang.org/adk/agent" + "google.golang.org/adk/platform" ) func TestReadonlyContext(t *testing.T) { @@ -65,6 +68,38 @@ func TestWithContext(t *testing.T) { } } +func TestNewInvocationContextGeneratesIDWithProvider(t *testing.T) { + ctx := platform.WithUUIDProvider(t.Context(), func() string { return "fixed" }) + inv := NewInvocationContext(ctx, InvocationContextParams{}) + + if got, want := inv.InvocationID(), "e-fixed"; got != want { + t.Errorf("InvocationID() = %q, want %q", got, want) + } +} + +func TestNewInvocationContextRespectsExplicitID(t *testing.T) { + // An explicit InvocationID must be used verbatim, leaving the provider unused. + ctx := platform.WithUUIDProvider(t.Context(), func() string { return "fixed" }) + inv := NewInvocationContext(ctx, InvocationContextParams{InvocationID: "explicit"}) + + if got, want := inv.InvocationID(), "explicit"; got != want { + t.Errorf("InvocationID() = %q, want %q", got, want) + } +} + +func TestNewInvocationContextDefaultID(t *testing.T) { + inv := NewInvocationContext(t.Context(), InvocationContextParams{}) + + id := inv.InvocationID() + rest, ok := strings.CutPrefix(id, "e-") + if !ok { + t.Fatalf("InvocationID() = %q, want \"e-\" prefix", id) + } + if _, err := uuid.Parse(rest); err != nil { + t.Errorf("InvocationID() = %q, suffix not a valid UUID: %v", id, err) + } +} + func TestInvocationContext_LiveSessionResumptionHandle(t *testing.T) { inv := NewInvocationContext(t.Context(), InvocationContextParams{}) diff --git a/internal/context/invocation_context.go b/internal/context/invocation_context.go index 47ca280a7..9fe41d568 100644 --- a/internal/context/invocation_context.go +++ b/internal/context/invocation_context.go @@ -17,10 +17,10 @@ package context import ( "context" - "github.com/google/uuid" "google.golang.org/genai" "google.golang.org/adk/agent" + "google.golang.org/adk/platform" "google.golang.org/adk/session" ) @@ -41,7 +41,7 @@ type InvocationContextParams struct { func NewInvocationContext(ctx context.Context, params InvocationContextParams) agent.InvocationContext { if params.InvocationID == "" { - params.InvocationID = "e-" + uuid.NewString() + params.InvocationID = "e-" + platform.NewUUID(ctx) } return &InvocationContext{ Context: ctx, diff --git a/internal/llminternal/audio_cache_manager.go b/internal/llminternal/audio_cache_manager.go index 750329ad9..4446ddbbb 100644 --- a/internal/llminternal/audio_cache_manager.go +++ b/internal/llminternal/audio_cache_manager.go @@ -16,6 +16,7 @@ package llminternal import ( "bytes" + "context" "fmt" "strings" "sync" @@ -24,6 +25,7 @@ import ( "google.golang.org/genai" "google.golang.org/adk/agent" + "google.golang.org/adk/platform" "google.golang.org/adk/session" ) @@ -47,14 +49,14 @@ func NewAudioCacheManager() *AudioCacheManager { } // CacheInput caches incoming user audio data. -func (m *AudioCacheManager) CacheInput(data []byte, mimeType string) { +func (m *AudioCacheManager) CacheInput(ctx context.Context, data []byte, mimeType string) { if mimeType != "" && !strings.HasPrefix(mimeType, "audio/") { return } m.mu.Lock() defer m.mu.Unlock() if len(m.inputCache) == 0 { - m.inputStartTime = time.Now() + m.inputStartTime = platform.Now(ctx) if mimeType != "" { m.inputMimeType = mimeType } @@ -63,14 +65,14 @@ func (m *AudioCacheManager) CacheInput(data []byte, mimeType string) { } // CacheOutput caches outgoing model audio data. -func (m *AudioCacheManager) CacheOutput(data []byte, mimeType string) { +func (m *AudioCacheManager) CacheOutput(ctx context.Context, data []byte, mimeType string) { if mimeType != "" && !strings.HasPrefix(mimeType, "audio/") { return } m.mu.Lock() defer m.mu.Unlock() if len(m.outputCache) == 0 { - m.outputStartTime = time.Now() + m.outputStartTime = platform.Now(ctx) if mimeType != "" { m.outputMimeType = mimeType } @@ -150,7 +152,7 @@ func (m *AudioCacheManager) flushCache(ctx agent.InvocationContext, cache [][]by role = "user" } - ev := session.NewEvent(ctx.InvocationID()) + ev := session.NewEventWithContext(ctx, ctx.InvocationID()) ev.Author = author ev.Timestamp = startTime ev.Content = &genai.Content{ diff --git a/internal/llminternal/audio_cache_manager_test.go b/internal/llminternal/audio_cache_manager_test.go index f405c53c2..91b7cc448 100644 --- a/internal/llminternal/audio_cache_manager_test.go +++ b/internal/llminternal/audio_cache_manager_test.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "testing" + "time" "google.golang.org/genai" @@ -70,6 +71,11 @@ func (m *audioMockInvocationContext) Session() session.Session { return m.sess func (m *audioMockInvocationContext) InvocationID() string { return m.invocationID } func (m *audioMockInvocationContext) Agent() agent.Agent { return m.agentObj } +func (m *audioMockInvocationContext) Deadline() (time.Time, bool) { return time.Time{}, false } +func (m *audioMockInvocationContext) Done() <-chan struct{} { return nil } +func (m *audioMockInvocationContext) Err() error { return nil } +func (m *audioMockInvocationContext) Value(any) any { return nil } + type audioMockAgent struct { agent.Agent name string @@ -250,10 +256,10 @@ func TestAudioCacheManager(t *testing.T) { mgr := NewAudioCacheManager() for _, in := range tt.inputs { - mgr.CacheInput(in.data, in.mime) + mgr.CacheInput(context.Background(), in.data, in.mime) } for _, out := range tt.outputs { - mgr.CacheOutput(out.data, out.mime) + mgr.CacheOutput(context.Background(), out.data, out.mime) } mockArt := &audioMockArtifacts{} diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index e49bebb3f..083c4b5f7 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -27,7 +27,6 @@ import ( "sync" "time" - "github.com/google/uuid" "google.golang.org/genai" "google.golang.org/adk/agent" @@ -40,6 +39,7 @@ import ( "google.golang.org/adk/internal/toolinternal" "google.golang.org/adk/internal/utils" "google.golang.org/adk/model" + "google.golang.org/adk/platform" "google.golang.org/adk/session" "google.golang.org/adk/tool" "google.golang.org/adk/tool/toolconfirmation" @@ -375,11 +375,11 @@ func (f *Flow) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq if runCfg.Live.SaveLiveBlob && resp.Content != nil { for _, part := range resp.Content.Parts { if part.InlineData != nil { - sess.audioMgr.CacheOutput(part.InlineData.Data, part.InlineData.MIMEType) + sess.audioMgr.CacheOutput(ctx, part.InlineData.Data, part.InlineData.MIMEType) } } } - ev := session.NewEvent(ctx.InvocationID()) + ev := session.NewEventWithContext(ctx, ctx.InvocationID()) ev.Author = ctx.Agent().Name() ev.LLMResponse = *resp select { @@ -409,7 +409,7 @@ func (f *Flow) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq } if req.RealtimeInput != nil { if blob, ok := req.RealtimeInput.(*genai.Blob); ok { - sess.audioMgr.CacheInput(blob.Data, blob.MIMEType) + sess.audioMgr.CacheInput(ctx, blob.Data, blob.MIMEType) } if err := liveConn.SendRealtime(connCtx, req.RealtimeInput); err != nil { errChan <- err @@ -715,8 +715,8 @@ func toolsetPreprocess(ctx agent.InvocationContext, req *model.LLMRequest) error return nil } -func newResponseWithEventID(resp *model.LLMResponse) *responseWithEventID { - return &responseWithEventID{resp, uuid.New().String()} +func newResponseWithEventID(ctx context.Context, resp *model.LLMResponse) *responseWithEventID { + return &responseWithEventID{resp, platform.NewUUID(ctx)} } func (f *Flow) callLLM(ctx agent.InvocationContext, req *model.LLMRequest, stateDelta map[string]any, artifactDelta map[string]int64) iter.Seq2[*responseWithEventID, error] { @@ -726,7 +726,7 @@ func (f *Flow) callLLM(ctx agent.InvocationContext, req *model.LLMRequest, state cctx := icontext.NewCallbackContextWithDelta(ctx, stateDelta, artifactDelta) callbackResponse, callbackErr := pluginManager.RunBeforeModelCallback(cctx, req) if callbackResponse != nil || callbackErr != nil { - yield(newResponseWithEventID(callbackResponse), callbackErr) + yield(newResponseWithEventID(ctx, callbackResponse), callbackErr) return } } @@ -736,7 +736,7 @@ func (f *Flow) callLLM(ctx agent.InvocationContext, req *model.LLMRequest, state callbackResponse, callbackErr := callback(cctx, req) if callbackResponse != nil || callbackErr != nil { - yield(newResponseWithEventID(callbackResponse), callbackErr) + yield(newResponseWithEventID(ctx, callbackResponse), callbackErr) return } } @@ -766,7 +766,7 @@ func (f *Flow) callLLM(ctx agent.InvocationContext, req *model.LLMRequest, state } // Function call ID is optional in genai API and some models do not use the field. // Set it in case after model callbacks use it. - utils.PopulateClientFunctionCallID(resp.Content) + utils.PopulateClientFunctionCallID(ctx, resp.Content) callbackResp, callbackErr := f.runAfterModelCallbacks(ctx, resp.LLMResponse, stateDelta, artifactDelta, err) // TODO: check if we should stop iterator on the first error from stream or continue yielding next results. @@ -836,7 +836,7 @@ func generateContent(ctx agent.InvocationContext, m model.LLM, req *model.LLMReq // Ensure that the span is ended in case of error or if none final responses are yielded before the yield returns false. defer endSpanAndTrackResult() for resp, err := range m.GenerateContent(ctx, req, useStream) { - response := newResponseWithEventID(resp) + response := newResponseWithEventID(ctx, resp) lastResponse = *response lastErr = err // Complete the span immediately to avoid capturing the upstream yield processing time. @@ -926,9 +926,9 @@ func (f *Flow) finalizeModelResponseEvent(ctx agent.InvocationContext, resp *res // FunctionCall & FunctionResponse matching algorithm assumes non-empty function call IDs // but function call ID is optional in genai API and some models do not use the field. // Generate function call ids. (see functions.populate_client_function_call_id in python SDK) - utils.PopulateClientFunctionCallID(resp.Content) + utils.PopulateClientFunctionCallID(ctx, resp.Content) - ev := session.NewEvent(ctx.InvocationID()) + ev := session.NewEventWithContext(ctx, ctx.InvocationID()) ev.ID = resp.eventID // TODO change NewEvent to accept event id ev.Author = ctx.Agent().Name() ev.Branch = ctx.Branch() @@ -1130,7 +1130,7 @@ func (f *Flow) handleFunctionCalls(ctx agent.InvocationContext, toolsDict map[st } // TODO: handle long-running tool. - ev := session.NewEvent(ctx.InvocationID()) + ev := session.NewEventWithContext(ctx, ctx.InvocationID()) ev.LLMResponse = model.LLMResponse{ Content: &genai.Content{ Role: "user", diff --git a/internal/llminternal/functions.go b/internal/llminternal/functions.go index e9f6b1ecc..9112732e9 100644 --- a/internal/llminternal/functions.go +++ b/internal/llminternal/functions.go @@ -63,7 +63,7 @@ func generateRequestConfirmationEvent( } requestConfirmationFC := &genai.FunctionCall{ - ID: utils.GenerateFunctionCallID(), + ID: utils.GenerateFunctionCallID(invocationContext), Name: toolconfirmation.FunctionCallName, Args: args, } @@ -79,7 +79,7 @@ func generateRequestConfirmationEvent( return nil } - ev := session.NewEvent(invocationContext.InvocationID()) + ev := session.NewEventWithContext(invocationContext, invocationContext.InvocationID()) ev.Author = invocationContext.Agent().Name() ev.Branch = invocationContext.Branch() ev.LLMResponse = model.LLMResponse{ diff --git a/internal/llminternal/functions_test.go b/internal/llminternal/functions_test.go index 1c17a7167..e0cca8179 100644 --- a/internal/llminternal/functions_test.go +++ b/internal/llminternal/functions_test.go @@ -16,6 +16,7 @@ package llminternal import ( "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -55,6 +56,11 @@ func (m *mockInvocationContext) Branch() string { return m.branch } +func (m *mockInvocationContext) Deadline() (time.Time, bool) { return time.Time{}, false } +func (m *mockInvocationContext) Done() <-chan struct{} { return nil } +func (m *mockInvocationContext) Err() error { return nil } +func (m *mockInvocationContext) Value(any) any { return nil } + func TestGenerateRequestConfirmationEvent(t *testing.T) { confirmingFunctionCall := &genai.FunctionCall{ ID: "call_1", diff --git a/internal/llminternal/outputschema_processor.go b/internal/llminternal/outputschema_processor.go index 5f73b48eb..bc87ec0f7 100644 --- a/internal/llminternal/outputschema_processor.go +++ b/internal/llminternal/outputschema_processor.go @@ -66,7 +66,7 @@ func outputSchemaRequestProcessor(ctx agent.InvocationContext, req *model.LLMReq // createFinalModelResponseEvent creates a final model response event from set_model_response JSON. func createFinalModelResponseEvent(invocationContext agent.InvocationContext, response string) *session.Event { // Create a proper model response event - finalEvent := session.NewEvent(invocationContext.InvocationID()) + finalEvent := session.NewEventWithContext(invocationContext, invocationContext.InvocationID()) finalEvent.Author = invocationContext.Agent().Name() finalEvent.Branch = invocationContext.Branch() finalEvent.Content = &genai.Content{ diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 7e1b6e298..8b0c17dfe 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -129,7 +129,7 @@ func TestInvokeAgent(t *testing.T) { { name: "Success", resultParams: TraceAgentResultParams{ - ResponseEvent: session.NewEvent("test-invocation-id"), + ResponseEvent: session.NewEventWithContext(context.Background(), "test-invocation-id"), }, wantName: "invoke_agent test-agent", wantStatus: codes.Unset, diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 3d29baa08..895229f05 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -15,13 +15,14 @@ package utils import ( + "context" "strings" - "github.com/google/uuid" "google.golang.org/genai" "google.golang.org/adk/agent" "google.golang.org/adk/model" + "google.golang.org/adk/platform" "google.golang.org/adk/session" ) @@ -33,17 +34,19 @@ const afFunctionCallIDPrefix = "adk-" // Since the ID field is optional, some models don't fill the field, but // the LLMAgent depends on the IDs to map FunctionCall and FunctionResponse events // in the event stream. -func PopulateClientFunctionCallID(c *genai.Content) { +func PopulateClientFunctionCallID(ctx context.Context, c *genai.Content) { for _, fn := range FunctionCalls(c) { if fn.ID == "" { - fn.ID = GenerateFunctionCallID() + fn.ID = GenerateFunctionCallID(ctx) } } } -// GenerateFunctionCallID generates a new function call ID. -func GenerateFunctionCallID() string { - return afFunctionCallIDPrefix + uuid.NewString() +// GenerateFunctionCallID generates a new function call ID. The ID is obtained +// through the platform package, so a UUID provider installed on ctx (see +// platform.WithUUIDProvider) controls it. +func GenerateFunctionCallID(ctx context.Context) string { + return afFunctionCallIDPrefix + platform.NewUUID(ctx) } // RemoveClientFunctionCallID removes the function call ID field that was set diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 9b5144d23..3594b132c 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -14,8 +14,57 @@ package utils_test -import "testing" +import ( + "context" + "strings" + "testing" -func TestNothing(t *testing.T) { - // To make it buildable. + "google.golang.org/genai" + + "google.golang.org/adk/internal/utils" + "google.golang.org/adk/platform" +) + +func TestGenerateFunctionCallIDUsesProvider(t *testing.T) { + ctx := platform.WithUUIDProvider(context.Background(), func() string { return "fixed" }) + + got := utils.GenerateFunctionCallID(ctx) + + // The generated ID must carry the "adk-" prefix that RemoveClientFunctionCallID + // relies on, and must incorporate the value from the installed provider. + if !strings.HasPrefix(got, "adk-") { + t.Errorf("GenerateFunctionCallID() = %q, want \"adk-\" prefix", got) + } + if !strings.HasSuffix(got, "fixed") { + t.Errorf("GenerateFunctionCallID() = %q, want it to use the provider value %q", got, "fixed") + } +} + +func TestGenerateFunctionCallIDDefaultIsUnique(t *testing.T) { + first := utils.GenerateFunctionCallID(context.Background()) + second := utils.GenerateFunctionCallID(context.Background()) + + if first == second { + t.Errorf("GenerateFunctionCallID() returned %q twice; want unique values", first) + } +} + +func TestPopulateClientFunctionCallIDUsesProvider(t *testing.T) { + ctx := platform.WithUUIDProvider(context.Background(), func() string { return "generated" }) + + content := &genai.Content{ + Parts: []*genai.Part{ + {FunctionCall: &genai.FunctionCall{Name: "needs_id"}}, + {FunctionCall: &genai.FunctionCall{ID: "keep", Name: "has_id"}}, + }, + } + + utils.PopulateClientFunctionCallID(ctx, content) + + if got := content.Parts[0].FunctionCall.ID; got != "adk-generated" { + t.Errorf("empty function call ID = %q, want %q", got, "adk-generated") + } + if got := content.Parts[1].FunctionCall.ID; got != "keep" { + t.Errorf("preset function call ID = %q, want it left untouched (%q)", got, "keep") + } } diff --git a/platform/doc.go b/platform/doc.go new file mode 100644 index 000000000..7c32a17ff --- /dev/null +++ b/platform/doc.go @@ -0,0 +1,29 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package platform provides seams for overriding system operations, such as +// reading the current time and generating unique IDs. +// +// By default these operations use the wall clock (time.Now) and random UUIDs. +// Users that need custom behavior can install their own providers on a +// context.Context with WithTimeProvider and WithUUIDProvider. ADK reads the +// current time and new IDs through Now and NewUUID, so an installed provider +// transparently controls the timestamps and identifiers that end up in events +// and sessions. +// +// Providers are carried explicitly on a context.Context. Carrying the +// provider on the context (rather than in a package-level variable) also +// keeps it isolated to a single call tree, which is what makes concurrent +// runs with independent providers safe. +package platform diff --git a/platform/time.go b/platform/time.go new file mode 100644 index 000000000..c5b9c0033 --- /dev/null +++ b/platform/time.go @@ -0,0 +1,50 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package platform + +import ( + "context" + "time" +) + +// TimeProvider returns the current time. The default behavior is the wall +// clock (time.Now); callers can install a custom provider on a context +// with WithTimeProvider. +type TimeProvider func() time.Time + +// timeProviderKey is the context key under which a TimeProvider is stored. +type timeProviderKey struct{} + +// WithTimeProvider returns a copy of ctx that carries provider. Calls to Now +// with the returned context, or any context derived from it, use provider +// instead of the wall clock. A nil provider is ignored by Now, which then +// falls back to time.Now. +func WithTimeProvider(ctx context.Context, provider TimeProvider) context.Context { + return context.WithValue(ctx, timeProviderKey{}, provider) +} + +// Now returns the current time. If ctx carries a TimeProvider installed with +// WithTimeProvider, that provider is used; otherwise Now falls back to +// time.Now. +// +// Now is the analog of google.adk.platform.time.get_time in adk-python. +func Now(ctx context.Context) time.Time { + if ctx != nil { + if p, ok := ctx.Value(timeProviderKey{}).(TimeProvider); ok && p != nil { + return p() + } + } + return time.Now() +} diff --git a/platform/time_test.go b/platform/time_test.go new file mode 100644 index 000000000..65381e099 --- /dev/null +++ b/platform/time_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package platform_test + +import ( + "context" + "testing" + "time" + + "google.golang.org/adk/platform" +) + +func TestNowDefaultUsesWallClock(t *testing.T) { + before := time.Now() + got := platform.Now(context.Background()) + after := time.Now() + + if got.Before(before) || got.After(after) { + t.Errorf("Now() = %v, want within [%v, %v]", got, before, after) + } +} + +func TestNowNilContext(t *testing.T) { + // Now must tolerate a nil context and fall back to the wall clock. + var nilCtx context.Context + before := time.Now() + got := platform.Now(nilCtx) + after := time.Now() + + if got.Before(before) || got.After(after) { + t.Errorf("Now(nil) = %v, want within [%v, %v]", got, before, after) + } +} + +func TestWithTimeProviderOverridesNow(t *testing.T) { + fixed := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) + ctx := platform.WithTimeProvider(context.Background(), func() time.Time { return fixed }) + + if got := platform.Now(ctx); !got.Equal(fixed) { + t.Errorf("Now() = %v, want %v", got, fixed) + } +} + +func TestWithTimeProviderDerivedContext(t *testing.T) { + fixed := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) + ctx := platform.WithTimeProvider(context.Background(), func() time.Time { return fixed }) + + // A context derived from the provider context must keep the provider. + derived, cancel := context.WithCancel(ctx) + defer cancel() + + if got := platform.Now(derived); !got.Equal(fixed) { + t.Errorf("Now() on derived context = %v, want %v", got, fixed) + } +} + +func TestWithTimeProviderNilFallsBack(t *testing.T) { + ctx := platform.WithTimeProvider(context.Background(), nil) + + before := time.Now() + got := platform.Now(ctx) + after := time.Now() + + if got.Before(before) || got.After(after) { + t.Errorf("Now() with nil provider = %v, want within [%v, %v]", got, before, after) + } +} + +func TestWithTimeProviderNestedOverride(t *testing.T) { + outer := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + inner := time.Date(2025, time.June, 7, 8, 9, 10, 0, time.UTC) + + ctx := platform.WithTimeProvider(context.Background(), func() time.Time { return outer }) + ctx = platform.WithTimeProvider(ctx, func() time.Time { return inner }) + + if got := platform.Now(ctx); !got.Equal(inner) { + t.Errorf("Now() = %v, want innermost provider value %v", got, inner) + } +} + +func TestWithTimeProviderIsCalledEachTime(t *testing.T) { + var calls int + ctx := platform.WithTimeProvider(context.Background(), func() time.Time { + calls++ + return time.Unix(int64(calls), 0).UTC() + }) + + first := platform.Now(ctx) + second := platform.Now(ctx) + + if first.Equal(second) { + t.Errorf("Now() returned the same time %v on consecutive calls; provider should be invoked each call", first) + } + if calls != 2 { + t.Errorf("provider called %d times, want 2", calls) + } +} diff --git a/platform/uuid.go b/platform/uuid.go new file mode 100644 index 000000000..7de33868c --- /dev/null +++ b/platform/uuid.go @@ -0,0 +1,52 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package platform + +import ( + "context" + + "github.com/google/uuid" +) + +// UUIDProvider returns a new unique identifier. The default behavior is to +// return a random UUIDv4 (uuid.NewString); callers can install a custom +// provider on a context with WithUUIDProvider. +type UUIDProvider func() string + +// uuidProviderKey is the context key under which a UUIDProvider is stored. +type uuidProviderKey struct{} + +// WithUUIDProvider returns a copy of ctx that carries provider. Calls to +// NewUUID with the returned context, or any context derived from it, use +// provider instead of generating a random UUID. A nil provider is ignored by +// NewUUID, which then falls back to uuid.NewString. +func WithUUIDProvider(ctx context.Context, provider UUIDProvider) context.Context { + return context.WithValue(ctx, uuidProviderKey{}, provider) +} + +// NewUUID returns a new unique identifier. If ctx carries a UUIDProvider +// installed with WithUUIDProvider, that provider is used; otherwise NewUUID +// falls back to a random UUIDv4 (uuid.NewString). +// +// NewUUID is the analog of google.adk.platform.uuid.new_uuid in adk-python, +// with the provider read from ctx rather than from a contextvar. +func NewUUID(ctx context.Context) string { + if ctx != nil { + if p, ok := ctx.Value(uuidProviderKey{}).(UUIDProvider); ok && p != nil { + return p() + } + } + return uuid.NewString() +} diff --git a/platform/uuid_test.go b/platform/uuid_test.go new file mode 100644 index 000000000..cadec2b3a --- /dev/null +++ b/platform/uuid_test.go @@ -0,0 +1,89 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package platform_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "google.golang.org/adk/platform" +) + +func TestNewUUIDDefaultIsRandomAndValid(t *testing.T) { + first := platform.NewUUID(context.Background()) + second := platform.NewUUID(context.Background()) + + if _, err := uuid.Parse(first); err != nil { + t.Errorf("NewUUID() = %q, not a valid UUID: %v", first, err) + } + if first == second { + t.Errorf("NewUUID() returned the same value %q twice; want random values", first) + } +} + +func TestNewUUIDNilContext(t *testing.T) { + // NewUUID must tolerate a nil context and fall back to a random UUID. + var nilCtx context.Context + got := platform.NewUUID(nilCtx) + if _, err := uuid.Parse(got); err != nil { + t.Errorf("NewUUID(nil) = %q, not a valid UUID: %v", got, err) + } +} + +func TestWithUUIDProviderOverridesNewUUID(t *testing.T) { + var n int + ctx := platform.WithUUIDProvider(context.Background(), func() string { + n++ + return "id-" + string(rune('0'+n)) + }) + + want := []string{"id-1", "id-2", "id-3"} + for i, w := range want { + if got := platform.NewUUID(ctx); got != w { + t.Errorf("NewUUID() call %d = %q, want %q", i+1, got, w) + } + } +} + +func TestWithUUIDProviderDerivedContext(t *testing.T) { + ctx := platform.WithUUIDProvider(context.Background(), func() string { return "fixed" }) + + derived, cancel := context.WithCancel(ctx) + defer cancel() + + if got := platform.NewUUID(derived); got != "fixed" { + t.Errorf("NewUUID() on derived context = %q, want %q", got, "fixed") + } +} + +func TestWithUUIDProviderNilFallsBack(t *testing.T) { + ctx := platform.WithUUIDProvider(context.Background(), nil) + + got := platform.NewUUID(ctx) + if _, err := uuid.Parse(got); err != nil { + t.Errorf("NewUUID() with nil provider = %q, not a valid UUID: %v", got, err) + } +} + +func TestWithUUIDProviderNestedOverride(t *testing.T) { + ctx := platform.WithUUIDProvider(context.Background(), func() string { return "outer" }) + ctx = platform.WithUUIDProvider(ctx, func() string { return "inner" }) + + if got := platform.NewUUID(ctx); got != "inner" { + t.Errorf("NewUUID() = %q, want innermost provider value %q", got, "inner") + } +} diff --git a/runner/live_runner_test.go b/runner/live_runner_test.go index 0d2bd50d7..e68fef161 100644 --- a/runner/live_runner_test.go +++ b/runner/live_runner_test.go @@ -76,7 +76,7 @@ func TestRunner_RunLive_Callbacks(t *testing.T) { Agent: testAgent, runLiveFn: func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { return &dummyLiveSession{}, func(yield func(*session.Event, error) bool) { - yield(session.NewEvent(ctx.InvocationID()), nil) + yield(session.NewEventWithContext(ctx, ctx.InvocationID()), nil) }, nil }, } @@ -221,7 +221,7 @@ func TestRunner_RunLive_ChronologicalBuffering(t *testing.T) { runLiveFn: func(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { return &dummyLiveSession{}, func(yield func(*session.Event, error) bool) { // 1. Partial Transcription - ev1 := session.NewEvent(ctx.InvocationID()) + ev1 := session.NewEventWithContext(ctx, ctx.InvocationID()) ev1.LLMResponse.Partial = true ev1.LLMResponse.OutputTranscription = &genai.Transcription{Text: "Hello"} if !yield(ev1, nil) { @@ -229,7 +229,7 @@ func TestRunner_RunLive_ChronologicalBuffering(t *testing.T) { } // 2. Function Call (happening during transcription) - ev2 := session.NewEvent(ctx.InvocationID()) + ev2 := session.NewEventWithContext(ctx, ctx.InvocationID()) ev2.LLMResponse.Content = &genai.Content{ Parts: []*genai.Part{{FunctionCall: &genai.FunctionCall{Name: "test_func"}}}, } @@ -238,7 +238,7 @@ func TestRunner_RunLive_ChronologicalBuffering(t *testing.T) { } // 3. Final Transcription - ev3 := session.NewEvent(ctx.InvocationID()) + ev3 := session.NewEventWithContext(ctx, ctx.InvocationID()) ev3.LLMResponse.OutputTranscription = &genai.Transcription{Text: "Hello there."} if !yield(ev3, nil) { return diff --git a/runner/runner.go b/runner/runner.go index fd910ee4f..c111511ab 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -217,7 +217,7 @@ func (r *Runner) Run(ctx context.Context, userID, sessionID string, msg *genai.C earlyExitResult, err := pluginManager.RunBeforeRunCallback(ctx) if earlyExitResult != nil || err != nil { - earlyExitEvent := session.NewEvent(ctx.InvocationID()) + earlyExitEvent := session.NewEventWithContext(ctx, ctx.InvocationID()) earlyExitEvent.Author = "user" earlyExitEvent.LLMResponse = model.LLMResponse{ Content: msg, @@ -297,7 +297,7 @@ func (s *runnerLiveSession) Send(req agent.LiveRequest) error { } if !isFunctionResponse { - event := session.NewEvent(s.iCtx.InvocationID()) + event := session.NewEventWithContext(s.iCtx, s.iCtx.InvocationID()) event.Author = "user" event.LLMResponse = model.LLMResponse{ Content: req.Content, @@ -406,7 +406,7 @@ func (r *Runner) RunLive(ctx context.Context, userID, sessionID string, cfg agen return nil, nil, err } if earlyExitResult != nil { - earlyExitEvent := session.NewEvent(iCtx.InvocationID()) + earlyExitEvent := session.NewEventWithContext(iCtx, iCtx.InvocationID()) earlyExitEvent.Author = agentToRun.Name() earlyExitEvent.LLMResponse = model.LLMResponse{ Content: earlyExitResult, @@ -571,7 +571,7 @@ func (r *Runner) appendMessageToSession(ctx agent.InvocationContext, storedSessi } } - event := session.NewEvent(ctx.InvocationID()) + event := session.NewEventWithContext(ctx, ctx.InvocationID()) event.Author = "user" event.LLMResponse = model.LLMResponse{ diff --git a/server/adka2a/v2/events.go b/server/adka2a/v2/events.go index ede8a5153..714cedd97 100644 --- a/server/adka2a/v2/events.go +++ b/server/adka2a/v2/events.go @@ -28,7 +28,7 @@ import ( // NewRemoteAgentEvent create a new Event authored by the agent running in the provided invocation context. func NewRemoteAgentEvent(ctx agent.InvocationContext) *session.Event { - event := session.NewEvent(ctx.InvocationID()) + event := session.NewEventWithContext(ctx, ctx.InvocationID()) event.Author = ctx.Agent().Name() event.Branch = ctx.Branch() return event diff --git a/server/adkrest/controllers/runtime_test.go b/server/adkrest/controllers/runtime_test.go index eecf58192..7172d340b 100644 --- a/server/adkrest/controllers/runtime_test.go +++ b/server/adkrest/controllers/runtime_test.go @@ -16,6 +16,7 @@ package controllers import ( "bytes" + "context" "encoding/json" "fmt" "iter" @@ -116,7 +117,7 @@ func testAgent(results []testAgentResult) func(ctx agent.InvocationContext) iter } func makeEvent(id, author, text string) *session.Event { - e := session.NewEvent(id) + e := session.NewEventWithContext(context.Background(), id) e.Author = author e.LLMResponse.Content = &genai.Content{ Parts: []*genai.Part{{Text: text}}, diff --git a/session/database/service.go b/session/database/service.go index ec64808f1..b4fc80291 100644 --- a/session/database/service.go +++ b/session/database/service.go @@ -22,9 +22,9 @@ import ( "strings" "time" - "github.com/google/uuid" "gorm.io/gorm" + "google.golang.org/adk/platform" "google.golang.org/adk/session" ) @@ -75,7 +75,7 @@ func (s *databaseService) Create(ctx context.Context, req *session.CreateRequest sessionID := req.SessionID if sessionID == "" { - sessionID = uuid.NewString() + sessionID = platform.NewUUID(ctx) } stateMap := req.State @@ -87,9 +87,9 @@ func (s *databaseService) Create(ctx context.Context, req *session.CreateRequest userID: req.UserID, sessionID: sessionID, state: stateMap, - updatedAt: time.Now(), + updatedAt: platform.Now(ctx), } - createdSession, err := createStorageSession(val) + createdSession, err := createStorageSession(ctx, val) if err != nil { return nil, err } diff --git a/session/database/service_test.go b/session/database/service_test.go index 90de7adcb..2098dc9b1 100644 --- a/session/database/service_test.go +++ b/session/database/service_test.go @@ -16,10 +16,12 @@ package database import ( "testing" + "time" "github.com/glebarez/sqlite" "gorm.io/gorm" + "google.golang.org/adk/platform" "google.golang.org/adk/session" "google.golang.org/adk/session/session_test" ) @@ -31,6 +33,24 @@ func Test_databaseService(t *testing.T) { }) } +func Test_databaseService_CreateUsesProviders(t *testing.T) { + fixedTime := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) + ctx := platform.WithTimeProvider(t.Context(), func() time.Time { return fixedTime }) + ctx = platform.WithUUIDProvider(ctx, func() string { return "fixed-session-id" }) + + s := emptyService(t) + resp, err := s.Create(ctx, &session.CreateRequest{AppName: "app", UserID: "user"}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if got := resp.Session.ID(); got != "fixed-session-id" { + t.Errorf("autogenerated session ID = %q, want provider value %q", got, "fixed-session-id") + } + if got := resp.Session.LastUpdateTime(); !got.Equal(fixedTime) { + t.Errorf("LastUpdateTime() = %v, want provider value %v", got, fixedTime) + } +} + func emptyService(t *testing.T) *databaseService { t.Helper() gormConfig := &gorm.Config{ diff --git a/session/database/storage_session.go b/session/database/storage_session.go index b773227f9..7d00c917b 100644 --- a/session/database/storage_session.go +++ b/session/database/storage_session.go @@ -15,6 +15,7 @@ package database import ( + "context" "encoding/json" "fmt" "time" @@ -22,6 +23,7 @@ import ( "google.golang.org/genai" "google.golang.org/adk/model" + "google.golang.org/adk/platform" "google.golang.org/adk/session" ) @@ -44,14 +46,15 @@ func (storageSession) TableName() string { } // Helper to map from internal struct to GORM struct -func createStorageSession(s *localSession) (*storageSession, error) { +func createStorageSession(ctx context.Context, s *localSession) (*storageSession, error) { + now := platform.Now(ctx) return &storageSession{ UserID: s.userID, AppName: s.appName, ID: s.sessionID, State: s.state, - CreateTime: time.Now(), - UpdateTime: time.Now(), + CreateTime: now, + UpdateTime: now, }, nil } diff --git a/session/event_test.go b/session/event_test.go new file mode 100644 index 000000000..b2dcbe980 --- /dev/null +++ b/session/event_test.go @@ -0,0 +1,99 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package session_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "google.golang.org/adk/platform" + "google.golang.org/adk/session" +) + +// TestNewEventDefaults covers the deprecated NewEvent, which must keep its +// original signature and use the wall clock and a random UUID. +func TestNewEventDefaults(t *testing.T) { + before := time.Now() + ev := session.NewEvent("inv-1") + after := time.Now() + + if ev.InvocationID != "inv-1" { + t.Errorf("InvocationID = %q, want %q", ev.InvocationID, "inv-1") + } + if _, err := uuid.Parse(ev.ID); err != nil { + t.Errorf("ID = %q, not a valid UUID: %v", ev.ID, err) + } + if ev.Timestamp.Before(before) || ev.Timestamp.After(after) { + t.Errorf("Timestamp = %v, want within [%v, %v]", ev.Timestamp, before, after) + } +} + +func TestNewEventUsesProviders(t *testing.T) { + fixedTime := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) + ctx := platform.WithTimeProvider(context.Background(), func() time.Time { return fixedTime }) + ctx = platform.WithUUIDProvider(ctx, func() string { return "fixed-event-id" }) + + ev := session.NewEventWithContext(ctx, "inv-1") + + if ev.ID != "fixed-event-id" { + t.Errorf("ID = %q, want %q", ev.ID, "fixed-event-id") + } + if !ev.Timestamp.Equal(fixedTime) { + t.Errorf("Timestamp = %v, want %v", ev.Timestamp, fixedTime) + } + if ev.InvocationID != "inv-1" { + t.Errorf("InvocationID = %q, want %q", ev.InvocationID, "inv-1") + } +} + +// TestNewEventDeterministicReplay verifies that replaying the same sequence of +// provider values produces identical events. This is the property a workflow +// engine relies on to make event creation replay-safe. +func TestNewEventDeterministicReplay(t *testing.T) { + newCtx := func() context.Context { + var ids int + ctx := platform.WithUUIDProvider(context.Background(), func() string { + ids++ + return "event-" + string(rune('0'+ids)) + }) + var times int + return platform.WithTimeProvider(ctx, func() time.Time { + times++ + return time.Date(2024, time.January, 1, 0, 0, times, 0, time.UTC) + }) + } + + run := func(ctx context.Context) []*session.Event { + return []*session.Event{ + session.NewEventWithContext(ctx, "inv"), + session.NewEventWithContext(ctx, "inv"), + } + } + + first := run(newCtx()) + second := run(newCtx()) + + for i := range first { + if first[i].ID != second[i].ID { + t.Errorf("event %d ID: first run %q, second run %q", i, first[i].ID, second[i].ID) + } + if !first[i].Timestamp.Equal(second[i].Timestamp) { + t.Errorf("event %d Timestamp: first run %v, second run %v", i, first[i].Timestamp, second[i].Timestamp) + } + } +} diff --git a/session/inmemory.go b/session/inmemory.go index 62967e697..24e70949b 100644 --- a/session/inmemory.go +++ b/session/inmemory.go @@ -25,11 +25,11 @@ import ( "sync" "time" - "github.com/google/uuid" "rsc.io/omap" "rsc.io/ordered" "google.golang.org/adk/internal/sessionutils" + "google.golang.org/adk/platform" ) type stateMap map[string]any @@ -50,7 +50,7 @@ func (s *inMemoryService) Create(ctx context.Context, req *CreateRequest) (*Crea sessionID := req.SessionID if sessionID == "" { - sessionID = uuid.NewString() + sessionID = platform.NewUUID(ctx) } key := id{ @@ -74,7 +74,7 @@ func (s *inMemoryService) Create(ctx context.Context, req *CreateRequest) (*Crea val := &session{ id: key, state: state, - updatedAt: time.Now(), + updatedAt: platform.Now(ctx), } s.sessions.Set(encodedKey, val) diff --git a/session/inmemory_test.go b/session/inmemory_test.go index 1d178be89..2085893d6 100644 --- a/session/inmemory_test.go +++ b/session/inmemory_test.go @@ -21,10 +21,29 @@ import ( "testing" "time" + "google.golang.org/adk/platform" "google.golang.org/adk/session" "google.golang.org/adk/session/session_test" ) +func Test_inMemoryService_CreateUsesProviders(t *testing.T) { + fixedTime := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) + ctx := platform.WithTimeProvider(t.Context(), func() time.Time { return fixedTime }) + ctx = platform.WithUUIDProvider(ctx, func() string { return "fixed-session-id" }) + + s := session.InMemoryService() + resp, err := s.Create(ctx, &session.CreateRequest{AppName: "app", UserID: "user"}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if got := resp.Session.ID(); got != "fixed-session-id" { + t.Errorf("autogenerated session ID = %q, want provider value %q", got, "fixed-session-id") + } + if got := resp.Session.LastUpdateTime(); !got.Equal(fixedTime) { + t.Errorf("LastUpdateTime() = %v, want provider value %v", got, fixedTime) + } +} + func Test_inMemoryService(t *testing.T) { opts := session_test.SuiteOptions{SupportsUserProvidedSessionID: true} // InMemory supports custom IDs session_test.RunServiceTests(t, opts, func(t *testing.T) session.Service { diff --git a/session/session.go b/session/session.go index 4c8668127..82b0bb4c1 100644 --- a/session/session.go +++ b/session/session.go @@ -15,13 +15,13 @@ package session import ( + "context" "errors" "iter" "time" - "github.com/google/uuid" - "google.golang.org/adk/model" + "google.golang.org/adk/platform" "google.golang.org/adk/tool/toolconfirmation" ) @@ -130,11 +130,26 @@ func (e *Event) IsFinalResponse() bool { } // NewEvent creates a new event defining now as the timestamp. +// +// Deprecated: Use [NewEventWithContext] instead so that platform-installed time +// and UUID providers (see [platform.WithTimeProvider] and +// [platform.WithUUIDProvider]) are honored. NewEvent always uses the wall clock +// and a random UUID. func NewEvent(invocationID string) *Event { + return NewEventWithContext(context.Background(), invocationID) +} + +// NewEventWithContext creates a new event defining now as the timestamp. +// +// The event ID and timestamp are obtained through the platform package, so a +// time or UUID provider installed on ctx (see [platform.WithTimeProvider] and +// [platform.WithUUIDProvider]) controls them. This lets callers such as +// workflow engines produce deterministic, replay-safe events. +func NewEventWithContext(ctx context.Context, invocationID string) *Event { return &Event{ - ID: uuid.NewString(), + ID: platform.NewUUID(ctx), InvocationID: invocationID, - Timestamp: time.Now(), + Timestamp: platform.Now(ctx), Actions: EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)}, } } From 9f6080f96a0828cc7b574d14850e0990ef31e661 Mon Sep 17 00:00:00 2001 From: wolo Date: Tue, 16 Jun 2026 07:31:28 +0200 Subject: [PATCH 70/86] feat(agent): add StrictContextMock test double (#1019) StrictContextMock implements the full ToolContext / CallbackContext / ReadonlyContext surface so it can be embedded in a test fake; embedders keep compiling as the interfaces grow, instead of breaking on every added method. Un-overridden methods panic with "not implemented" (a loud failure on unexpected calls), while the context.Context methods (Deadline/Done/Err/Value) delegate to the wrapped Ctx. --- agent/context_mock.go | 129 +++++++++++++++++++++++++++++++++++++ agent/context_mock_test.go | 62 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 agent/context_mock.go create mode 100644 agent/context_mock_test.go diff --git a/agent/context_mock.go b/agent/context_mock.go new file mode 100644 index 000000000..9facf40dc --- /dev/null +++ b/agent/context_mock.go @@ -0,0 +1,129 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agent + +import ( + "context" + "time" + + "google.golang.org/genai" + + "google.golang.org/adk/memory" + "google.golang.org/adk/session" + "google.golang.org/adk/tool/toolconfirmation" +) + +// StrictContextMock is a strict test double for the context interfaces +// ([ToolContext], [CallbackContext], [ReadonlyContext]). +// +// Embed it in a test fake and override only the methods your test actually +// uses. Because it implements the whole surface, embedders keep compiling as +// the interfaces grow. +// +// An un-overridden method panics with "not implemented" — an unexpected call +// fails the test loudly instead of silently returning a zero value. +// +// The exception is the standard library's context.Context methods (Deadline, +// Done, Err and Value): those read from the supplied Ctx rather than panicking, +// so the mock carries a usable context payload. If Ctx is nil they panic like +// everything else. +type StrictContextMock struct { + // Ctx supplies the values returned by Deadline, Done, Err and Value. + Ctx context.Context +} + +func (m *StrictContextMock) ctx() context.Context { + if m.Ctx == nil { + panic("agent.StrictContextMock: Ctx is nil") + } + return m.Ctx +} + +// context.Context methods, served from Ctx instead of panicking. + +// Deadline implements [ToolContext]. +func (m *StrictContextMock) Deadline() (deadline time.Time, ok bool) { return m.ctx().Deadline() } + +// Done implements [ToolContext]. +func (m *StrictContextMock) Done() <-chan struct{} { return m.ctx().Done() } + +// Err implements [ToolContext]. +func (m *StrictContextMock) Err() error { return m.ctx().Err() } + +// Value implements [ToolContext]. +func (m *StrictContextMock) Value(key any) any { return m.ctx().Value(key) } + +// ReadonlyContext methods. + +// UserContent implements [ToolContext]. +func (m *StrictContextMock) UserContent() *genai.Content { panic("not implemented") } + +// InvocationID implements [ToolContext]. +func (m *StrictContextMock) InvocationID() string { panic("not implemented") } + +// AgentName implements [ToolContext]. +func (m *StrictContextMock) AgentName() string { panic("not implemented") } + +// ReadonlyState implements [ToolContext]. +func (m *StrictContextMock) ReadonlyState() session.ReadonlyState { panic("not implemented") } + +// UserID implements [ToolContext]. +func (m *StrictContextMock) UserID() string { panic("not implemented") } + +// AppName implements [ToolContext]. +func (m *StrictContextMock) AppName() string { panic("not implemented") } + +// SessionID implements [ToolContext]. +func (m *StrictContextMock) SessionID() string { panic("not implemented") } + +// Branch implements [ToolContext]. +func (m *StrictContextMock) Branch() string { panic("not implemented") } + +// CallbackContext methods. + +// Artifacts implements [ToolContext]. +func (m *StrictContextMock) Artifacts() Artifacts { panic("not implemented") } + +// State implements [ToolContext]. +func (m *StrictContextMock) State() session.State { panic("not implemented") } + +// ToolContext methods. + +// FunctionCallID implements [ToolContext]. +func (m *StrictContextMock) FunctionCallID() string { panic("not implemented") } + +// Actions implements [ToolContext]. +func (m *StrictContextMock) Actions() *session.EventActions { panic("not implemented") } + +// SearchMemory implements [ToolContext]. +func (m *StrictContextMock) SearchMemory(context.Context, string) (*memory.SearchResponse, error) { + panic("not implemented") +} + +// ToolConfirmation implements [ToolContext]. +func (m *StrictContextMock) ToolConfirmation() *toolconfirmation.ToolConfirmation { + panic("not implemented") +} + +// RequestConfirmation implements [ToolContext]. +func (m *StrictContextMock) RequestConfirmation(hint string, payload any) error { + panic("not implemented") +} + +var ( + _ ToolContext = (*StrictContextMock)(nil) + _ CallbackContext = (*StrictContextMock)(nil) + _ ReadonlyContext = (*StrictContextMock)(nil) +) diff --git a/agent/context_mock_test.go b/agent/context_mock_test.go new file mode 100644 index 000000000..f6346260c --- /dev/null +++ b/agent/context_mock_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agent + +import ( + "context" + "testing" +) + +// fakeToolContext shows the intended usage: embed StrictContextMock and +// override only the methods the test needs. This mirrors how out-of-tree +// consumers build a ToolContext test double without tracking every method. +type fakeToolContext struct { + StrictContextMock +} + +var _ ToolContext = (*fakeToolContext)(nil) + +func TestStrictContextMock_ValueDelegatesToCtx(t *testing.T) { + type key struct{} + ctx := context.WithValue(context.Background(), key{}, "v") + + f := &fakeToolContext{StrictContextMock{Ctx: ctx}} + + if got := f.Value(key{}); got != "v" { + t.Errorf("Value() = %v, want %q", got, "v") + } +} + +func TestStrictContextMock_ADKMethodPanics(t *testing.T) { + f := &fakeToolContext{StrictContextMock{Ctx: context.Background()}} + + defer func() { + if r := recover(); r == nil { + t.Error("FunctionCallID() did not panic, want panic for unimplemented method") + } + }() + _ = f.FunctionCallID() +} + +func TestStrictContextMock_NilCtxPanics(t *testing.T) { + f := &fakeToolContext{} + + defer func() { + if r := recover(); r == nil { + t.Error("Value() did not panic with nil Ctx") + } + }() + _ = f.Value("k") +} From 90091e2a7f80cf506d602b12d593f8eb0b09af26 Mon Sep 17 00:00:00 2001 From: Brett Dietsch Date: Tue, 16 Jun 2026 09:09:02 -0400 Subject: [PATCH 71/86] fix: Allow manual setting of session IDs (#721) * fix/allow manual setting of session IDs * fixing missing return + session case issues for inevitable test replay * Changes in tests * Updated replays * check rpc status code instead of string match --------- Co-authored-by: Karol Droste --- session/session_test/service_suite.go | 13 +++++++++++-- session/vertexai/service_test.go | 15 +++++++++++---- ...Test_vertexaiService_AppendEvent_ok.replay | Bin 3915 -> 4115 bytes ...nt_partial_events_are_not_persisted.replay | Bin 3382 -> 3584 bytes ..._when_session_not_found_should_fail.replay | Bin 1330 -> 1327 bytes ...Service_AppendEvent_with_all_fields.replay | Bin 7263 -> 4375 bytes ...vice_AppendEvent_with_bytes_content.replay | Bin 3994 -> 4204 bytes ...ce_AppendEvent_with_existing_events.replay | Bin 4435 -> 4644 bytes ...est_vertexaiService_Create_full_key.replay | Bin 0 -> 2750 bytes ...Service_Create_generated_session_id.replay | Bin 6668 -> 2756 bytes ...reate_when_already_exists__it_fails.replay | Bin 3519 -> 3854 bytes .../Test_vertexaiService_Delete.replay | Bin 3649 -> 4869 bytes ...xaiService_Get_error_when_not_found.replay | Bin 635 -> 633 bytes ...ce_Get_get_session_respects_user_id.replay | Bin 3924 -> 4125 bytes .../Test_vertexaiService_Get_ok.replay | Bin 3390 -> 3622 bytes ...exaiService_Get_with_config_filters.replay | Bin 11049 -> 10524 bytes .../testdata/Test_vertexaiService_List.replay | Bin 12888 -> 9369 bytes ...StateManagement_app_state_is_shared.replay | Bin 5845 -> 5188 bytes ...agement_session_state_is_not_shared.replay | Bin 4868 -> 5289 bytes ...agement_temp_state_is_not_persisted.replay | Bin 5084 -> 4246 bytes ...agement_user_state_is_user_specific.replay | Bin 9811 -> 7281 bytes .../vertexai/testdata/missing_author.replay | Bin 1731 -> 1728 bytes .../testdata/missing_invocation_id.replay | Bin 1738 -> 1735 bytes session/vertexai/vertexai.go | 3 --- session/vertexai/vertexai_client.go | 5 +++-- 25 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 session/vertexai/testdata/Test_vertexaiService_Create_full_key.replay diff --git a/session/session_test/service_suite.go b/session/session_test/service_suite.go index ff3b51bb0..81a7b54a4 100644 --- a/session/session_test/service_suite.go +++ b/session/session_test/service_suite.go @@ -22,6 +22,8 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genai" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/adk/model" "google.golang.org/adk/session" @@ -87,7 +89,7 @@ func RunServiceTests(t *testing.T, opts SuiteOptions, setup func(t *testing.T) s req := &session.CreateRequest{ AppName: testAppName, UserID: "testUserID", - SessionID: "testSessionID", + SessionID: "test-session-id", State: map[string]any{ "k": float64(5), }, @@ -385,7 +387,14 @@ func RunServiceTests(t *testing.T, opts SuiteOptions, setup func(t *testing.T) s SessionID: "nonExistent", }) if err != nil { - t.Errorf("Delete() non-existent error = %v, want nil", err) + if st, ok := status.FromError(err); ok { + // VertexAI fails on Delete when the sessionID is not found, we accept that + if st.Code() != codes.InvalidArgument { + t.Errorf("Delete() non-existent gRPC code = %v, want InvalidArgument", st.Code()) + } + } else { + t.Errorf("Delete() non-existent error = %v, want nil or gRPC InvalidArgument", err) + } } } }) diff --git a/session/vertexai/service_test.go b/session/vertexai/service_test.go index 0f5286818..a48328613 100644 --- a/session/vertexai/service_test.go +++ b/session/vertexai/service_test.go @@ -31,17 +31,23 @@ import ( "google.golang.org/adk/session/session_test" ) +// if you want to test it for yourself, you can regenerate all by running +// `UPDATE_REPLAYS=true go test --run ./... -v` +// You need a GCP project with enabled Agent Platform API +// and some deployed Agent Engine instances +// To deploy an instance of an application to Agent Engine you can run +// `go run ./cmd/adkgo/adkgo.go deploy agentengine -e ./examples/agentengine/main.go -p YOUR-PROJECT -r us-central1 -d . -s "Test01" ` const ( - ProjectID = "adk-go-test" + ProjectID = "adk-go-e2e" Location = "us-central1" - EngineId = "5576569044451983360" - EngineId2 = "8602987994044956672" + EngineId = "1491331942182813696" + EngineId2 = "6857370898194759680" UserID = "test-user" ) func Test_vertexaiService(t *testing.T) { opts := session_test.SuiteOptions{ - SupportsUserProvidedSessionID: false, + SupportsUserProvidedSessionID: true, ProvidesServerAssignedEventID: true, AppName: EngineId, } // VertexAI forbids custom IDs @@ -158,6 +164,7 @@ func deleteAllFromApp(t *testing.T, v session.Service, app string) { }) if err != nil { t.Errorf("error listing session for delete all: %s", err) + return } for _, s := range sessionsResp.Sessions { diff --git a/session/vertexai/testdata/Test_vertexaiService_AppendEvent_ok.replay b/session/vertexai/testdata/Test_vertexaiService_AppendEvent_ok.replay index 743e91c354df650e2f59ea98784c21b9002a8b4e..eaf302fd67ca4ea2a8ed6820adca09f19650f5aa 100644 GIT binary patch literal 4115 zcmeHK%}W$v822654_+%}h>$5d2*GB!Gw;mKm)4=o%)XTMRJU<`T~^nbY2VoeZw3_= z1brQ%Ac|0g&VdJws8i5EqeG|g6!;H_P-*YZ?5w+%D~_^4wXlb07oPX|`2Bv*^Thf( zW7tj^V^#FCNWb-fJ;5g|E0MyHc*@EQMvNr=-x;#fqme8cz>a|;{g{xXW%lEAHW|l! zXBrz0&i}|w0C?6JvvI`pV%SL%iCd$aIxX=M!&ftyIP7J3$xd6tIPMVM7#wL!SZ!Fu ze9DR&4vm2D8PXQVrjs^Oh^Ju?%S@VyE;ErdG2u~EN0Nl}sE9OCLz1E^Y~4UjhTt^X z&(LHU?rsPQWui@4W+I(2b50|@Hcq>*vR|s<9Z-l%(PdrIWla!;Z3M;>uc(AqWKop` zU6TbB=`zw(k+=M(39sss5LFbUC{aPx2CZ`db_97i4;A(vfFl(wVksFc7C#%;%A+C-mOK)) zZ;ID-B<1=XeIQpGz;wrfx#Z+Lb)QghLUC$ooc8be=z<2@yhDzD!5YB zo8t?^^}z0J=SFO*=r~;S?@};m<`tad>PK;BJ&Lif0GtBwW<_@xoD)I1lZi-;s*zdtWfYcIVO< z^G&AnX18Z?DIIoZ7B~Fx(6Hq*7XnhQ3$AjSh}Cu7r-S+<25bIEpNKa2$h2%RERE sbF6{PP^4?rcE-Yc|1#N7G+C8YLDMyQ(Wl$2Z#MAhLa-UxSiQHXkC;TzLLenV`t%%2?h)Qdq=%FCut%TsrX4pt zngZdoWH^p3&oxZM(>#c6rL1JsN~SDK_(()mB8o1^vK&FWCP|9G_RMu<33ky5K&xfA zRR#(T(lu=>>1M4$(D0Ol-Qri-8w|VyDv7BGiMk|<2xC7qiy&Fh6hRYI z8A+-l@OJs*gjXd!q98?7RYB4;9SMTQx-izDiY+Hu*Yrw#7wMD}hG(#cA;Vd;G%FKn4OAKqNO%ClDD6|H!qs_wNq~l;K5zSFLb6){G6Qr*-G0MPL0_{Zxdx+~S zbaALNW#!N*+MD~U?^ZVQ`8=5Im?}_);m*N{9al#js((1t6_kY+Qp77Zn{Yu{SPfBb zcAHrkwDzie1g^arVwY;q`%qdB$duaI`A}k?0r(WaTW#KKxIlQq%c`czsw_q%P1XfT zRwJU%qrncJNn*z)E`@rEO2csXb|}K1cUOe>mNx9lhc`bMs>0id?jI`sR~7F4?l0%D zW>vU=i_&%?+yf9ysA_|h>2ay$UM}SC;73d_$623bi348%{Kw$XlBL|Y;lrV6bAK9} zxN7cS1~m6|QOba5^R77Jqvc@`!@maLWHUCc#;wbmEDBW2G)J0Ff_9B(nS?|%~xdW{lwP1SXo-UI2) RNm0dVe|1i_PB%gaegl%7A0_|* diff --git a/session/vertexai/testdata/Test_vertexaiService_AppendEvent_partial_events_are_not_persisted.replay b/session/vertexai/testdata/Test_vertexaiService_AppendEvent_partial_events_are_not_persisted.replay index 6ffdf6b885e73b44f328b260ba51c5915b209ed3..54f6fbf21c2306da9834c86b164aa92d91042d5c 100644 GIT binary patch literal 3584 zcmeHK%WKp?9Cp%nTgO+)K{SX5LD(uyGI>s_p6c5As%3lfnB7j7?Pimh$u9Kl(Sy~i zAfgmJi1Z`~7QF~wD~O_?(u2^yz=H_RZZ`Xnb{EsqiWtb@w}IdM9^d!Px7q3OEb$$C zVGn!O**gUIb5gbXU?kZ}t|mrx057E)DYX$ry$Myg>M{9taNLP>xQbF5iTxZMxs zF4a1oTL>y{Evb0TCqeyG{$n4!1)6mkmTsw*j%7vOLSYh(hRUkOvTm4)hAqRyriHPF zB`-dkN{WgVSw)(nYuMCfRW)QjgxM}lY`VvekvH4B%62?!hc~9y?pUtH8s5W z{`DFVI-(=s)GB~yk{sN?3V#PcXD8>_Y+(7x$`^1VZl@kKaFGVzBN~kLMC@-gm>^-y zfoA|_lFX%*A|^l?mYM_F76Np|*-=m@#a(V|Ykj+0Yh7Hs`Kh(mdXz=*5FD&geypVM z{Q_52;6a$OeSfTk>M?4@jqeR4%rkgATKbwAUVi%H14#EJ#l@8(4Kr0HQdCHai&l!= z!9~*6TX;^~dMm~5v_4f+15TKP7m^UVeR0%h7Zynm{3ltBgd3AA^*IvXvwteSwLv%3 zmkRd)oJhK@I7y7Q>FstPBay~F_C~WL!W91wz`sm$V~LF=x0eQQ42Qix6I#;q z#33z9`aGc}?Z^@XVrOsUlM!0(a|vwfI)E3O=ApMsZS$B&Q7j}|mLg*;8@i!ljW5H? zNjMeFa5HoHs%|ddJpQ)Q$Gllv#LVSsIMU@n)>!D}Dsy6{?1u~KegBwn7+5z|Q^po% Ve_u@9GO#=y-9(qRPdCyBegVsFxCsCN literal 3382 zcmds3O;6N77`9zmaQq@YnZ|hVU{(xmr|t9u^l%_^YI1aqU z6YRIa{sV$^Q>{c%CBRBKh-$Nn=d<^8E=sCO9nN6tL1h{f;z!{$PU?OctD_0_C~hvw zD*_m&i!oMG6fgFPQjV&7f_9;l@Z%aLRC)v^<0P8LWlB_Uc44>@4O6zR22t6gtOTOg z$Z#2lH1PtcvN}i<`eCIQR{RhX6(QR~maCbjiJ2(%vM z22of^YGDIZnT&C=wX3uwg6Bf3FdIVMHB22s$Nh)ORFWPmQ60@O4HMa>?KrxtL8O^# zlxe0onOm-c5tH1k<%BDhQjYWJdO6cbjC!FwOMxSpY?z(%TT3SV9oAI5`-x^rS>i1@uY;*Lmu1_Ah-C zK($r&VMwW)!^;_?Usito$dw>C2l8GVk1XiYNm;Dx;5_JW%u9PA`v7iz0Gs19&v0HW zujdChKfL~!E97{UanC2TSZ5PWWL{;oA&y8HyuG(2&E0z&;)r^G&~OA%9tU@LhN6=? z5;K3tg}!4tXM4@n_Cefv&hkG~2fndi2X35n(}IZnMgU{HbY&o!-X0$Kl|7Ui?K_b# zSafz;!l|mH?*jN=jkoEm+xh>?h<6smso$I_?lul^JH@SWrnoMYOcIBDF<(jb@|Gvr zZ3tjuKeus5EMpPVFrbbgLOQf<16ht#1$TMoDm}&B^4QZfkG*{MUSM~5$I}I2=o+}% uV?)*%jb>_dO0gQ##lq?1W*&~E>8@kDuE`?5i!965r_%dq;^4faaP|*(AZ^zG diff --git a/session/vertexai/testdata/Test_vertexaiService_AppendEvent_when_session_not_found_should_fail.replay b/session/vertexai/testdata/Test_vertexaiService_AppendEvent_when_session_not_found_should_fail.replay index da1ac3c0eebd0872423b9a0301a068f5f36f22ba..9edebdf48a8ea99a92c089526f2e1294e93ec488 100644 GIT binary patch delta 213 zcmdnQwVsPFD8M-=wIC<4k^u<#C-OP7oMq&4o4k?PWMZeVcoQR6azRmkR%&udv3_Do zwr+a9ZmLo03&$jqk5AGTxi~huMX3_GCvEZ?OO_ lKN7T0zQ+>G#7v}qGYeC5V{-!wOADYA%uOxLEDV79LjaqcKV1L- delta 215 zcmZ3_wTX)_D8M-=wIC<4k^u+=Ch|G6oMYs2o7~7KJ+VMXqM4B^rJyK3D>b>KSU)i( zTQ@ylw5%*>4@-)0E`0Jr=>*#H0l diff --git a/session/vertexai/testdata/Test_vertexaiService_AppendEvent_with_all_fields.replay b/session/vertexai/testdata/Test_vertexaiService_AppendEvent_with_all_fields.replay index 42dab8697bc7827013ad3c7cf22db5043a91615e..f36bca6461733ac0a0c432dced7a0a119e630bb3 100644 GIT binary patch literal 4375 zcmds4O^6gn6t>!RcT?+c3&AuYh!7Yz>~vTER8cr= zo41g7Na8^>f{3DCb5RWD5Xd3s7DEF0fsjBBSv=(wFJ@ESJv~4E>|pOQ8yx7FuAb&q zz4yNFd+(KI_m@cMd#!civnZ}{Aiu6wDXsdXSoUe7QuJ!#e|~~S^=TkQ7rZURYzrGOZ6Z+GnLi-fs}b<@eybVMI@oGH0q+ zS(vKQDWVhAr)7_e6EL;GrphGXk>?{-9D~uI7F1_~YAqm4MW%}k1G%P-u#S;oyS6+w zzoEfMgkF?{vy$@h2-KE2txtn$)Cgju#iJpKl3nFr*26QPb1vJpT*I}nrfVxWn99zaY#TPb_Z6RHy+1^amv1PiNYhc^3k*QgxsmUCcmT}^#dF%^$$Gvw%jw>Gb zt5(uT)p=D=+^V67ypn7 z>(9l(0rH+_#l;(8NP@~tQ^=I^g9H!e0Y;CF3>hCvLxqNX0VpGJ*miA)$*(*CU*JNc z=QugVCr4^Q6Y1OKZI4BF-^BRov$wxK|L@Y$5;y`$6wzp|Oe>_cDZQ@?;q$8GxCuL!(dV?0SUQ}T*H*F=`;;fg?B*Sq4C+x4!AwQ>Das(*jyWNB~aQXt9KC3sbW z9}Kr!AhFgk)v#O*JDTpAPTT}x&Cvh16fTTCFSX}x(l34Y`OQ0^r}nf<%fMRn2HGG5NEc4NLfrQQ`O-}KOb#y0tW1aQq;k$yM~YCRss z$CKeO@i6D4c7XV#JE?t;Nh)j^g@n?Tsm%=H@Fz08KfF;3?jCyF!mkhcn( go-^UF#ZYWG8g{WLXpZIDSes3ozay)s8-*?Z0gR)yC;$Ke literal 7263 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 literal 4435 zcmeHKOKa3n6mF*Nv|b;r5wrwX#l=|ZB=_cdTSc_iYE@dNyYABI?J(_35_2;H-G~pA zQUym>iV7+yF5M~m69lQ^MnT1$xOU-6yfc~1qx3=3Iu$XH#ZBPc@0|0U@0^nu9Y_!- zW6n0Q=R14bIsTBCvh7rc#FH62HyJn6?7uf&x6BaUlNlcYyQb8@CPW%qjQyO^<) zrpF*qF-Loo#PVD-14V{|+E&_14O*$RMX0E%x~6JaQWQmn*pOvS;@c)#bCkI7Fvq-& z2rnW)S^;jxwo-1+DjJQCIK(a8%3qFv>s$qx4k5y_f*>^TI*^If4=WXsTm-I52pXEM z$&!kZXa|>5QIT|rRjjK>lCh#Gn0e)b#0p+4o5{MMSNuD}vOH;eCchU5{MPolr&r!Y zJ1=~FAPKdH828gBOy8=xT|}56;stDZ&Vj)&z^f(#s#qXX$;ie~WyOT-@##~Dfc`M} z%l`8V9LQE+H-boYDeMmNS$Ou@OhC{9+KRQ&H-q>VA(BU62Z)-E(>DW4XIMFx|CdW? znwhyV|0&v8xOFEMYu;514afJdl@>=Fhgg$?c~;KCTMir#6W1CW0Wq#}jb#SETWBdp zv8N?%<>Aq2`^9VTUPZ^7MvB!E5l(`kbvs5KCVeE<`ajauNFx^MOMfcTZDFPEbeei4 zMpGfI)KyIcjBc>hh4sCv(V=~>n%E@YANewWw>g{~)#lDGT7Dq{9&=!>!Q%rdswx$A zRh1MOX-GwiWXOu77`|9_H~DgfnyP}ei8!Hds0LD@gpj1E7(=ZdCo17AmwRkWm!GAZ z>ukoX>1<{*-d3Kp3Lk!aY-Zg!Xud%+UadPX*^R*$e@HvH)^1#+Zh1QhGYk4rTy9;PFRg30=U$&@ zvwro^#q}Hj`&YP*`Q_OYxvBVI*70Uz9sine7@CBzp<~Rto`O|P)6u9uk`Hg3Zp3!} E2Bq}9iU0rr diff --git a/session/vertexai/testdata/Test_vertexaiService_Create_full_key.replay b/session/vertexai/testdata/Test_vertexaiService_Create_full_key.replay new file mode 100644 index 0000000000000000000000000000000000000000..e716abd0a6d56a2fc7786714981150f1a4a0ba8c GIT binary patch literal 2750 zcmds3OKTHR6waO2+Fn8HMMOpsQIM!)UPjz~MaQd{ltCb3 zo#ZOmrGe?lBEvyE*LJOO*RowqM7dzd`Mhit6j@hvIj2kG34owSu}E78kDhIB23n-}~Slklx}->(Xh3X1BjOsMq8HaCVll7jlGgk(6uqbvbQHjH5Byzs-LaOWvnz}TRg;?N z5aiLW!KX`4J`W7veZMrxw+9En$rS)krzzWF6IulD#&!)b!G7!Ki_+1!kuc~@nJoCl zXv8jOWL=WC+KMlyglX#u{}RCQG=o}A5#Njrtz=?baxz^Rw?7mNj=#rkZpWYg#*Xj5 zdvhV#aTt`PwX(qC0n_(KYBK8QxrUs?N;6j4Wn+%f<=OM)f#I(oR?cPnzD0yMvr$4^ zwn@wg!aoGCl!g-RNn%n=ZMBXbk9PlUoM9HTD){zF{ox%;(T>CS~6zauVe z*jC4|gI8_!RZ3g!$P%+*`y}F6M3YBc3itd0;6$Qi>zikr{mB|LK+RAzt)Lpr|7BH2 zHFzn_F-7z1W_*iH<6D0H_8>El)qok_DR`nsIo1~SX6m!T_`FXSvOE7Xy-@Wcd-;%b YLuc<^TG3E-X)3$|j-T8*xyTOv0Z*)?xc~qF literal 0 HcmV?d00001 diff --git a/session/vertexai/testdata/Test_vertexaiService_Create_generated_session_id.replay b/session/vertexai/testdata/Test_vertexaiService_Create_generated_session_id.replay index 707357cc391bc1efecdaa76cdccd052929634a78..f85ca3c5036b1177b7f26a540b2b44fa888e7764 100644 GIT binary patch literal 2756 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 diff --git a/session/vertexai/testdata/Test_vertexaiService_Delete.replay b/session/vertexai/testdata/Test_vertexaiService_Delete.replay index 81f37ac61cd592afe0d5e6811a96ebc48a6d992c..00f0545fdaf95b8ec56ef506d61fb7523e79e2c2 100644 GIT binary patch literal 4869 zcmds5-)kd99C!1h?airI77vUDQqXKW$*AC3fT;M0Hu*K!ljs-at;r zG>~-7K{`djrf|f8*Xe#6iM|xQZ4%MIt&u_pq*T${E+Uk_2`X&|-$n){l3w4PZ{m5R zAj!gpPMHNna>=}bZ0hKiEHN_(woSWPvYV!jh$Lq9l3h#jG5GNj)$AOUd+*@TiuIp6i&jW!! zJ9F>r!|$eMf4TdhA)H*iz`gt}2d+d>8z61?|>f!P6b}y;k zqAG)D!Ia)^7j_jsA;dZgI0vS^;_q*SFUZ{&YF8a{88;ih|_c>7Rp?EY^o*=qhUd_sBHLt4KTrSHV@_D`m-i%UP zehmN4a2LRN{^@|>KKSzBi?}ebVjTAhcxi}2<`~60ZbK}!+H^maj7lFXCZVMc>tjTS zN*{d@lfg(-*ZZ^=+4a7N$+(`3cbW6|B)B*PD^A-GH>gfs!XGDRlKlHY!+^c&IDL+X zHm0zFUAry{-y}e4gt<#-$8DDF2IdbF{Ktu{wQ6~}Tv=R!rD}DpdJ8s@jU3Y`6qwjr zS8M^YQ7@SML`JU* zEM1ab?JMZ*4k!o*8?iT`GVLvFi^y@XL&QaMJs4e2Xftpf6?^h7!xo#n^d>4!HXWCt zp|jRhv|Y=BTfqSCL8j|Utgq&$fc%BUZac& z#eZ7|&#}|StbuLH++zOQ25XpgCTQl@v2@(e$JucaFixvbZETnJSO;MIAB9Td{HjO( z5I6kNhCccWXJqlZiL817+N=bxz*QgU1z5H_x@Fd3h#3bF>^cVOz9*g$Hiz~+50tOz z^^j^);6;$|6z~^@%N0I-9K!i6&OeMlns7|tg_`a(-4=`6m}sF~d24Z{yi{APE^n@u zDmVJl!R1h^)`M;url;!oHy};H}~t<5ti<_Zmm0OR$nY-$!5-kbTc_I=Va|7 z;sq)qB8aa-!3PEZ0kQvpKB*w$ljw7w`dHAaXC{}~5jtyQ?SKRGkdyHJZr}ONciinW zE=(ft@E-cRM*mHQ>qr4YK?FrVLRnw*Li(NzP&yP3ls!m1McjcHhbZ2G=|Sj2=}Zbe z0*gidGX^|I4ihNmC|(j`(MQ8$L8nk$_-Y0*;a&l~Bt;jYPq5_m_g4e7O6a;2A>Si3 z1uSKF)rT=jy-1O09vH=89CYI#j3Jf`!!ixik#$`+6vx&yQ|6ZT8X1PEvc}MAS^g>u zK0FbpJ+~4Md4c>%#%-nCuRhsl@YX; z4aDv+A&0F-T@^VOV$waJ>W}%lDl&nX6HDsWK9RrLwND`CRD1Kp_z3>90Un#dmNfCj z9paG;b3Znk4erfCIfZJ2+y${0Vnl=i%HqDrf42Zy$K<;X_p)F!9w6@11@6ki`JTJE zvAMN&PUyOB&%Gc7FotR9x7!qUcMRJWs7We3lSl;^i-Xh~!q-r`FQ&O0D!S0UFOAB*LOB0|u QmZoNA=0+3W2QxAQ0QLDVmH+?% diff --git a/session/vertexai/testdata/Test_vertexaiService_Get_get_session_respects_user_id.replay b/session/vertexai/testdata/Test_vertexaiService_Get_get_session_respects_user_id.replay index f4615a8a16729cc060e7a897c8ca57f4b752a29a..eec25c86ee6671f7b46a9d387fd7d310b5f2ccee 100644 GIT binary patch literal 4125 zcmds4&r1|h9QV!oV|iA}9QrXu2O(GucjnFP{L(s^tH5#dq)fjX7Fo!QwRl8U3O;0}9uGmr24{`h=8-}j9V z_QbKBHYV!mZ;k%LfIZJAEi0ME(L~zHjYN$U{qBrf*-SK#hOuLy=ny6(Wtl@bn@=S$ z-;>3LgNrY@c>u3C6E==|Rt!5uq6sUrt@|}q6B8cAbR zhr%zH=9U{K^$3e$3fZah3E@Fk90We+5JeC4jnVxwNYV*T!S4CVzz;BNR zUE3md0}cAH<8$B*fW1NTQa0feAOpus3EC3^Y>BfIz$L{jv%9i>-LJCFeg1N{Tvm4# zNAL)2G;F(TTx9DxCNIJk*i?{!@(X_hayOtN62>SzUd*pFwyxe=T4y4mV};}ZwtJ_z zxM17Z9O=zdnc}_ycsU3#Ds1@3cnE3=4fZ5gU&vZVeag(Ei_PK5*|#5`{9RvP2e<16 z3Y1~Ee%QC`;D}9S9Y-31vhaF}IBu>97nFr%6Mn91%fg_cm(?S1=w%c86Z#(yNWTKo zq_T1zkk~~47XZ9p+jfl$WGB3=ijpKMu~jWLJ~;;W`g+>#HQ?t=usd|M zk3|O1p0n=_xf%|BzujF!Zg#~Yz`Sw^r*U~+c^7bAsj}h&qPC6VjE9zIOn@0?4Z!}= z&2sw@reXk*6it#c_hnr0vuUrjTn0~y<(2IM;icTJ9Vfz*b t(!d;RI5!&Y&DhRFnyNfqdrNM~DB))h?{^cv^Yz0-|I%Rdo{Xh;A6 literal 3924 zcmds4J8aWH8198WqAQQeQ!$9414@e;pB>w0w=k3v3gyv~Zr$AE8l_Ea<8vZ)gP{Te zVkip(5)wl}NDQ!0bpv*UK(HZ@03nr$0n`B+oH)*-qN(Cmsnkdre6jxfAK&-=Ut;6K zF=D2*xfbWM;(UXEIl(0jBbg@Qc-qJ&!dlAt-JUkAOgP6+5nJQK6NJ)~p-&Jimx>c^ z*dm%uN{8$sfM@JElZ4$Unwg^ExRKd3XibzDxtt}`W+vg7X&EylZc|Q6%=RaZe%m?c z(negfofIgSrTuZD+m@E*IVTTl=qWuJ)srcmP)?GtEXgVoMN#5aB@&Sl(=pYSrNrWg zfKzRNT@FB0C*8E6C#|er2pS$UiB-InnGe8Qpqv=yc|nbc0?#Yz7Bb_g+g8fSlB)0$ zRxnZ|BuB6!h@9bFPB{@_j08D?cv(P#Bx6}&Y#6Ik#fFotX?nT73(k}inyoQM;9+KO z@XN#b*X>=aKVSR>Y|T>$th$gH*mSzLFjQUBv62b$E4%6kCY!6uKj)Zjq zbSD4@{p4S&pLqvhrkr*&pj11<9&e(b|GZob#CW(Lwri$2Fe@;vY#=AV1F)mOm&$?n z0$+TAFVShniF5nL$M&vA->?1#p*`J&?f_=6QDh96CeahooWp7M1AwRfw6-dShmD({ zx!_<9v8{!+^|hw-9DlMicx7Sf)!nsxJ`W~a#tN(nuxH`OmaC(tBZ4H<=9h@)Q`9at zo3eh1SP9`ZyU9fKTYE)8eAiwHu|u`zT_~;kWJ>kyTqrS50DKJK^(L=3QXo9#1QBCi zz_KVIgjI|pNOXDB)8aBoXj#XlP{%=6hZnY}!*6$3hdYB`i+gtI*|m#RGF(#GO=bE2 zG93KmF6iTp%5VV~r42>dd%&MkRR{6-acSEfU9UU;2LXS+vo^^R3El&^EAh~|=Hj7Y zkAI4!NyXzY`}FuVQ9ML6d0QNH;d0-P?_U9Mv=OgXW7?4_Vj+UCfK*vkBZ9(Y;5k1A z!0qBjZv9(PMczF9^`qd{tDd?+-!a%*=R&74+nCBuhoc$Oo(t{UZQ7y8NKh55s-p9U TP?cmE3*+wk9Nj+Y2p#wbQEV;4 diff --git a/session/vertexai/testdata/Test_vertexaiService_Get_ok.replay b/session/vertexai/testdata/Test_vertexaiService_Get_ok.replay index 386b3a6fabb1e37bd02313f0b7d7639710bf7346..b728c9f0a9590de67126d3f70f57847a86544329 100644 GIT binary patch literal 3622 zcmds3O=}ZD7?SEBrQ6U-1363r@67YQ&-1*GGd1Gi$aj|x zvCkj&?Fal7sT_u7AB!bFtj>zA$Nr~tVO$Xxl^IN3MV!WjcwsP&<3+E8rI8rB6xV+g zUI92xmm(}CRouuUVkxX_TePK$gYQ%^p}Y?#qd1($B}ycBc44R-4q+8bepqrTdjXNE zWT=D#8oRzCv1gDl@PcwND0=}WlA_y+rYW|rDo90&X4)n%I0FzuZb9LD1&#rNl-+Zi z;CNzmkYo5cVYwH|9RT=YP>!p?<~`!A2*JM->g;P7Vr%C~A zBw`ufme*UvGKOiYl$QV&^R%i_B9%!6E@VCHicC6E?-Z!%C){cB^= zwUcuZ9*2Ey6b+}wke$_&(@YInmaemmjgVy` zz5=i3(NxmIc6oE9mN)Nx+IZ2!;#$**<;@Flu)~E67!7Btb7HX)(WS!C17;j1GAzxK Wk&V~@EW5LZ7&cLtI;~Q2Q9|+HLD!EanQW3ts^Xztx7Lr=6n{Xvoi1s!nV6X^^eiHX zs3-BD=s^(t16IMmAO*dOdiCVdi{fmv$?huM(zvwJUG^|pc=EpUJkR?+?##H0qrh9) z!#_XyZ$OaFt2IJu0amI3Y0N90&;Mr&B(5vVa1JvMDzlhUpMq}dRaY}+&pm837Hs!=i={?(A9AqygikR`>y?a{@C>vMKkqS58UeC2 zIPm4=?e~S@?`toHpL6Y*V`b&`<@lR6wrY@mf<@0Rkz6spOe{pZmYAmdCjfiL**v|sO04;1xdwrz3dntPiw&2^!qdf4WDd^{1$vpjL` zn*gS^vV=Rb7=pINLzrzCCFq#8p_>-Zv6u6hD!IkKL)cL;A`K0EO+wha2Tw)0>vTjo ybsC)P@j~7iBXf-frCg8LN^x+vO%B`AbjL;xPsE1dn3jd~nPd}9^>1(#5B>(0AaFMT diff --git a/session/vertexai/testdata/Test_vertexaiService_Get_with_config_filters.replay b/session/vertexai/testdata/Test_vertexaiService_Get_with_config_filters.replay index 29fd3132a94fa8b754b309f3b5a60cfce54e9e68..2e921064e6cd0a8352a582c2be1da4d11e02ebd3 100644 GIT binary patch literal 10524 zcmeHNU5Fc16wb`m;r>YOL+1rqAGBf5* z0_&62`s7RXNs)@8g$jz&mlhOJ6cir{t#1m_Cq=2g_$uO^zf4lIyJR*q&?cKK?Gn}wbs@it7PD+)!-C8S|HTJ!G)NVFPZL~^U6O|4V zN^7=tm^9n93X%3ViRqH?kK#uhc--A^NGXtFIyG9V*o|$4_DESFFSZDEg%`jxPSZX{ zDlV1Gwe^Lny+AOL>UPC+nFds9(S-`J+@@Ja5>tcPR?Vs|S=E|FsDu;)$ucq&jC8Cc zSv6E)dUdKrNfU`YMSpY5U|zg30pJ!I*KMoXY+0V*(jkX5{U;0OCcsM_)l{Qw$VOR* z816tfi3Ye*i6Y64oOoU2wt57AkM*xgL-^#yriux@Nkja2MDm z+%)sa7w=un&%O26`xkk!=QPjl{)z*8lRP+#4&h}EJeMMD9eq#!@%;t95NYq9LdO@m zUxPSb+%_byit_`+je`6%2bPk+5Q~T+{CRMsgU3)JygQ0!f&f3x@62gmXXLc=KcD&` zcA9@$L0}e4d;hVE>sXi+`8Ec(fV}BAi|fcgqs}?}KJ@c3=KK#%U(U~+|M8#i_~Jy) z!-5m`4(j!X9fw$JOKp~A#4kDUSQ58)$%y>!rs;R#c5%|fZ1-f%YNJQ;Gsj=Q^m+bB z?vNMxym$~C*s&GVVd<9?r$!pqSJHxYdZb}}H7!_g7-?8fqy_7ZBMs|oX~BAP(njT} z8g>1GMa86zN-QEOSgAHDNiB{0sl=AXA~LbmnD9&R*?h82qT4S)2^M~v0&jAl-RUAz z7)FTK{ZL6(a9LJm2z3RsAq2}%3HppeF6cCHF77q5QcBfz1!1j>F;-L+%k0%hsjyOK zrIe46GZqo0%oALiyRO3hZmq%Y3e1M{(7pm@&CLQt?ENf+i{2aW@jnzs zr;2>Y`y`uZ)BbTtpZU4CKXZLje_H4Wp zqS7Ecp*zP_q*Fz9{Sq)69vt+J+w0jwY6BJD;gVescMFMH|K(xxcjf~|!y9}ul2NaY z;R5|z4rI-6=)V!%gwXIm21Ng%@9rxJ`CE^YI*IvPXA<(adZa}DPxaF1z91IfSQE;PWCNeC4IWSh~j&iY&9&%XKhj7P)jIx!m7 z9`L{x4`hb9_G;^BX{q738^wFBv$UY literal 11049 zcmeHNO^6&t6yBQb&aUN8mR2;j!JiSrBw?qks())^M2%}qR5ngL<<>h}874c^-A?xm z>`mjYf>94jE+Pmjdi5lR;6ad(lZ1c=LCHyxRm7WK1godJcczosS*J5Uu-iiqO!dI) zS6{uZ_q|tN@zk**bE@X*1pjRC?+g%*N@d$FS6QJ{wHwO?v%>%P&f4`_p^2B6XX3(X z=DHQzI?d|MN{LCw>df?5cvJiwz~{YHhZO=Vrc-eXCA+q*(U_GYd$qw_PdEb?ow|LF zl{{B6msjS>_MFG>OI5pMdfWw9YPfSHW_fk9iY4xcYg-koykM0p7IP(4)iqV4NKq6O zQ$vkwpFe-EZ=Bh(P8!0Q-!q*yZ|;G(=jGgRtUxh zy$#PKH-MG9t;k3-u#OECBcxK5NOtsWS2B=B6-}3QObmq}tms7WV6n#}wp_><=9|-d zi7)c9>6yaAaKCWZ%&%Xpf1jQG>*|&7fEXL)!QVl|%x}B6H$}{G=0)N16@Uv#TyL9* zVxpVl@&5yN|6)^wct z6)a4OnPz)TT4MtD!fF5InO_NSgzwThZs)zXf6mTcyZH4tAiqcO!2!aNe&gb4$6?m; zLX*GH;xz!DOM+~NjVLnK!AL_xxL=&~Q93kPv6}dq?9A&Iu3yfcnOOASmW+4;9>48* zxenj^Sbi!6ItKLlVF5jz0v!YTjbQEdQcq2Q+gzBv{&4ZrABvCb=8qEJe;ZeWJ|V|-xBm?Ekeyh7)-IHocs zs0)~3_8upgy389OybHp;G*p7ME?$PIFF(7Bx9g-+Z?_>5XR5Y|@nouWGmbipBj~?K zVa`Z5v>E_$2=6sTC3W&==8)iYSV2ZM789^ADDyvzhEBsksZc*?1vo;0WIZ zX6O6&o9~-%)04Z?q*O5HucDuG^ly|Cc8OWr&K5{2Q?M&DDKkfZcW3Q#F*OILiEF~t z6mguKZB3E#TrNYz-DP6Br1sDFF%CTF&X-7vtzwpPPAX#;+b-I)N}4=XA&x8T2alG@ z_5qS{9nqZ0k7eyKA``J-XH1vwz!59XScX_`*(^YjZo{#yoRyuhvN?-5B2+O{6o^$B zA{jwN$GR{wJzQ}}8E)gLvV44T2uN$}wqRS?a>eo%O+8j38@g`|Ich6}K+!yA-wOA1jbD;2l+A&bz z(3vAI@(Ev^s#J@MB=;vvgYmWzds!OnA+DbT?{Q!vj9uzW_z6%1`M^MtB*2<+wu7rC z#VdSgZvA4T+cr+9QCE1W5L^`6B z#IhumyMo7^ATDekeW!}YzurChM7&X;aQv)8I4*WGi&|JQ*#D_dSN11-Wjw)0r(<+g{@YM4kUF4ISFR8|IoOI-$6VXbqk%?;#Jdo zXLcpLZ{_IF(t0Ch$6!TDi%TfbM1}wW literal 12888 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 a6-^V-L{>wyOog8)bpuWw?w+8=?)?Xv;hu~D diff --git a/session/vertexai/testdata/Test_vertexaiService_StateManagement_session_state_is_not_shared.replay b/session/vertexai/testdata/Test_vertexaiService_StateManagement_session_state_is_not_shared.replay index e0f8c54993b93fe1699b427ff69192af1f9b6d9a..d263387e1b4126274f995ecf24b5e03356ffce89 100644 GIT binary patch literal 5289 zcmd^DU5FD`6m}+Y-QHDK$CnaHAB1AmZf5SxojWsXAJne@x=R-NP<$D)*AO?EnPw&n z`_@Nk7as}*LDa=R^g&QdO9d5Cq3w%cw@@f5i^@VNJ_t&srLK2oGMP!qZrDlUmKw;z zxi{Q<&pF@uzH^ct+?pj;Q6F1GKQHLFo8dm<^QM_El1#2>R)#Zrf$lp8&2lL-3J(xR zhnXQ_+Xd4YBIVISj__N{M0ZGaF?E>%JDf3#Wc*Wft6*nxW~u3+b*E&>XBA>Q90B{R zvU!N)9GlmNNBZ(+9}x*(G;_K`UtseUyDvu!r>qwtPoH6%M#0GU8~K7kY#t&FN)ptN z2vt#q64o%cD!UByfP_5)xnzo^9>N(an$x=Q*WBAn^F6FPI`=+!k6YUP^R&Y`Vok+WJW4Mj;h0JElBF1q4C}pAGaW7=>alS7qL6z_#>sfBx0?w~xM>nVDfeT{Pg97n|A%wzur9ZBc0; z>BZA+5wuh76tqv;E@(*?c&mH`8;_%4zjjo?R7uq|Nrnii8bTNef)G_OhyGLcK~&dMb74k4Goj^g9oIn%p#B zmD*FVgU-fIQEiDu+|feR6Ng|&fFDB!SCiQRh)rFf%fd3dc+SS5Dzybd1B>o#r zH?^)jXxXY1{rLI~Cc8g`^KAHTS|QQd&+h>1D8AMNICrVo$*8sDV{Z7r1bvJ)E292sM!_0;^Z|42q@4ZQ4 za94sj8FRFaeZH`#6Y$%Glx?RnB%aLJ`JuR(X7Al$JC}`*h^L5ait(d_(zIJ8z)FIg%@gYK!*IaK*{4a;rGhzhrZ;8x zx@=y^*h$l6E>Iy)dy~X+b7n>qm><-((pIY9N~JAA1y$8GRl`V86jj8AENh5go#=*L zu$`U}`AC#wTjKKK4#0NKMW;B3=5Re@Td7>$DlQNoa7eD?0e_Bz7eU2IT@)oOE0QQ0 zcphE~)H`A-XfncB(iN;|*uav83?$h8;#80gBucu`fc;@yubS|M zW5G-%Ce0DfoFP#?0o~!?)-e8;_s@R@Fk5kZA*2+H;d(!!g=_cEbBTdKp1>ZsG*3yc z&rh7{gjC8T39f)0rsGt9;E&7ii>!u_ZH2Vg9XVGXGffnIHMrAul0A#hQv_WUwy-egN{iuBxnv zNs2B>hAJb?Fj@-vi4x@B+@85xgnZ2?wsi48zCI)|5Ax&lg#7!~LcZSbU= zm1uk?60YA3I_dG4f8P!lfjr>1)}}yx%luQH(^XD^cVm~1Cr)~8jxY9kw>g2X=@&gu z*Y}V$cQ?t9>OE^JWX%m&@w20)?Q%VlHh3hx8RowA5x@hD^vE@rCB;A*QZxmNGGYZ+ zHITt);lVJ?ly{21yCvN!70}7Cd#{QGv}UvhY}yPr&aoidnQNcQ56AnnjyoFbUTj*a zPu;(y6%k{0lh<@f5wRlSR%@m3yzqdlXr(l2R4YCITg^2zBw<6xSYdzQU{zzm81!yH L2bx!MW2=4uZvauj diff --git a/session/vertexai/testdata/Test_vertexaiService_StateManagement_temp_state_is_not_persisted.replay b/session/vertexai/testdata/Test_vertexaiService_StateManagement_temp_state_is_not_persisted.replay index 9046e20a8c1b17a8311ef79405608c5c56173f75..2a07472518a2a364cd1898377c8aa1e2dd3178a6 100644 GIT binary patch literal 4246 zcmds4OK1~O6z!YDHoR6_W>p5OP!MCKnan5iY3st)SnH=PDYy_^Ow-qpCYc%YCV}qU zDF`lH6rm`k;;OEtLIqKfiXck;;X=9+1fe@!_^EF)nS5+*>9nL`ATTeJx$oU`&pmTb zY`8Cm%(OOHM?Rm(Hvs5kY{D=SX%vd5joesBOOpH6xRK3-^4uu0G%hrPFisl!2+HP@ zam4mzk!GRdkIXE9r>sd6g`6mwnZ%*Ek=Zn8Rg@UIltb8}&%;48YfPZHg;{MZ)tN9l z5s%ok5!Wno0?g)cXB_EPR!eg%IR-ZLq@IZCiKLD&%ZVx{2%IYNoWd)dAgL1VAEk0! zGet6`n5%vm-r%TdLr-LLx*ahzWTI?gOM1x%uYuB`BsHQ6YD5Y1;cc9X#SWsF1s>BZjb>mg z4Fhi%W?t2|yj;1+R9v@$ju!xSd)c%|68buT=~`mQGzH!iQhD_H)(2mE)ygJB5PWmU z-?b@E{~&)qvRvlh2QcadlyV4{_8FKep|c~j-;iL3L7^LFsO{BsdADl%{`=E6<(d{& zv0m5=8*&(ByHY&uXQ(_6_rZG2G`mt^8Kb9cGEW&ZKpk}v2} zY^VU;y;YVRH%+9EMe{_en6Chy^g@%07%ordKuw84_b`4N(++=9&vX1iy3Iz}LYWFw zA*681LAx*@y6C}sfmZ##Qz675I9JzkskY!X|AVqnH=)0RWH z#i(u5dQH77Hl9;2huEFe+Z?@`qP+D{W#t?q(a!-~1n^32U+c(L1I&t2M3$tm7?H@8 zAdvSz?{ryvowq&e(z68!QXDE8r!;b`^Xxg|E=2b;LdJ z(HQ}Hwkqy6p^7sAW~PBReX9)Ow$<=XPrrQ*F%~FqBYO22i@mb~;J-=~54>2zvUP~k z_|v0NI)T!tY?K~&G)h%bT&mRe<#Wj4(QV3ufBFpIKxw$$+&)#9A4MXjNRkEOfMi7> zIw^^C2A=UUO*j~?bv*~*e!3~}&any)r+-kyI8>~N^EnESY_K6o%rZuEn+a literal 5084 zcmd^DU5Fc16yBN5ZsXl*>pV%&IxOhM+MWMQGU-OxcGGohyIHe}U@6P&3#{;sc@9$+9Xi ziB;McXDJdYom!`XFu+&Kv1^`hw=8lc7kce2nM*Wyo9cL#6-7yt6-g9x+AiGUQIJ!_ z%W_5(R4F5C8Brx4B_YQ<(dLL(WknM-Nfu>Q%BfjN5CjH;@_;C|JjT9^cj7xsj<}*@ zojC#?Wez4k|Kie*
k9e)BIn)wfDhcLTiSw>{cTc$tSd0?XGFD8_NUYa&954Xnnf2O-Z=V{*t5uCY> zERo$Cj?F>Ob3DY&noFTx_3UOi6YN?DKgXV-4L$uweU^#hwdgw*{JZ zEGrd4Mbknn@FG6XzDBZKfq6C)IiF`I_u2hP_H~v@kKWHZFclP%PiSy_0Iwpnq#jL}c2krdXC4Cp(5N0}m}C&cMmNPLCX#QycjMbD_ck^*sFz1d zty)W`7QpYM}oY4K*y7y-g6Hq$oxQo?P&}StA%aCRG$pA@L4Kb-6h+I4vY;sn(*V!K=_-OOZZ$Z#-fk#w|GxhIC}}o(R*z6e zp9W72*pNhqomIce6&o&IOCS2b$x2QYBrTWKG=&VNw2VroI7>l$cY5zsCH?R}y4aYO diff --git a/session/vertexai/testdata/Test_vertexaiService_StateManagement_user_state_is_user_specific.replay b/session/vertexai/testdata/Test_vertexaiService_StateManagement_user_state_is_user_specific.replay index c37fdba642f005a3b7d758fd755ee2efa029b98f..809035f5a45e5a3d4b58a9a95d6c5101bd73cbc7 100644 GIT binary patch literal 7281 zcmd^EO>7%Q6z*=EG)#cB8=)erLa3<16yn{P{hMv!Kuaj)uaS$8KwP%Y)N*3)F5Y#d z+;ZShLWEMd1W`f?s#H}RDJm)gq#h}0BMuY}P%h9SB+3C%R6&^C^{&_0P4L=oR2(^n zXIr!L=6&;h-+RuC?#U3Vpiga}e}B^deujI5&zWYfK+@TQSsqL4dHT6?$Sf7plkgyM zbeKLsY&&ln2S{l$pC$aB644z}c{6#60Z%wn7D@Z7=vLlNXU*c8i`J}?AupGS?QnVU ztW`3{N!GD>eQaVlXAToY_=1_$9l8UXFWbXeVmKwe0C~C%+cfeD}&t*Jnm-7~c0W4fbr_kgY3?fpxiEH3wLx;pdWSteN%-OcRyfdRYX zJh%cK?id3KH}e?71Qu70e9mkO(($4jz$C@|;op*-YXbUTw`4DIf-CY714iPUQ+0{p zeiXq(cwlY0AFIOde#W~ai)?FUzO-ItzC82UnQEE6D6<>f3Ho%)3dtahkw*@wjxDNh zRKDTT$2bMg`^PW!ZMydDZ>O15&qMA}G2E_ZQSzu|5o2s*l5$$|X9heT2PTm<0-U_d z+*F`&LNehp?2bgPrq^c?EK#=D=W;48}_@micRhVdt1)fwx}?W zRPT>%k+b*ODQC~OUCt6N_=bG#nvJ7fU-y95I;UM&5oM7oFOp=eKv@yQ8tu{qRZtN` znkpy=QP1wkLf;xlQ?t@lQL6KvQe9p8^;I@?Ti4Je8uV%M za2%Taslc%akS&79kX*sKuwfgsebHpOh;#N|%VzWT_(_e=A6G1N|3#nmjfC zQZgD)l04TUNzQhZBgbX3K~WdHvyiC)4BU+SU+s^jmWZRM5ce4Em9$` z2Goc=2p(AFaWpN*JXk)I9w}PRRBCgV;iSIl-tOaMAr4Ni#KwuJD2gONRq_V#6fD%Y z7Tb#xe<Lf`w6kCi`UjjALrcKRnuj0ssI2 literal 9811 zcmeHNU5Fc16z*h`ZM@ssc8XME)nTvDYH<=k{ zCTkZF3#CxBqKKeqf0h=DAo$oWMMV%qK`jW157r+LEc#OaKz-hr5@dfQqiLBOqEk7okfxO^es7IvMxGvr z`;etTdN(r7tfuZp#&|Y`B3lfkSg7fG=@0qvvU~+NK;YH;Lg##dso{Q3Q!_?EwI`ty zIx>o0kcWKW2vK%43Lz_VJPV;DFTm4?>1?wZ5m}L!WXQ`B&k8KI%Q6woo!pE-4hm60 zj7kh6Nl_N^j6_->Sz{GfjBHg9%IkX=M?I}r3VA13O)d$(_V%GKx|hHF$C4EI75`=kX zOKEA;U$(cN%HwKNGN}I=$74N}QZP34tA$*S+EYSeJ86n*8BXI;%;wBtwBOoGJ%e{S zjbfDGIv=BYud(~R)Uy;3?&{z`sDlF-UaHEpg9Dg<)I35wxO_w4K%j#I9UQP}Mh6FG z>X5d=fyM2N18W^&Iq3r%YVd5A!$CsYJ(7pWPd+&Zqgm3Dzw~8P))GHqcbRDVb z_&9C^gnlK!)<*W(W5X4Ylf>0kkR%iG+kJ_3{;WC<*_EWHQ&HN+%{{>buS|XP`sL~A zY2wL630rUbLPKENf|qRSxSfK+JwG?43v{ZPU7*LC-v#p9EuROR%A9JX<#VvbEgxRw z7~JrYSca8x&qwBYZ_9_}WuAeM;aEY&ucJK6lSWHgKC3~9>)2=!*AE~1k_sT^++-H=>7M{OojBauZluxpDCY7gdHVX%FY&j`*x*F;#RR<@ zX%={HlSL7Vyo~7q6c{dw#RcX8dGJsp`qJ6OD_tX>aFXbQQ=i)ez%#m)Sa(0Tuf~LU zWtO(DFha-kx-}7A)n>UO&ZhJg)=>Y)3l|&D6`6_3vd9WtRK(m;5CzDRW6jMKM}JNh zbLGIt-`<7AOJz`Rrrg$UnKJvsk!yr1JO>$3U<3}gG8o7}mX}(CDo%1-I&${3{c(zC mRBx`_exor=NyM!r2^TDnzlV|qQH-*?oc7`N<`K;B9sdEo$ja0J diff --git a/session/vertexai/testdata/missing_author.replay b/session/vertexai/testdata/missing_author.replay index eed191ab6d74b4dfe30f1f689494b8ea3125061b..fd66f499e4f866ab8bee490447640a4b55cc324b 100644 GIT binary patch delta 281 zcmX@idw`cOD8M-=wIC<4k^ucTRck;@%q w_v8<(x?%xbez@#5G_f=^HU`>jWN2YzVQ6e-X*M~ZEtrX!G-I%M0ccDJ0Q=)p0ssI2 delta 269 zcmX@WdzhCmD8M-=wIC<4k^uBvD@{xdEiH_V%_e8Fg)lJ_sUMrGC+Dz*0019PPw)T$ diff --git a/session/vertexai/testdata/missing_invocation_id.replay b/session/vertexai/testdata/missing_invocation_id.replay index 83c71691c30fb815570c1a5bc74d62ef4adb0c4a..5437eb2f51acfcefa0c3cd1a720d669a3782a6cc 100644 GIT binary patch delta 241 zcmX@bdz_aqD8M-=wIC<4k^u;~Ch|G6oM7Z~o7})=GO<%wtd1+8peR2pHMyi%KQSd+ zH$7iB)hKncHJgU8nT4sjvAKbTrG=rTiMgqznT5f`cS>A0_AI!_CUE-nP43N7j84pq z_a?`(xUkG-l6jZG|o%MX{Kh9;JV#>PMcjSMY}EDVj!EX^j{vxbmniV&vL KCfflGVFdue5Jay4 delta 231 zcmX@kdy1DYD8M-=wIC<4k^u;~C-OP7oMhy3n=HsAJ+VMXyq+tupeR2pHMyi%KQSd+ zH$7jsB(=C?vIU!lu!Wg{k)?&XrKO31iHW7DnVGrK#J5UZ%U86XV-uM7^JU3qNk%7T z#`}|FSX>zAOkT+1Egs0_kJC6)Q*$#@GfSXZCZ>j#7RJVA29s@BLx|Ro#W_Iz!Hlc` D_g_Td diff --git a/session/vertexai/vertexai.go b/session/vertexai/vertexai.go index b7b0bb523..5af33944d 100644 --- a/session/vertexai/vertexai.go +++ b/session/vertexai/vertexai.go @@ -56,9 +56,6 @@ func (s *vertexAiService) Create(ctx context.Context, req *session.CreateRequest if req.AppName == "" || req.UserID == "" { return nil, fmt.Errorf("app_name and user_id are required, got app_name: %q, user_id: %q", req.AppName, req.UserID) } - if req.SessionID != "" { - return nil, fmt.Errorf("user-provided Session id is not supported for VertexAISessionService: %q", req.SessionID) - } sess, err := s.client.createSession(ctx, req) if err != nil { return nil, fmt.Errorf("failed to create session: %w", err) diff --git a/session/vertexai/vertexai_client.go b/session/vertexai/vertexai_client.go index dab671a3c..7354d5c2e 100644 --- a/session/vertexai/vertexai_client.go +++ b/session/vertexai/vertexai_client.go @@ -86,8 +86,9 @@ func (c *vertexAiClient) createSession(ctx context.Context, req *session.CreateR ReasoningEngine: reasoningEngine, } rpcReq := &aiplatformpb.CreateSessionRequest{ - Parent: vertexaiutil.AgentEngineResource(aeData), - Session: pbSession, + Parent: vertexaiutil.AgentEngineResource(aeData), + Session: pbSession, + SessionId: req.SessionID, } lro, err := c.rpcClient.CreateSession(ctx, rpcReq) if err != nil { From bfab1bd76649e84bb3f6c17ec8a337a33d05d031 Mon Sep 17 00:00:00 2001 From: Karol Piotrowicz Date: Tue, 16 Jun 2026 21:40:49 +0200 Subject: [PATCH 72/86] docs: add AGENTS.md for AI coding agents (#1045) Add an AGENTS.md with project context, exact build/test/lint commands, repository layout, idioms, a minimal example, framework-extension and testing guidance, and contribution boundaries for AI coding agents. GEMINI.md and CLAUDE.md are thin pointers to AGENTS.md so Gemini CLI and Claude Code pick up the same single source of truth. --- AGENTS.md | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + GEMINI.md | 1 + 3 files changed, 151 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 GEMINI.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..b75cab3a8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,149 @@ +# AGENTS.md + +Context for AI coding agents (Claude Code, Gemini CLI, Cursor, Copilot, etc.) +working in the ADK Go repository. Human contributors should start with +CONTRIBUTING.md. + +## Project overview + +ADK Go (`google.golang.org/adk`) is an open-source, code-first Go toolkit for +building, evaluating, and deploying AI agents. It is model-agnostic but +optimized for Gemini, and is one of three ADK implementations — Go, Python, and +Java — that share a conceptual model but are independent codebases. Requires +Go 1.25+. + +## Setup & core commands + +Run from the repo root. These match what CI enforces (CI also passes `-v`): + +- Build: `go build -mod=readonly ./...` +- Test: `go test -race -mod=readonly -count=1 -shuffle=on ./...` +- Single pkg: `go test -race ./agent/...` +- Lint: `golangci-lint run` (golangci-lint v2; CI pins v2.3.1; config in `.golangci.yml`) +- Tidy check: `go mod tidy -diff` (must print nothing) +- Format: `golangci-lint fmt` (applies gofumpt + goimports per config) + +## Definition of done + +A change is complete only when all of these pass locally: + +1. `go build` (above) succeeds. +2. `go test` (above) is green. +3. `golangci-lint run` reports no findings. +4. `go mod tidy -diff` prints nothing. +5. New/changed behavior has tests; a bug fix has a test that reproduces the bug. +6. Every new Go file starts with the Apache 2.0 license header (enforced by `goheader`). + +## Repository layout + +- `agent/` Agent interface + types (`llmagent`, `workflowagents`, `remoteagent`) +- `runner/` Execution engine that drives the run loop +- `model/` LLM abstraction (`gemini`, `apigee`) +- `tool/` Tool/Toolset interface + built-in tools (incl. `skilltoolset/`, `mcptoolset/`) +- `session/` Conversation state + events +- `memory/`, `artifact/` Long-term memory and file/data services +- `plugin/` Cross-cutting lifecycle hooks +- `server/` HTTP servers (`adkrest` is primary; `adka2a`, `agentengine`) +- `cmd/` CLI (`adkgo`) and server launchers +- `telemetry/`, `util/` Public helper packages +- `internal/` Private packages — NOT public API; `internal/httprr` is vendored +- `examples/` Runnable example agents (quickstart, tools, a2a, skills, …) + +## Conventions & idioms + +- **Streaming:** agent runs return `iter.Seq2[*session.Event, error]`; consume + with `for event, err := range … {}`. Don't collect events into a slice. +- **Interface-first:** public packages expose interfaces (`Agent`, `Tool`, + `Toolset`, `Service`); concrete impls live in sub-packages or `internal/`. +- **Callbacks over subclassing** (`Before*`/`After*` for Agent/Model/Tool); + returning non-nil from a `Before` callback short-circuits execution. +- **Errors:** wrap with `fmt.Errorf("…: %w", err)`. Tool confirmation uses + sentinel errors (e.g. `tool.ErrConfirmationRequired`). +- Prefer an existing helper over a new one; keep packages small and focused. + +## Minimal example + +```go +model, err := gemini.NewModel(ctx, "gemini-2.5-flash", + &genai.ClientConfig{APIKey: os.Getenv("GOOGLE_API_KEY")}) +// handle err +a, err := llmagent.New(llmagent.Config{ + Name: "assistant", + Model: model, + Instruction: "You are a helpful assistant.", + Tools: []tool.Tool{ /* ... */ }, +}) +// handle err +r, err := runner.New(runner.Config{ + AppName: "my-app", + Agent: a, + SessionService: session.InMemoryService(), + AutoCreateSession: true, +}) +// handle err +msg := genai.NewContentFromText("Hello", genai.RoleUser) +for event, err := range r.Run(ctx, userID, sessionID, msg, agent.RunConfig{}) { + // handle err; read event.LLMResponse.Content +} +``` + +See `examples/quickstart` for a full runnable program. + +## Extending the framework + +- **Add a tool:** wrap a Go function with + `functiontool.New[Args, Results](cfg, handler)` (Args/Results are structs), or + implement the `tool.Tool` interface for full control. +- **Add a toolset:** implement `tool.Toolset`; its `Tools(ctx)` may return + different tools per invocation. +- **Add an agent type:** follow the `agent/workflowagents/*` packages; construct + agents via `llmagent.New` / `agent.New`, not by implementing `agent.Agent` + directly. +- **Add cross-cutting behavior:** register a `plugin.New(plugin.Config{...})` + hook (`Before*`/`After*` for run/agent/model/tool) instead of editing the loop. + +## Testing + +- Tests run **offline by default**: LLM HTTP traffic is replayed from + `testdata/*.httprr` via `internal/httprr`. Never add live model or network + calls to tests. +- To (re)record a package's traffic, supply real credentials (e.g. + `GOOGLE_API_KEY`) and run `go generate .//...` (it runs + `go test -httprecord=…`); commit the updated `testdata/*.httprr`. +- Prefer table-driven tests; shared helpers live in `internal/testutil`. + +## Boundaries + +**Always** +- Run build, tests, lint, and `go mod tidy -diff` before declaring done. +- Keep PRs small and focused — one concern per PR. +- Add or update tests for the code you change. + +**Ask first** +- Adding or upgrading a dependency (`go.mod`). +- Changing a high-fan-in package (`session`, `agent`, `model`, `tool`, + `runner`) — prefer additive, backward-compatible changes. +- Any change to the public API surface, and any breaking change. + +**Never** +- Break the public API — keep changes backward-compatible. +- Edit vendored code (`internal/httprr`) or commit secrets / API keys. +- Add tests that make live LLM or network calls. + +## PRs & commits + +See `CONTRIBUTING.md` for the full process and CLA. Key points for agents: +most PRs (beyond trivial docs/typos) need a linked issue; include a **Testing +Plan**; attach logs or screenshots for behavior changes (Runner output / ADK Web). + +## Alignment with adk-python + +[adk-python](https://github.com/google/adk-python) is the source of truth for +feature behavior. When porting or validating a feature, check parity with the +Python implementation. + +## Resources + +- Docs: https://google.github.io/adk-docs/ +- Examples: `./examples` +- Java ADK: https://github.com/google/adk-java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..543a9c15f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](./AGENTS.md) for project context, commands, and contribution guidelines for AI coding agents. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000..543a9c15f --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +See [AGENTS.md](./AGENTS.md) for project context, commands, and contribution guidelines for AI coding agents. From fea859618d79dfca3d288b558ae569452edc9269 Mon Sep 17 00:00:00 2001 From: Jan Krynauw Date: Thu, 18 Jun 2026 09:01:50 +0200 Subject: [PATCH 73/86] fix(agentengine): support Gemini Enterprise AgentSpace streams (#777) * fix(agentengine): support Gemini Enterprise AgentSpace streams * refactor(agentengine): split streaming_agent_run_with_events handler * fix(agentengine): add MemoryService to streamingAgentRunWithEventsHandler runner configuration. * fix(adkrest): make debug telemetry span tests order-insensitive - The Go test check was failing in TestDebugTelemetryGetSpansBySessionID because the test asserted a fixed ordering for returned spans. In practice, the debug telemetry store can return the same set of spans in a different order depending on span/export timing, especially when spans are created and ended very close together. - This change keeps the production behavior unchanged and updates the tests to compare spans as a stable sorted set before diffing. The comparison still checks the span names, relevant attributes, and logs, but no longer treats incidental retrieval order as part of the contract. * chore(agentengine): clarify streaming agent run metadata Document the request_json payload for streaming_agent_run_with_events, mark the method as async_stream consistently, and replace realistic test fixture IDs with clearly fake values. --- .../internal/services/debugtelemetry_test.go | 18 + .../controllers/method/stream_query.go | 3 +- .../method/streaming_agent_run_with_events.go | 251 ++++++++++++ .../streaming_agent_run_with_events_test.go | 357 ++++++++++++++++++ server/agentengine/handler.go | 1 + .../models/streaming_agent_run_with_events.go | 50 +++ 6 files changed, 678 insertions(+), 2 deletions(-) create mode 100644 server/agentengine/controllers/method/streaming_agent_run_with_events.go create mode 100644 server/agentengine/controllers/method/streaming_agent_run_with_events_test.go create mode 100644 server/agentengine/internal/models/streaming_agent_run_with_events.go diff --git a/server/adkrest/internal/services/debugtelemetry_test.go b/server/adkrest/internal/services/debugtelemetry_test.go index 3ebb6ed8a..0daeb6ef9 100644 --- a/server/adkrest/internal/services/debugtelemetry_test.go +++ b/server/adkrest/internal/services/debugtelemetry_test.go @@ -228,6 +228,7 @@ func TestDebugTelemetryGetSpansBySessionID(t *testing.T) { cmpopts.IgnoreUnexported(log.Value{}), cmpopts.IgnoreFields(DebugSpan{}, "StartTime", "EndTime", "TraceID", "SpanID", "ParentSpanID"), cmpopts.IgnoreFields(DebugLog{}, "ObservedTimestamp", "TraceID", "SpanID"), + cmpopts.SortSlices(compareDebugSpans), cmpopts.EquateEmpty(), } @@ -365,6 +366,7 @@ func TestDebugTelemetryGetSpansByEventID(t *testing.T) { cmpopts.IgnoreUnexported(log.Value{}), cmpopts.IgnoreFields(DebugSpan{}, "StartTime", "EndTime", "ParentSpanID", "TraceID", "SpanID"), cmpopts.IgnoreFields(DebugLog{}, "ObservedTimestamp", "TraceID", "SpanID"), + cmpopts.SortSlices(compareDebugSpans), cmpopts.EquateEmpty(), } @@ -472,3 +474,19 @@ func setupWithConfig(t *testing.T, cfg *DebugTelemetryConfig) (*DebugTelemetry, func setup(t *testing.T) (*DebugTelemetry, *sdktrace.TracerProvider, *sdklog.LoggerProvider) { return setupWithConfig(t, nil) } + +func compareDebugSpans(a, b DebugSpan) bool { + if a.Name != b.Name { + return a.Name < b.Name + } + if a.ParentSpanID != b.ParentSpanID { + return a.ParentSpanID < b.ParentSpanID + } + if a.Attributes[string(semconv.GenAIConversationIDKey)] != b.Attributes[string(semconv.GenAIConversationIDKey)] { + return a.Attributes[string(semconv.GenAIConversationIDKey)] < b.Attributes[string(semconv.GenAIConversationIDKey)] + } + if a.Attributes[eventIDKey] != b.Attributes[eventIDKey] { + return a.Attributes[eventIDKey] < b.Attributes[eventIDKey] + } + return a.Attributes["genai.operation.name"] < b.Attributes["genai.operation.name"] +} diff --git a/server/agentengine/controllers/method/stream_query.go b/server/agentengine/controllers/method/stream_query.go index 97f75618c..0196c5b57 100644 --- a/server/agentengine/controllers/method/stream_query.go +++ b/server/agentengine/controllers/method/stream_query.go @@ -113,8 +113,7 @@ func (s *streamQueryHandler) streamJSONL(ctx context.Context, rw http.ResponseWr continue } - chunk := *event - err = helper.EmitJSON(rw, chunk) + err = helper.EmitJSON(rw, *event) if err != nil { e := fmt.Errorf("helper.EmitJSON() failed: %w", err) log.Print(e.Error()) diff --git a/server/agentengine/controllers/method/streaming_agent_run_with_events.go b/server/agentengine/controllers/method/streaming_agent_run_with_events.go new file mode 100644 index 000000000..e9cfad7dc --- /dev/null +++ b/server/agentengine/controllers/method/streaming_agent_run_with_events.go @@ -0,0 +1,251 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package method + +import ( + "context" + "encoding/json" + "fmt" + "iter" + "log" + "net/http" + + "google.golang.org/genai" + "google.golang.org/protobuf/types/known/structpb" + + "google.golang.org/adk/agent" + "google.golang.org/adk/cmd/launcher" + "google.golang.org/adk/runner" + "google.golang.org/adk/server/agentengine/internal/helper" + "google.golang.org/adk/server/agentengine/internal/models" + "google.golang.org/adk/session" +) + +type streamingAgentRunWithEventsHandler struct { + config *launcher.Config + methodName string + apiMode string + agentEngineID string +} + +// NewStreamingAgentRunWithEventsHandler creates a new handler for streaming_agent_run_with_events. +func NewStreamingAgentRunWithEventsHandler(config *launcher.Config, agentEngineID, methodName, apiMode string) *streamingAgentRunWithEventsHandler { + return &streamingAgentRunWithEventsHandler{config: config, agentEngineID: agentEngineID, methodName: methodName, apiMode: apiMode} +} + +// Handle generates stream of json-encoded responses based on the payload. Errors are also emitted as errors. +func (s *streamingAgentRunWithEventsHandler) Handle(ctx context.Context, rw http.ResponseWriter, payload []byte) error { + streamErr := s.streamJSONL(ctx, rw, payload) + // streamJSONL will return error only before streaming. In that case we can handle it with HTTP Status, which is done in upstream. + if streamErr != nil { + err := fmt.Errorf("s.streamJSONL() failed: %w", streamErr) + return err + } + return nil +} + +// streamJSONL streams a single line for each event or error. +func (s *streamingAgentRunWithEventsHandler) streamJSONL(ctx context.Context, rw http.ResponseWriter, payload []byte) error { + var req models.StreamingAgentRunWithEventsRequest + if err := json.Unmarshal(payload, &req); err != nil { + err = fmt.Errorf("json.Unmarshal() failed for models.StreamingAgentRunWithEventsRequest: %w", err) + log.Print(err.Error()) + return err + } + + runReq, requestedSessionID, err := decodeStreamingAgentRunWithEventsRequest(&req) + if err != nil { + err = fmt.Errorf("decodeStreamingAgentRunWithEventsRequest() failed: %w", err) + log.Print(err.Error()) + return err + } + if err := s.ensureBackendSession(ctx, runReq, requestedSessionID); err != nil { + err = fmt.Errorf("s.ensureBackendSession() failed: %w", err) + log.Print(err.Error()) + return err + } + + events, err := s.run(ctx, runReq, &runReq.Message, s.config) + if err != nil { + err = fmt.Errorf("s.run() failed: %w", err) + log.Print(err.Error()) + return err + } + + rw.Header().Set("Content-Type", "application/json") + rw.Header().Set("Cache-Control", "no-cache") + rw.Header().Set("Connection", "keep-alive") + // from this moment on we must not return error. Instead, it should be handled by using helper.EmitJSONError + + for event, err := range events { + log.Printf("Processing event: %+v err: %+v\n", event, err) + if err != nil { + log.Printf("error in events: %v\n", err) + e := helper.EmitJSONError(rw, err) + if e != nil { + e = fmt.Errorf("helper.EmitJSONError() failed: %w", e) + log.Print(e.Error()) + } + break + } + if event == nil { + continue + } + if event.LLMResponse.Content == nil { + continue + } + + err = helper.EmitJSON(rw, models.StreamingAgentRunWithEventsResponse{ + Events: []*session.Event{event}, + SessionID: runReq.SessionID, + }) + if err != nil { + e := fmt.Errorf("helper.EmitJSON() failed: %w", err) + log.Print(e.Error()) + e = helper.EmitJSONError(rw, e) + if e != nil { + e = fmt.Errorf("helper.EmitJSONError() failed: %w", e) + log.Print(e.Error()) + } + break + } + } + return nil +} + +// decodeStreamingAgentRunWithEventsRequest decodes input.request_json and returns +// the caller-requested session_id before backend ADK session normalization. +func decodeStreamingAgentRunWithEventsRequest(req *models.StreamingAgentRunWithEventsRequest) (*models.StreamingAgentRunWithEventsRunRequest, string, error) { + var runReq models.StreamingAgentRunWithEventsRunRequest + if err := json.Unmarshal([]byte(req.Input.RequestJSON), &runReq); err != nil { + return nil, "", fmt.Errorf("json.Unmarshal(input.request_json) failed: %w", err) + } + return &runReq, runReq.SessionID, nil +} + +// ensureBackendSession normalizes Gemini Enterprise requests so +// the runner always receives a backend ADK session ID. +// +// On the first turn, the embedded session_id is usually a Gemini Enterprise / +// Discovery Engine session resource: +// +// projects/{project}/locations/global/collections/default_collection/engines/{engine}/sessions/{session} +// +// VertexAISessionService cannot use that resource name as a caller-provided +// backend session ID. This method treats the incoming session_id as either a +// backend ADK session ID returned by a previous response, or an external +// first-turn resource that needs a newly-created backend session. +func (s *streamingAgentRunWithEventsHandler) ensureBackendSession(ctx context.Context, req *models.StreamingAgentRunWithEventsRunRequest, requestedSessionID string) error { + if requestedSessionID == "" { + return nil + } + if req.UserID == "" { + return fmt.Errorf("user_id is required for backend session handling") + } + if s.config == nil || s.config.SessionService == nil { + return fmt.Errorf("session service is required for backend session handling") + } + + getResp, err := s.config.SessionService.Get(ctx, &session.GetRequest{ + AppName: s.agentEngineID, + UserID: req.UserID, + SessionID: requestedSessionID, + }) + if err == nil && getResp.Session != nil { + req.SessionID = getResp.Session.ID() + return nil + } + + createResp, err := s.config.SessionService.Create(ctx, &session.CreateRequest{ + AppName: s.agentEngineID, + UserID: req.UserID, + }) + if err != nil { + return fmt.Errorf("sessionService.Create() failed: %w", err) + } + + req.SessionID = createResp.Session.ID() + return nil +} + +// Name implements MethodHandler. +func (s *streamingAgentRunWithEventsHandler) Name() string { + return s.methodName +} + +var _ MethodHandler = (*streamingAgentRunWithEventsHandler)(nil) + +// Metadata implements MethodHandler. +func (s *streamingAgentRunWithEventsHandler) Metadata() (*structpb.Struct, error) { + classAsyncMethod, err := structpb.NewStruct(map[string]any{ + "api_mode": s.apiMode, + "name": s.methodName, + "parameters": map[string]any{ + "properties": map[string]any{ + "request_json": map[string]any{ + "type": "string", + }, + }, + "required": []any{ + "request_json", + }, + "type": "object", + }, + "description": `Streams responses asynchronously from the ADK application. + +This method is primarily meant for invocation from Gemini Enterprise. + +Args: + request_json (str): + Required. A JSON-encoded request object with: + - user_id (str): Required. The user ID to run the agent for. + - session_id (str): Optional. The session ID. Gemini Enterprise may + send its Discovery Engine session resource on the first turn; later + turns should use the backend ADK session ID returned in the response. + - message (Content): Required. The user message, using the genai + Content JSON shape, for example: + {"role":"user","parts":[{"text":"Hi"}]} + +`, + }) + if err != nil { + return nil, fmt.Errorf("cannot create %s: %w", s.Name(), err) + } + return classAsyncMethod, nil +} + +func (s *streamingAgentRunWithEventsHandler) run(ctx context.Context, req *models.StreamingAgentRunWithEventsRunRequest, message *genai.Content, config *launcher.Config) (iter.Seq2[*session.Event, error], error) { + rootAgent := config.AgentLoader.RootAgent() + + r, err := runner.New(runner.Config{ + AppName: s.agentEngineID, + Agent: rootAgent, + SessionService: config.SessionService, + ArtifactService: config.ArtifactService, + MemoryService: config.MemoryService, + PluginConfig: config.PluginConfig, + AutoCreateSession: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to create runner: %v", err) + } + + // The path mirrors Python AdkApp.streaming_agent_run_with_events, + // which does not force SSE mode. Sending both partial and final events here + // causes Gemini Enterprise to render duplicate answer text. + return r.Run(ctx, req.UserID, req.SessionID, message, agent.RunConfig{ + StreamingMode: agent.StreamingModeNone, + }), nil +} diff --git a/server/agentengine/controllers/method/streaming_agent_run_with_events_test.go b/server/agentengine/controllers/method/streaming_agent_run_with_events_test.go new file mode 100644 index 000000000..64a0cfb1e --- /dev/null +++ b/server/agentengine/controllers/method/streaming_agent_run_with_events_test.go @@ -0,0 +1,357 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package method + +import ( + "context" + "encoding/json" + "iter" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/cmd/launcher" + "google.golang.org/adk/model" + "google.golang.org/adk/server/agentengine/internal/models" + "google.golang.org/adk/session" +) + +type agentSpaceStreamResponse struct { + Events []simpleEvent `json:"events"` + SessionID string `json:"session_id"` +} + +type streamAwareLLM struct{} + +func (streamAwareLLM) Name() string { + return "stream-aware-llm" +} + +func (streamAwareLLM) GenerateContent(ctx context.Context, req *model.LLMRequest, stream bool) iter.Seq2[*model.LLMResponse, error] { + return func(yield func(*model.LLMResponse, error) bool) { + if stream { + if !yield(&model.LLMResponse{ + Content: genai.NewContentFromText("partial response", genai.RoleModel), + Partial: true, + }, nil) { + return + } + } + yield(&model.LLMResponse{ + Content: genai.NewContentFromText("final response", genai.RoleModel), + }, nil) + } +} + +func TestDecodeStreamingAgentRunWithEventsRequest(t *testing.T) { + payload := []byte(`{ + "class_method": "streaming_agent_run_with_events", + "input": { + "request_json": "{\"message\":{\"role\":\"user\",\"parts\":[{\"text\":\"Hi\"}]},\"session_id\":\"projects/111111111111/locations/global/collections/default_collection/engines/test-engine/sessions/12345678901234567890\",\"user_id\":\"test-user@example.com\"}" + } + }`) + var req models.StreamingAgentRunWithEventsRequest + if err := json.Unmarshal(payload, &req); err != nil { + t.Fatalf("json.Unmarshal() failed: %v", err) + } + + got, requestedSessionID, err := decodeStreamingAgentRunWithEventsRequest(&req) + if err != nil { + t.Fatalf("decodeStreamingAgentRunWithEventsRequest() failed: %v", err) + } + + want := &models.StreamingAgentRunWithEventsRunRequest{ + UserID: "test-user@example.com", + SessionID: "projects/111111111111/locations/global/collections/default_collection/engines/test-engine/sessions/12345678901234567890", + Message: genai.Content{ + Role: "user", + Parts: []*genai.Part{{Text: "Hi"}}, + }, + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("decodeStreamingAgentRunWithEventsRequest() mismatch (-want +got):\n%s", diff) + } + if requestedSessionID != want.SessionID { + t.Errorf("requestedSessionID = %q, want %q", requestedSessionID, want.SessionID) + } +} + +func TestEnsureBackendSession_CreateBackendSession(t *testing.T) { + sessionService := session.InMemoryService() + handler := NewStreamingAgentRunWithEventsHandler(&launcher.Config{SessionService: sessionService}, "app", "streaming_agent_run_with_events", "async_stream") + req := &models.StreamingAgentRunWithEventsRunRequest{ + UserID: "test-user@example.com", + SessionID: "projects/111111111111/locations/global/collections/default_collection/engines/test-engine/sessions/12345678901234567890", + } + requestedSessionID := req.SessionID + + if err := handler.ensureBackendSession(t.Context(), req, requestedSessionID); err != nil { + t.Fatalf("ensureBackendSession() failed: %v", err) + } + if req.SessionID == "" || req.SessionID == requestedSessionID { + t.Fatalf("SessionID = %q, want generated backend session ID", req.SessionID) + } + + got, err := sessionService.Get(t.Context(), &session.GetRequest{ + AppName: "app", + UserID: "test-user@example.com", + SessionID: req.SessionID, + }) + if err != nil { + t.Fatalf("Get() failed: %v", err) + } + if got.Session.ID() != req.SessionID { + t.Errorf("stored SessionID = %q, want %q", got.Session.ID(), req.SessionID) + } +} + +func TestEnsureBackendSession_ReuseReturnedBackendSession(t *testing.T) { + sessionService := session.InMemoryService() + created, err := sessionService.Create(t.Context(), &session.CreateRequest{ + AppName: "app", + UserID: "jan@example.com", + }) + if err != nil { + t.Fatalf("Create() failed: %v", err) + } + + handler := NewStreamingAgentRunWithEventsHandler(&launcher.Config{SessionService: sessionService}, "app", "streaming_agent_run_with_events", "async_stream") + req := &models.StreamingAgentRunWithEventsRunRequest{ + UserID: "jan@example.com", + SessionID: created.Session.ID(), + } + + if err := handler.ensureBackendSession(t.Context(), req, req.SessionID); err != nil { + t.Fatalf("ensureBackendSession() failed: %v", err) + } + if req.SessionID != created.Session.ID() { + t.Errorf("SessionID = %q, want existing backend session %q", req.SessionID, created.Session.ID()) + } +} + +func TestStreamJSONL_AgentSpaceResponseEnvelope(t *testing.T) { + const ( + appName = "app" + userID = "test-user@example.com" + externalSessionID = "projects/111111111111/locations/global/collections/default_collection/engines/test-engine/sessions/12345678901234567890" + ) + + a, err := llmagent.New(llmagent.Config{ + Name: "Echo", + BeforeAgentCallbacks: []agent.BeforeAgentCallback{ + func(cc agent.CallbackContext) (*genai.Content, error) { + return cc.UserContent(), nil + }, + }, + }) + if err != nil { + t.Fatalf("failed to create agent: %v", err) + } + + config := &launcher.Config{ + AgentLoader: agent.NewSingleLoader(a), + SessionService: session.InMemoryService(), + } + h := NewStreamingAgentRunWithEventsHandler(config, appName, "streaming_agent_run_with_events", "async_stream") + + requestJSON := `{"message":{"role":"user","parts":[{"text":"Please"}]},"session_id":"` + externalSessionID + `","user_id":"` + userID + `"}` + payload, err := json.Marshal(models.StreamingAgentRunWithEventsRequest{ + ClassMethod: "streaming_agent_run_with_events", + Input: models.StreamingAgentRunWithEventsInput{ + RequestJSON: requestJSON, + }, + }) + if err != nil { + t.Fatalf("json.Marshal() failed: %v", err) + } + + w := newStringWriter() + if err := h.streamJSONL(t.Context(), w, payload); err != nil { + t.Fatalf("streamJSONL() failed: %v", err) + } + + var got agentSpaceStreamResponse + if err := json.Unmarshal([]byte(w.sb.String()), &got); err != nil { + t.Fatalf("json.Unmarshal() failed: %v", err) + } + if got.SessionID == "" || got.SessionID == externalSessionID { + t.Fatalf("SessionID = %q, want generated backend session ID", got.SessionID) + } + if len(got.Events) != 1 { + t.Fatalf("len(Events) = %d, want 1", len(got.Events)) + } + + wantContent := genai.NewContentFromText("Please", genai.RoleUser) + if diff := cmp.Diff(wantContent, got.Events[0].Content); diff != "" { + t.Errorf("event content mismatch (-want +got):\n%s", diff) + } +} + +func TestStreamJSONL_AgentSpaceAcceptsReturnedBackendSessionID(t *testing.T) { + const ( + appName = "app" + userID = "test-user@example.com" + externalSessionID = "projects/111111111111/locations/global/collections/default_collection/engines/test-engine/sessions/12345678901234567890" + ) + + a, err := llmagent.New(llmagent.Config{ + Name: "Echo", + BeforeAgentCallbacks: []agent.BeforeAgentCallback{ + func(cc agent.CallbackContext) (*genai.Content, error) { + return cc.UserContent(), nil + }, + }, + }) + if err != nil { + t.Fatalf("failed to create agent: %v", err) + } + + config := &launcher.Config{ + AgentLoader: agent.NewSingleLoader(a), + SessionService: session.InMemoryService(), + } + h := NewStreamingAgentRunWithEventsHandler(config, appName, "streaming_agent_run_with_events", "async_stream") + + run := func(message, sessionID string) agentSpaceStreamResponse { + t.Helper() + requestJSON := `{"message":{"role":"user","parts":[{"text":"` + message + `"}]},"session_id":"` + sessionID + `","user_id":"` + userID + `"}` + payload, err := json.Marshal(models.StreamingAgentRunWithEventsRequest{ + ClassMethod: "streaming_agent_run_with_events", + Input: models.StreamingAgentRunWithEventsInput{ + RequestJSON: requestJSON, + }, + }) + if err != nil { + t.Fatalf("json.Marshal() failed: %v", err) + } + + w := newStringWriter() + if err := h.streamJSONL(t.Context(), w, payload); err != nil { + t.Fatalf("streamJSONL() failed: %v", err) + } + + var got agentSpaceStreamResponse + if err := json.Unmarshal([]byte(w.sb.String()), &got); err != nil { + t.Fatalf("json.Unmarshal() failed: %v", err) + } + return got + } + + first := run("Hi", externalSessionID) + second := run("Again", first.SessionID) + + if first.SessionID == "" || first.SessionID == externalSessionID { + t.Fatalf("first SessionID = %q, want generated backend session ID", first.SessionID) + } + if second.SessionID != first.SessionID { + t.Fatalf("second SessionID = %q, want returned backend session ID %q", second.SessionID, first.SessionID) + } + + list, err := config.SessionService.List(t.Context(), &session.ListRequest{ + AppName: appName, + UserID: userID, + }) + if err != nil { + t.Fatalf("List() failed: %v", err) + } + if len(list.Sessions) != 1 { + t.Fatalf("len(Sessions) = %d, want 1", len(list.Sessions)) + } +} + +func TestStreamJSONL_AgentSpaceUsesNonStreamingMode(t *testing.T) { + const ( + appName = "app" + userID = "test-user@example.com" + externalSessionID = "projects/111111111111/locations/global/collections/default_collection/engines/test-engine/sessions/12345678901234567890" + ) + + a, err := llmagent.New(llmagent.Config{ + Name: "StreamAware", + Model: streamAwareLLM{}, + }) + if err != nil { + t.Fatalf("failed to create agent: %v", err) + } + + config := &launcher.Config{ + AgentLoader: agent.NewSingleLoader(a), + SessionService: session.InMemoryService(), + } + h := NewStreamingAgentRunWithEventsHandler(config, appName, "streaming_agent_run_with_events", "async_stream") + + requestJSON := `{"message":{"role":"user","parts":[{"text":"What is your capabilities"}]},"session_id":"` + externalSessionID + `","user_id":"` + userID + `"}` + payload, err := json.Marshal(models.StreamingAgentRunWithEventsRequest{ + ClassMethod: "streaming_agent_run_with_events", + Input: models.StreamingAgentRunWithEventsInput{ + RequestJSON: requestJSON, + }, + }) + if err != nil { + t.Fatalf("json.Marshal() failed: %v", err) + } + + w := newStringWriter() + if err := h.streamJSONL(t.Context(), w, payload); err != nil { + t.Fatalf("streamJSONL() failed: %v", err) + } + + var got agentSpaceStreamResponse + if err := json.Unmarshal([]byte(w.sb.String()), &got); err != nil { + t.Fatalf("json.Unmarshal() failed: %v", err) + } + if len(got.Events) != 1 { + t.Fatalf("len(Events) = %d, want 1", len(got.Events)) + } + + wantContent := genai.NewContentFromText("final response", genai.RoleModel) + if diff := cmp.Diff(wantContent, got.Events[0].Content); diff != "" { + t.Errorf("event content mismatch (-want +got):\n%s", diff) + } +} + +func TestStreamingAgentRunWithEventsHandlerMetadata(t *testing.T) { + handler := NewStreamingAgentRunWithEventsHandler(nil, "", "streaming_agent_run_with_events", "async_stream") + + got, err := handler.Metadata() + if err != nil { + t.Fatalf("Metadata() failed: %v", err) + } + + want := map[string]any{ + "api_mode": "async_stream", + "name": "streaming_agent_run_with_events", + "parameters": map[string]any{ + "properties": map[string]any{ + "request_json": map[string]any{ + "type": "string", + }, + }, + "required": []any{"request_json"}, + "type": "object", + }, + } + + if diff := cmp.Diff(want, got.AsMap(), cmpopts.IgnoreMapEntries(func(k string, _ any) bool { + return k == "description" + })); diff != "" { + t.Errorf("Metadata() mismatch (-want +got):\n%s", diff) + } +} diff --git a/server/agentengine/handler.go b/server/agentengine/handler.go index 74aeb063c..be13e0ba2 100644 --- a/server/agentengine/handler.go +++ b/server/agentengine/handler.go @@ -93,6 +93,7 @@ func listNonStreamHandlers(config *launcher.Config, agentEngineID string) []meth func listStreamHandlers(config *launcher.Config, agentEngineID string) []method.MethodHandler { return []method.MethodHandler{ method.NewStreamQueryHandler(config, agentEngineID, "async_stream_query", "async_stream"), + method.NewStreamingAgentRunWithEventsHandler(config, agentEngineID, "streaming_agent_run_with_events", "async_stream"), } } diff --git a/server/agentengine/internal/models/streaming_agent_run_with_events.go b/server/agentengine/internal/models/streaming_agent_run_with_events.go new file mode 100644 index 000000000..c5e89331a --- /dev/null +++ b/server/agentengine/internal/models/streaming_agent_run_with_events.go @@ -0,0 +1,50 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +import ( + "google.golang.org/genai" + + "google.golang.org/adk/session" +) + +// StreamingAgentRunWithEventsRequest is the JSON-encoded payload for the +// streaming_agent_run_with_events method. +type StreamingAgentRunWithEventsRequest struct { + ClassMethod string `json:"class_method"` + Input StreamingAgentRunWithEventsInput `json:"input"` +} + +// StreamingAgentRunWithEventsInput wraps the actual request payload as JSON. +// RequestJSON is a JSON-encoded StreamingAgentRunWithEventsRunRequest. +type StreamingAgentRunWithEventsInput struct { + RequestJSON string `json:"request_json"` +} + +// StreamingAgentRunWithEventsRunRequest is the request decoded from +// StreamingAgentRunWithEventsInput.RequestJSON. +type StreamingAgentRunWithEventsRunRequest struct { + UserID string `json:"user_id"` + SessionID string `json:"session_id"` + Message genai.Content `json:"message"` +} + +// StreamingAgentRunWithEventsResponse is the response envelope expected by +// Gemini Enterprise for streaming_agent_run_with_events. +type StreamingAgentRunWithEventsResponse struct { + Events []*session.Event `json:"events,omitempty"` + Artifacts []any `json:"artifacts,omitempty"` + SessionID string `json:"session_id,omitempty"` +} From b304aafa8b9f3925f8d4e91f1539e02224f78a24 Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Thu, 18 Jun 2026 11:02:28 +0000 Subject: [PATCH 74/86] Get rid of context.Background (#1062) * Got rid of context.Background * Linter fixes --- session/session.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/session/session.go b/session/session.go index 82b0bb4c1..f90e561ab 100644 --- a/session/session.go +++ b/session/session.go @@ -20,6 +20,8 @@ import ( "iter" "time" + "github.com/google/uuid" + "google.golang.org/adk/model" "google.golang.org/adk/platform" "google.golang.org/adk/tool/toolconfirmation" @@ -136,7 +138,12 @@ func (e *Event) IsFinalResponse() bool { // [platform.WithUUIDProvider]) are honored. NewEvent always uses the wall clock // and a random UUID. func NewEvent(invocationID string) *Event { - return NewEventWithContext(context.Background(), invocationID) + return &Event{ + ID: uuid.NewString(), + InvocationID: invocationID, + Timestamp: time.Now(), + Actions: EventActions{StateDelta: make(map[string]any), ArtifactDelta: make(map[string]int64)}, + } } // NewEventWithContext creates a new event defining now as the timestamp. From ca2e3d5cc614ec61ddb09f845ae8d1b73bc387b6 Mon Sep 17 00:00:00 2001 From: Nicholas Breckwoldt <52029793+nicholasbreckwoldt@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:27:09 +0100 Subject: [PATCH 75/86] fix: update built-in load_memory tool for compatibility with VertexAiSessionService (#793) * fix: update load_memory tool with json marshaling of memory search response into map[string]any to avoid downstream incompatibility with Go struct types * revert: undo initial changes to load_memory tool * fix: safely normalize all part.FunctionResponse.Response types via JSON round-trip before conversion to structpb. This prevents errors or panics in `structpb.NewStruct()` caused by raw Go types that are incompatible with Protocol Buffers. * feat: add utility to safely normalize Go types via JSON round-trip before conversion to structpb. This prevents errors or panics in structpb.NewStruct() caused by raw Go types that are incompatible with Protocol Buffers. * chore: rename and simplify toStructPB util function * chore: add broader vertexai sessions test coverage --------- Co-authored-by: nicholas@alisx.com Co-authored-by: Karol Droste --- session/vertexai/vertexai_client.go | 27 ++++- session/vertexai/vertexai_test.go | 179 ++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 5 deletions(-) diff --git a/session/vertexai/vertexai_client.go b/session/vertexai/vertexai_client.go index 7354d5c2e..b31724da3 100644 --- a/session/vertexai/vertexai_client.go +++ b/session/vertexai/vertexai_client.go @@ -16,6 +16,7 @@ package vertexai import ( "context" + "encoding/json" "fmt" "regexp" "strconv" @@ -69,7 +70,7 @@ func (c *vertexAiClient) createSession(ctx context.Context, req *session.CreateR } // Convert and set the initial state if provided if len(req.State) > 0 { - stateStruct, err := structpb.NewStruct(req.State) + stateStruct, err := toStructPB(req.State) if err != nil { return nil, fmt.Errorf("failed to convert state to structpb: %w", err) } @@ -255,7 +256,7 @@ func (c *vertexAiClient) appendEvent(ctx context.Context, appName, sessionID str var eventState *aiplatformpb.EventActions // Convert and set the initial state if provided if len(event.Actions.StateDelta) > 0 { - sessionState, err := structpb.NewStruct(event.Actions.StateDelta) + sessionState, err := toStructPB(event.Actions.StateDelta) if err != nil { return fmt.Errorf("failed to convert state to structpb: %w", err) } @@ -505,7 +506,7 @@ func createAiplatformpbContent(event *session.Event) (*aiplatformpb.Content, err } } if part.FunctionCall != nil { - args, err := structpb.NewStruct(part.FunctionCall.Args) + args, err := toStructPB(part.FunctionCall.Args) if err != nil { return nil, fmt.Errorf("failed to convert function call to structpb: %w", err) } @@ -518,7 +519,7 @@ func createAiplatformpbContent(event *session.Event) (*aiplatformpb.Content, err } } if part.FunctionResponse != nil { - response, err := structpb.NewStruct(part.FunctionResponse.Response) + response, err := toStructPB(part.FunctionResponse.Response) if err != nil { return nil, fmt.Errorf("failed to convert function response to structpb: %w", err) } @@ -552,7 +553,7 @@ func createAiplatformpbMetadata(event *session.Event) (*aiplatformpb.EventMetada Branch: event.Branch, } if event.CustomMetadata != nil { - customMetadata, err := structpb.NewStruct(event.CustomMetadata) + customMetadata, err := toStructPB(event.CustomMetadata) if err != nil { return nil, fmt.Errorf("failed to convert event customMetadata to structpb: %w", err) } @@ -784,6 +785,22 @@ func createGroundingMetadata(metadata *aiplatformpb.GroundingMetadata) *genai.Gr return out } +// toStructPB converts an arbitrary Go value into a protobuf Struct. +// It uses JSON marshaling as an intermediary step to safely serialize +// the input data before constructing the *structpb.Struct. +// Returns an error if any part of the JSON round-trip or conversion fails. +func toStructPB(value any) (*structpb.Struct, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("failed to marshal value: %w", err) + } + res := &structpb.Struct{} + if err := res.UnmarshalJSON(data); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON data to structpb: %w", err) + } + return res, nil +} + // derefString is a helper to safely dereference string pointers // Returns empty string if pointer is nil func derefString(s *string) string { diff --git a/session/vertexai/vertexai_test.go b/session/vertexai/vertexai_test.go index 9aa3a6874..ab2fec2da 100644 --- a/session/vertexai/vertexai_test.go +++ b/session/vertexai/vertexai_test.go @@ -17,6 +17,8 @@ package vertexai import ( "testing" + "google.golang.org/adk/model" + "google.golang.org/adk/session" "google.golang.org/adk/util/vertexai" "google.golang.org/genai" @@ -169,3 +171,180 @@ func TestAiplatformToGenaiContentPreservesFunctionIDs(t *testing.T) { t.Errorf("FunctionResponse.ID = %q, want %q", got, want) } } + +func TestToStructPB(t *testing.T) { + tests := []struct { + name string + input any + expectError bool + validate func(t *testing.T, s *structpb.Struct) + }{ + { + name: "simple map representing function call args or function response", + input: map[string]any{"city": "Stockholm"}, + expectError: false, + validate: func(t *testing.T, s *structpb.Struct) { + if got, want := s.Fields["city"].GetStringValue(), "Stockholm"; got != want { + t.Errorf("city = %q, want %q", got, want) + } + }, + }, + { + name: "invalid input", + input: "hello", + expectError: true, + }, + { + name: "custom struct representing possible state delta", + input: struct { + StringValue string + BoolValue bool + IntValue int32 + ArrayValue []string + }{ + StringValue: "value", + BoolValue: false, + IntValue: 123, + ArrayValue: []string{"value"}, + }, + expectError: false, + validate: func(t *testing.T, s *structpb.Struct) { + if _, exists := s.Fields["StringValue"]; !exists { + t.Error("expected 'StringValue' field to exist") + } + if _, exists := s.Fields["BoolValue"]; !exists { + t.Error("expected 'Boolvalue' field to exist") + } + if _, exists := s.Fields["IntValue"]; !exists { + t.Error("expected 'IntValue' field to exist") + } + if _, exists := s.Fields["ArrayValue"]; !exists { + t.Error("expected 'ArrayValue' field to exist") + } + }, + }, + { + name: "custom struct representing possible state delta respects json tags and omitempty", + input: struct { + StringValue string `json:"string_value"` + BoolValue bool `json:"bool_value"` + IntValue int32 `json:"int_value"` + ArrayValue []string `json:"array_value"` + EmptyStringValue string `json:"empty_string_value,omitempty"` + }{ + StringValue: "value", + BoolValue: false, + IntValue: 123, + ArrayValue: []string{"value"}, + EmptyStringValue: "", + }, + expectError: false, + validate: func(t *testing.T, s *structpb.Struct) { + if _, exists := s.Fields["string_value"]; !exists { + t.Error("expected 'string_value' field to exist") + } + if _, exists := s.Fields["bool_value"]; !exists { + t.Error("expected 'bool_value' field to exist") + } + if _, exists := s.Fields["int_value"]; !exists { + t.Error("expected 'int_value' field to exist") + } + if _, exists := s.Fields["array_value"]; !exists { + t.Error("expected 'array_value' field to exist") + } + if _, exists := s.Fields["empty_string_value"]; exists { + t.Error("unexpected 'empty_string_value' field") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := toStructPB(tt.input) + if (err != nil) != tt.expectError { + t.Errorf("toStructPB() error = %v, expectError %v", err, tt.expectError) + } + if !tt.expectError && tt.validate != nil { + tt.validate(t, result) + } + }) + } +} + +func TestCreateAiplatformpbContent(t *testing.T) { + tests := []struct { + name string + event *session.Event + expectError bool + }{ + { + name: "simple function call args", + event: &session.Event{ + LLMResponse: model.LLMResponse{ + Content: &genai.Content{ + Parts: []*genai.Part{ + genai.NewPartFromFunctionCall("tool", map[string]any{ + "city": "Stockholm", + }), + }, + Role: genai.RoleUser, + }, + }, + }, + expectError: false, + }, + { + name: "simple function response", + event: &session.Event{ + LLMResponse: model.LLMResponse{ + Content: &genai.Content{ + Parts: []*genai.Part{ + genai.NewPartFromFunctionResponse("tool", map[string]any{ + "city": "Stockholm", + }), + }, + Role: genai.RoleUser, + }, + }, + }, + expectError: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := createAiplatformpbContent(tt.event) + if (err != nil) != tt.expectError { + t.Errorf("createAiplatformpbContent() error = %v, expectError %v", err, tt.expectError) + } + }) + } +} + +func TestCreateAiplatformpbMetadata(t *testing.T) { + tests := []struct { + name string + event *session.Event + expectError bool + }{ + { + name: "simple custom metadata", + event: &session.Event{ + LLMResponse: model.LLMResponse{ + CustomMetadata: map[string]any{ + "key": "value", + }, + }, + }, + expectError: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := createAiplatformpbMetadata(tt.event) + if (err != nil) != tt.expectError { + t.Errorf("createAiplatformpbMetadata() error = %v, expectError %v", err, tt.expectError) + } + }) + } +} From 44e3d67a427dcf834f79025299ccbfa287605f52 Mon Sep 17 00:00:00 2001 From: Karol Droste Date: Fri, 19 Jun 2026 12:21:17 +0000 Subject: [PATCH 76/86] Main linter fixes (#1068) * session test suite fix * Linter fixes --- cmd/launcher/web/a2a/a2a.go | 8 +++---- internal/llminternal/base_flow.go | 22 +++++++++++++++++ memory/vertexai/vertexai.go | 2 +- session/database/service_test.go | 6 ++--- session/inmemory_test.go | 6 ++--- .../service_suite.go | 2 +- session/vertexai/service_test.go | 24 +++++++++---------- 7 files changed, 46 insertions(+), 24 deletions(-) rename session/{session_test => sessiontestsuite}/service_suite.go (99%) diff --git a/cmd/launcher/web/a2a/a2a.go b/cmd/launcher/web/a2a/a2a.go index 26cf47787..b69558208 100644 --- a/cmd/launcher/web/a2a/a2a.go +++ b/cmd/launcher/web/a2a/a2a.go @@ -32,8 +32,8 @@ import ( "google.golang.org/adk/server/adka2a/v2" ) -// compatApiPath is a suffix used to build an A2A invocation URL for 0.3 -const compatApiPath = "/a2a/invoke" +// compatAPIPath is a suffix used to build an A2A invocation URL for 0.3 +const compatAPIPath = "/a2a/invoke" // apiPath is a suffix used to build an A2A invocation URL for 1.0 const apiPath = "/a2a/v1/invoke" @@ -83,7 +83,7 @@ func (a *a2aLauncher) Parse(args []string) ([]string, error) { // SetupSubrouters implements the web.Sublauncher interface. It adds A2A paths to the main router. func (a *a2aLauncher) SetupSubrouters(router *mux.Router, config *launcher.Config) error { - publicCompatURL, err := url.JoinPath(a.config.agentURL, compatApiPath) + publicCompatURL, err := url.JoinPath(a.config.agentURL, compatAPIPath) if err != nil { return err } @@ -132,7 +132,7 @@ func (a *a2aLauncher) SetupSubrouters(router *mux.Router, config *launcher.Confi reqHandler := a2asrv.NewHandler(executor, config.A2AOptions...) router.Handle(apiPath, a2asrv.NewJSONRPCHandler(reqHandler)) - router.Handle(compatApiPath, a2av0.NewJSONRPCHandler(reqHandler)) + router.Handle(compatAPIPath, a2av0.NewJSONRPCHandler(reqHandler)) return nil } diff --git a/internal/llminternal/base_flow.go b/internal/llminternal/base_flow.go index 083c4b5f7..b3409923b 100644 --- a/internal/llminternal/base_flow.go +++ b/internal/llminternal/base_flow.go @@ -45,20 +45,42 @@ import ( "google.golang.org/adk/tool/toolconfirmation" ) +// ErrModelNotConfigured is returned when the model is not configured. var ErrModelNotConfigured = errors.New("model not configured; ensure Model is set in llmagent.Config") +// BeforeModelCallback is called before sending a request to the model. +// +// If it returns non-nil result or error, the actual call is skipped and the returned value is used +// as the model invocation result. type BeforeModelCallback func(ctx agent.CallbackContext, llmRequest *model.LLMRequest) (*model.LLMResponse, error) +// AfterModelCallback is called after receiving a response from the model. +// +// If it returns non-nil result or error, the returned value is used as the model invocation result. type AfterModelCallback func(ctx agent.CallbackContext, llmResponse *model.LLMResponse, llmResponseError error) (*model.LLMResponse, error) +// OnModelErrorCallback is called when the model returns an error. +// +// If it returns non-nil result or error, the returned value is used as the model invocation result. type OnModelErrorCallback func(ctx agent.CallbackContext, llmRequest *model.LLMRequest, llmResponseError error) (*model.LLMResponse, error) +// BeforeToolCallback is called before executing a tool call. +// +// If it returns non-nil result or error, the actual tool call is skipped and the returned value is +// used as the tool invocation result. type BeforeToolCallback func(ctx agent.ToolContext, tool tool.Tool, args map[string]any) (map[string]any, error) +// AfterToolCallback is called after executing a tool call. +// +// If it returns non-nil result or error, the returned value is used as the tool invocation result. type AfterToolCallback func(ctx agent.ToolContext, tool tool.Tool, args, result map[string]any, err error) (map[string]any, error) +// OnToolErrorCallback is called when the tool returns an error. +// +// If it returns non-nil result or error, the returned value is used as the tool invocation result. type OnToolErrorCallback func(ctx agent.ToolContext, tool tool.Tool, args map[string]any, err error) (map[string]any, error) +// Flow is a base implementation of the agent flow. type Flow struct { Model model.LLM diff --git a/memory/vertexai/vertexai.go b/memory/vertexai/vertexai.go index 32bb74f5d..5355fc23e 100644 --- a/memory/vertexai/vertexai.go +++ b/memory/vertexai/vertexai.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// package vertexai provides support for using MemoryBank provided by VertexAI +// Package vertexai provides support for using MemoryBank provided by VertexAI package vertexai import ( diff --git a/session/database/service_test.go b/session/database/service_test.go index 2098dc9b1..59e21efab 100644 --- a/session/database/service_test.go +++ b/session/database/service_test.go @@ -23,12 +23,12 @@ import ( "google.golang.org/adk/platform" "google.golang.org/adk/session" - "google.golang.org/adk/session/session_test" + "google.golang.org/adk/session/sessiontestsuite" ) func Test_databaseService(t *testing.T) { - opts := session_test.SuiteOptions{SupportsUserProvidedSessionID: true} - session_test.RunServiceTests(t, opts, func(t *testing.T) session.Service { + opts := sessiontestsuite.SuiteOptions{SupportsUserProvidedSessionID: true} + sessiontestsuite.RunServiceTests(t, opts, func(t *testing.T) session.Service { return emptyService(t) }) } diff --git a/session/inmemory_test.go b/session/inmemory_test.go index 2085893d6..d98664ca7 100644 --- a/session/inmemory_test.go +++ b/session/inmemory_test.go @@ -23,7 +23,7 @@ import ( "google.golang.org/adk/platform" "google.golang.org/adk/session" - "google.golang.org/adk/session/session_test" + "google.golang.org/adk/session/sessiontestsuite" ) func Test_inMemoryService_CreateUsesProviders(t *testing.T) { @@ -45,8 +45,8 @@ func Test_inMemoryService_CreateUsesProviders(t *testing.T) { } func Test_inMemoryService(t *testing.T) { - opts := session_test.SuiteOptions{SupportsUserProvidedSessionID: true} // InMemory supports custom IDs - session_test.RunServiceTests(t, opts, func(t *testing.T) session.Service { + opts := sessiontestsuite.SuiteOptions{SupportsUserProvidedSessionID: true} // InMemory supports custom IDs + sessiontestsuite.RunServiceTests(t, opts, func(t *testing.T) session.Service { return session.InMemoryService() }) } diff --git a/session/session_test/service_suite.go b/session/sessiontestsuite/service_suite.go similarity index 99% rename from session/session_test/service_suite.go rename to session/sessiontestsuite/service_suite.go index 81a7b54a4..2639e194b 100644 --- a/session/session_test/service_suite.go +++ b/session/sessiontestsuite/service_suite.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package session_test +package sessiontestsuite import ( "strconv" diff --git a/session/vertexai/service_test.go b/session/vertexai/service_test.go index a48328613..2ce87faf4 100644 --- a/session/vertexai/service_test.go +++ b/session/vertexai/service_test.go @@ -28,7 +28,7 @@ import ( "google.golang.org/grpc" "google.golang.org/adk/session" - "google.golang.org/adk/session/session_test" + "google.golang.org/adk/session/sessiontestsuite" ) // if you want to test it for yourself, you can regenerate all by running @@ -40,18 +40,18 @@ import ( const ( ProjectID = "adk-go-e2e" Location = "us-central1" - EngineId = "1491331942182813696" - EngineId2 = "6857370898194759680" + EngineID = "1491331942182813696" + EngineID2 = "6857370898194759680" UserID = "test-user" ) func Test_vertexaiService(t *testing.T) { - opts := session_test.SuiteOptions{ + opts := sessiontestsuite.SuiteOptions{ SupportsUserProvidedSessionID: true, ProvidesServerAssignedEventID: true, - AppName: EngineId, + AppName: EngineID, } // VertexAI forbids custom IDs - session_test.RunServiceTests(t, opts, func(t *testing.T) session.Service { + sessiontestsuite.RunServiceTests(t, opts, func(t *testing.T) session.Service { name := strings.ReplaceAll(t.Name(), "/", "_") s, _ := emptyService(t, name, false) return s @@ -68,21 +68,21 @@ func Test_vertexaiService_AppendEvent_StructuralValidation(t *testing.T) { }{ { name: "missing_session_id", - session: &localSession{appName: EngineId, userID: UserID}, + session: &localSession{appName: EngineID, userID: UserID}, event: &session.Event{}, wantErr: true, offline: true, }, { name: "nil_event", - session: &localSession{appName: EngineId2, userID: "user2", sessionID: "session2"}, + session: &localSession{appName: EngineID2, userID: "user2", sessionID: "session2"}, event: nil, wantErr: true, offline: true, }, { name: "missing_author", - session: &localSession{appName: EngineId2, userID: "user2", sessionID: "session2"}, + session: &localSession{appName: EngineID2, userID: "user2", sessionID: "session2"}, event: &session.Event{ Timestamp: time.Now(), InvocationID: uuid.NewString(), @@ -91,7 +91,7 @@ func Test_vertexaiService_AppendEvent_StructuralValidation(t *testing.T) { }, { name: "missing_invocation_id", - session: &localSession{appName: EngineId2, userID: "user2", sessionID: "session2"}, + session: &localSession{appName: EngineID2, userID: "user2", sessionID: "session2"}, event: &session.Event{ Timestamp: time.Now(), Author: UserID, @@ -153,8 +153,8 @@ func emptyService(t *testing.T, name string, offline bool) (session.Service, map } func deleteAll(t *testing.T, v session.Service) { - deleteAllFromApp(t, v, EngineId) - deleteAllFromApp(t, v, EngineId2) + deleteAllFromApp(t, v, EngineID) + deleteAllFromApp(t, v, EngineID2) } func deleteAllFromApp(t *testing.T, v session.Service, app string) { From 53502666c10261bdd2a95f56eddec3562333717f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Westerberg?= Date: Wed, 24 Jun 2026 09:46:14 +0100 Subject: [PATCH 77/86] fix(conformance): Add "system_instruction" keyNode processing (#1075) --- internal/configurable/conformance/replayplugin/yaml_utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/configurable/conformance/replayplugin/yaml_utils.go b/internal/configurable/conformance/replayplugin/yaml_utils.go index 1dddcbec6..f344f23ce 100644 --- a/internal/configurable/conformance/replayplugin/yaml_utils.go +++ b/internal/configurable/conformance/replayplugin/yaml_utils.go @@ -54,7 +54,7 @@ func normalizeYAMLNode(node *yaml.Node) { // 1. Fix known type mismatches and singular/plural fields switch keyNode.Value { - case "systeminstruction": + case "systeminstruction", "system_instruction": if valueNode.Kind == yaml.ScalarNode { val := valueNode.Value valueNode.Kind = yaml.MappingNode From caf798a0a20c46c498a5b736a16b433a81daf839 Mon Sep 17 00:00:00 2001 From: nuthalapativarun Date: Tue, 30 Jun 2026 01:14:43 -0700 Subject: [PATCH 78/86] test(session/vertexai): add table-driven tests for FunctionCall/Response mapping (#739) Add TestAiplatformToGenaiContent_FunctionCallMapping, a table-driven test that verifies aiplatformToGenaiContent correctly preserves: - ID, Name, and Args for FunctionCall parts - ID, Name, and Response for FunctionResponse parts - empty-string IDs are passed through unchanged The table-driven format matches the style used elsewhere in this file and makes it easy to add further cases. --- session/vertexai/vertexai_test.go | 134 +++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) diff --git a/session/vertexai/vertexai_test.go b/session/vertexai/vertexai_test.go index ab2fec2da..9b37a5fa8 100644 --- a/session/vertexai/vertexai_test.go +++ b/session/vertexai/vertexai_test.go @@ -21,12 +21,142 @@ import ( "google.golang.org/adk/session" "google.golang.org/adk/util/vertexai" + aiplatformpb "cloud.google.com/go/aiplatform/apiv1beta1/aiplatformpb" "google.golang.org/genai" "google.golang.org/protobuf/types/known/structpb" - - aiplatformpb "cloud.google.com/go/aiplatform/apiv1beta1/aiplatformpb" ) +func TestAiplatformToGenaiContent_FunctionCallMapping(t *testing.T) { + makeArgs := func(m map[string]any) *structpb.Struct { + s, err := structpb.NewStruct(m) + if err != nil { + t.Fatalf("failed to create struct: %v", err) + } + return s + } + + tests := []struct { + name string + input *aiplatformpb.SessionEvent + wantID string + wantName string + wantArgKey string + wantArgVal string + isResponse bool + wantRespKey string + wantRespVal string + }{ + { + name: "FunctionCall preserves ID, Name, and Args", + input: &aiplatformpb.SessionEvent{ + Content: &aiplatformpb.Content{ + Role: "model", + Parts: []*aiplatformpb.Part{ + { + Data: &aiplatformpb.Part_FunctionCall{ + FunctionCall: &aiplatformpb.FunctionCall{ + Id: "call-id-abc", + Name: "my_tool", + Args: makeArgs(map[string]any{"param": "value"}), + }, + }, + }, + }, + }, + }, + wantID: "call-id-abc", + wantName: "my_tool", + wantArgKey: "param", + wantArgVal: "value", + }, + { + name: "FunctionCall with empty ID is preserved as empty", + input: &aiplatformpb.SessionEvent{ + Content: &aiplatformpb.Content{ + Role: "model", + Parts: []*aiplatformpb.Part{ + { + Data: &aiplatformpb.Part_FunctionCall{ + FunctionCall: &aiplatformpb.FunctionCall{ + Id: "", + Name: "tool_no_id", + Args: makeArgs(map[string]any{"x": "y"}), + }, + }, + }, + }, + }, + }, + wantID: "", + wantName: "tool_no_id", + wantArgKey: "x", + wantArgVal: "y", + }, + { + name: "FunctionResponse preserves ID, Name, and Response", + isResponse: true, + input: &aiplatformpb.SessionEvent{ + Content: &aiplatformpb.Content{ + Role: "user", + Parts: []*aiplatformpb.Part{ + { + Data: &aiplatformpb.Part_FunctionResponse{ + FunctionResponse: &aiplatformpb.FunctionResponse{ + Id: "call-id-abc", + Name: "my_tool", + Response: makeArgs(map[string]any{"result": "ok"}), + }, + }, + }, + }, + }, + }, + wantID: "call-id-abc", + wantName: "my_tool", + wantRespKey: "result", + wantRespVal: "ok", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := aiplatformToGenaiContent(tt.input) + if got == nil || len(got.Parts) == 0 { + t.Fatal("expected at least one part, got nil or empty") + } + if tt.isResponse { + fr := got.Parts[0].FunctionResponse + if fr == nil { + t.Fatal("expected FunctionResponse part, got nil") + } + if fr.ID != tt.wantID { + t.Errorf("FunctionResponse.ID = %q, want %q", fr.ID, tt.wantID) + } + if fr.Name != tt.wantName { + t.Errorf("FunctionResponse.Name = %q, want %q", fr.Name, tt.wantName) + } + if got, ok := fr.Response[tt.wantRespKey]; !ok || got != tt.wantRespVal { + t.Errorf("FunctionResponse.Response[%q] = %v, want %q", tt.wantRespKey, got, tt.wantRespVal) + } + } else { + fc := got.Parts[0].FunctionCall + if fc == nil { + t.Fatal("expected FunctionCall part, got nil") + } + if fc.ID != tt.wantID { + t.Errorf("FunctionCall.ID = %q, want %q", fc.ID, tt.wantID) + } + if fc.Name != tt.wantName { + t.Errorf("FunctionCall.Name = %q, want %q", fc.Name, tt.wantName) + } + if got, ok := fc.Args[tt.wantArgKey]; !ok || got != tt.wantArgVal { + t.Errorf("FunctionCall.Args[%q] = %v, want %q", tt.wantArgKey, got, tt.wantArgVal) + } + } + }) + } +} + func TestGetReasoningEngineID(t *testing.T) { tests := []struct { name string From 0265e02a4a0dcf2178348ec5d8f5f9709663f552 Mon Sep 17 00:00:00 2001 From: David Mora Date: Mon, 13 Jul 2026 09:26:14 -0600 Subject: [PATCH 79/86] docs(model): document dual session-resumption carriers on LLMResponse Post-v1.5.0-merge, LLMResponse carries session resumption twice by design: upstream's SessionResumptionHandle string (populated by the googlellm connection, read by llminternal Flow.RunLive) and the fork's structured SessionResumptionUpdate (populated by model/gemini, consumed by the liveflow engine, which also honors Resumable=false handle invalidation). Document that on the fork field so neither carrier is removed as an apparent duplicate during future syncs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WQUNntrYi9Yn1ExuN9f5VR --- model/llm.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/model/llm.go b/model/llm.go index 00b0b1ba8..8cb2d4b29 100644 --- a/model/llm.go +++ b/model/llm.go @@ -66,7 +66,13 @@ type LLMResponse struct { // SessionResumptionHandle is the upstream (v1.5.0) live resumption handle. SessionResumptionHandle string - // Live-only: session resumption state update from the server. + // SessionResumptionUpdate is the fork's structured session-resumption + // carrier (Live-only). Two carriers coexist by design — do not remove + // either: the upstream live engine (llminternal Flow.RunLive, fed by + // the googlellm connection) reads the plain SessionResumptionHandle + // string above, while the hulilabs liveflow engine + // (internal/llminternal/liveflow, fed by model/gemini) consumes this + // full update so it can also honor Resumable=false handle invalidation. SessionResumptionUpdate *genai.LiveServerSessionResumptionUpdate // Live-only: GoAway signal indicating the server wants the client to reconnect. GoAway *genai.LiveServerGoAway From bfda3b40a61742573bbb1bb3a567f5a359663307 Mon Sep 17 00:00:00 2001 From: David Mora Date: Mon, 13 Jul 2026 09:26:23 -0600 Subject: [PATCH 80/86] docs(liveflow): document relationship to the upstream live engine The v1.5.0 merge brought in a second live engine: Runner.RunLive / agent.LiveSession / adkrest /run_live drive upstream Flow.RunLive plus the googlellm connection. Fence it off in the liveflow package doc by listing its verified v1.5.0 gaps (string-matched GoAway, no reconnect backoff or attempt budget, history re-sent on resume, dropped ToolCallCancellation, input transcriptions authored as the agent) and naming Runner.RunLiveQueue + liveflow as the hulilabs-supported path. Strengthen the RunLiveQueue doc comment to point at that comparison. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WQUNntrYi9Yn1ExuN9f5VR --- internal/llminternal/liveflow/doc.go | 26 ++++++++++++++++++++++++++ runner/runner.go | 11 +++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/internal/llminternal/liveflow/doc.go b/internal/llminternal/liveflow/doc.go index 93b9f6a52..66bbba8c1 100644 --- a/internal/llminternal/liveflow/doc.go +++ b/internal/llminternal/liveflow/doc.go @@ -33,4 +33,30 @@ // // eventOrError and sendEvent (flow.go) are the shared channel currency // every concern uses to publish events into RunLive's iterator output. +// +// # Relationship to the upstream live engine +// +// Since the google/adk-go v1.5.0 merge, two live engines coexist in this +// repository. Runner.RunLive, the agent.LiveSession API, and the adkrest +// /run_live endpoint drive the UPSTREAM engine (llminternal Flow.RunLive +// plus the googlellm live connection) — not this package. As of v1.5.0 +// that engine has known gaps: +// +// - GoAway is not surfaced as a signal; reconnect eligibility is decided +// by substring-matching error text ("GoAway", "EOF", "1008", ...). +// - Reconnects retry immediately in a loop with no backoff and no +// attempt budget. +// - The preprocessed history is re-sent on every reconnect, even when +// resuming with a session handle the server already has context for. +// - ToolCallCancellation server messages are dropped, so cancelled +// tool calls keep running. +// - Every event is authored as the agent, so input transcriptions +// (user speech) are misattributed to the model in session history. +// +// Runner.RunLiveQueue driving this package is the hulilabs-supported live +// path: it handles each of the above (GoAway-aware reconnects with +// resumption handles, turn-cycle replay suppression, tool cancellation, +// role-correct transcription events). Route new live features and fixes +// here; treat the upstream engine as upstream-owned code that syncs with +// google/adk-go. package liveflow diff --git a/runner/runner.go b/runner/runner.go index 7ead849e1..69ff2af10 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -601,12 +601,15 @@ func (r *Runner) appendMessageToSession(ctx agent.InvocationContext, storedSessi // Unlike Run(), it always uses the root agent and does not append an initial user message. // Audio events (CustomMetadata["is_audio"]==true) are not persisted to the session. // -// RunLiveQueue is the fork-supported live entry point: it drives the +// RunLiveQueue is the hulilabs-supported live entry point: it drives the // internal/llminternal/liveflow engine via a *agent.LiveRequestQueue and // returns the event stream as an iterator. It coexists with the upstream -// [Runner.RunLive] (agent.LiveSession API) that arrived in v1.5.0; the method -// was renamed from RunLive so upstream keeps that name and future syncs stay -// conflict-free. +// [Runner.RunLive] (agent.LiveSession API) that arrived in v1.5.0, which +// drives the upstream live engine — as of v1.5.0 that engine has known +// gaps (string-matched GoAway, no reconnect backoff, history re-sent on +// resume, tool cancellation ignored; see the liveflow package doc for the +// full comparison). The method was renamed from RunLive so upstream keeps +// that name and future syncs stay conflict-free. func (r *Runner) RunLiveQueue( ctx context.Context, userID, sessionID string, From 7cb80062177116af526d03a1e695f6868304eb90 Mon Sep 17 00:00:00 2001 From: David Mora Date: Mon, 13 Jul 2026 09:58:53 -0600 Subject: [PATCH 81/86] fix(runner): extend sub-runner plugin-inheritance guard to upstream RunLive Fork fix #46 (2208905) made Run and the queue-based live entry point (now RunLiveQueue) skip installing the runner's own plugin manager into context when it has no plugins, so a plugin-less sub-runner inherits the parent's manager and model/tool/agent callbacks keep propagating. The upstream Runner.RunLive that arrived with v1.5.0 kept the unconditional upstream line, so a plugin-less sub-runner driven through it would clobber an inherited parent manager with its own empty one. Apply the same HasPlugins guard there and add a regression test that captures the context plugin manager via a fake live agent (the upstream live engine itself cannot run offline). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WQUNntrYi9Yn1ExuN9f5VR --- runner/runner.go | 7 +- runner/runner_subagent_plugin_test.go | 101 ++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/runner/runner.go b/runner/runner.go index 69ff2af10..5f0cc405d 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -380,7 +380,12 @@ func (r *Runner) RunLive(ctx context.Context, userID, sessionID string, cfg agen StreamingMode: runconfig.StreamingModeBidi, // Live is always bidirectional streaming Live: &cfg, }) - ctx = plugininternal.ToContext(ctx, r.pluginManager) + // Same guard as Run() and RunLiveQueue(): a plugin-less (sub-)runner must + // inherit the parent's plugin manager from context instead of clobbering + // it with its own empty one, so model/tool/agent callbacks still propagate. + if r.pluginManager != nil && r.pluginManager.HasPlugins() { + ctx = plugininternal.ToContext(ctx, r.pluginManager) + } var artifacts agent.Artifacts if r.artifactService != nil { diff --git a/runner/runner_subagent_plugin_test.go b/runner/runner_subagent_plugin_test.go index 032ea7822..002c485b7 100644 --- a/runner/runner_subagent_plugin_test.go +++ b/runner/runner_subagent_plugin_test.go @@ -428,3 +428,104 @@ func TestRunLiveInheritsContextPluginManager(t *testing.T) { t.Errorf("seeded plugin afterAgent = %d, want exactly 1 (RunLive inherited context manager)", afterAgent) } } + +// --------------------------------------------------------------------------- +// Regression test: the upstream-API RunLive entry point (agent.LiveSession +// signature, added in v1.5.0) must apply the same plugin-manager inheritance +// guard as Run and RunLiveQueue. +// +// The upstream live engine cannot run offline (it requires a *genai.Client +// live connection), so instead of driving it end-to-end this test uses a fake +// live agent that captures the plugin manager present in the invocation +// context RunLive hands to the agent. A plugin-less runner must leave the +// context-seeded parent manager in place; without the guard, the runner's own +// empty manager replaces it and parent plugin callbacks stop propagating. +// --------------------------------------------------------------------------- + +// noopLiveSession is a no-op agent.LiveSession returned by captureLiveAgent. +type noopLiveSession struct{} + +func (s *noopLiveSession) Send(agent.LiveRequest) error { return nil } +func (s *noopLiveSession) Close() error { return nil } + +// captureLiveAgent embeds a plain agent.Agent (the interface has an unexported +// method, so it cannot be implemented directly by a test type) and adds the +// RunLive method the runner's upstream live entry point type-asserts for, +// recording the plugin manager found in the invocation context. +type captureLiveAgent struct { + agent.Agent + mu sync.Mutex + captured *plugininternal.PluginManager +} + +func (a *captureLiveAgent) RunLive(ctx agent.InvocationContext) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { + a.mu.Lock() + a.captured = plugininternal.FromContext(ctx) + a.mu.Unlock() + return &noopLiveSession{}, func(func(*session.Event, error) bool) {}, nil +} + +func (a *captureLiveAgent) capturedManager() *plugininternal.PluginManager { + a.mu.Lock() + defer a.mu.Unlock() + return a.captured +} + +func TestUpstreamRunLiveInheritsContextPluginManager(t *testing.T) { + counter := newCallCounter() + parentMgr, err := plugininternal.NewPluginManager(plugininternal.PluginConfig{ + Plugins: []*plugin.Plugin{counter.plugin(t)}, + }) + if err != nil { + t.Fatalf("plugininternal.NewPluginManager: %v", err) + } + seededCtx := plugininternal.ToContext(context.Background(), parentMgr) + + inner, err := agent.New(agent.Config{ + Name: "capture_agent", + Description: "captures the context plugin manager from RunLive", + Run: func(agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(func(*session.Event, error) bool) {} + }, + }) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + capture := &captureLiveAgent{Agent: inner} + + svc := session.InMemoryService() + if _, err := svc.Create(context.Background(), &session.CreateRequest{ + AppName: "test", + UserID: "user1", + SessionID: "sess1", + }); err != nil { + t.Fatalf("session create: %v", err) + } + + // Empty PluginConfig: this runner owns no plugins, so its guard must leave + // the context-seeded parent manager in place. + r, err := runner.New(runner.Config{ + AppName: "test", + Agent: capture, + SessionService: svc, + PluginConfig: runner.PluginConfig{}, + }) + if err != nil { + t.Fatalf("runner.New: %v", err) + } + + sess, events, err := r.RunLive(seededCtx, "user1", "sess1", agent.LiveRunConfig{}) + if err != nil { + t.Fatalf("RunLive: %v", err) + } + defer func() { _ = sess.Close() }() + for _, err := range events { + if err != nil { + t.Fatalf("RunLive yielded error: %v", err) + } + } + + if got := capture.capturedManager(); got != parentMgr { + t.Errorf("RunLive installed plugin manager %p in the invocation context, want the inherited parent manager %p", got, parentMgr) + } +} From 4cfd2e8bb6088c769120fd065ee386491d5bbc7c Mon Sep 17 00:00:00 2001 From: David Mora Date: Mon, 13 Jul 2026 09:59:34 -0600 Subject: [PATCH 82/86] docs(session): point LiveDiagnostics docs at RunLiveQueue after rename The v1.5.0 merge renamed the fork's queue-based Runner.RunLive to RunLiveQueue and rebound the RunLive name to upstream's new agent.LiveSession entry point, whose engine never sets LiveDiagnostics. The LiveDiagnostics struct doc and the session.Event field doc still said the field is populated "for events from RunLive", steering upstream-RunLive callers into expecting diagnostics they will never get. Reference RunLiveQueue and state explicitly that upstream-API RunLive events carry nil. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WQUNntrYi9Yn1ExuN9f5VR --- session/live_diagnostics.go | 6 ++++-- session/session.go | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/session/live_diagnostics.go b/session/live_diagnostics.go index ed6b018ce..956f3d309 100644 --- a/session/live_diagnostics.go +++ b/session/live_diagnostics.go @@ -17,8 +17,10 @@ package session import "time" // LiveDiagnostics captures computed timing and protocol state for a live -// streaming event. Only populated for events produced by RunLive; nil for -// standard Run() events. +// streaming event. Only populated for events produced by the runner's +// RunLiveQueue; nil for standard Run() events and for events produced by +// the upstream-API RunLive (agent.LiveSession signature), whose engine does +// not compute diagnostics. // // EPHEMERAL: This struct is NOT persisted to storage. It is attached to // yielded events for the caller's use only. The runner strips it before diff --git a/session/session.go b/session/session.go index 7c0564211..7f5935687 100644 --- a/session/session.go +++ b/session/session.go @@ -118,9 +118,9 @@ type Event struct { // Only valid for function call event. LongRunningToolIDs []string - // LiveDiagnostics is populated only for events from RunLive sessions. - // EPHEMERAL: not persisted to storage. Nil for standard Run() events - // and for events loaded from storage. + // LiveDiagnostics is populated only for events from RunLiveQueue sessions. + // EPHEMERAL: not persisted to storage. Nil for standard Run() events, for + // events from the upstream-API RunLive, and for events loaded from storage. LiveDiagnostics *LiveDiagnostics } From 9617b717715c94fd0e3ae4c5fc1c60cc0ad4b61c Mon Sep 17 00:00:00 2001 From: David Mora Date: Mon, 13 Jul 2026 10:00:23 -0600 Subject: [PATCH 83/86] docs(runner): warn pre-v1.5.0 fork callers on the rebound RunLive name The v1.5.0 merge renamed the fork's queue-based Runner.RunLive to RunLiveQueue while upstream introduced a different RunLive (agent.LiveSession API) under the surviving name. The compile break for out-of-repo fork consumers is loud, but the surviving name silently points at the weaker upstream engine, so a consumer adapting to the new signature could migrate onto it unknowingly. Document on RunLive itself that it is the upstream engine, that its events never carry LiveDiagnostics, and that pre-v1.5.0 callers belong on RunLiveQueue. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WQUNntrYi9Yn1ExuN9f5VR --- runner/runner.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/runner/runner.go b/runner/runner.go index 5f0cc405d..53b3e3cd0 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -335,6 +335,17 @@ func (s *closedLiveSession) Close() error { return nil } +// RunLive runs the agent in live bidirectional streaming mode via the +// upstream (google/adk-go) live engine introduced in v1.5.0, returning an +// [agent.LiveSession] for sending client events alongside the event stream. +// +// Fork note: this is not the method named RunLive before the v1.5.0 merge — +// the fork's queue-based entry point is now [Runner.RunLiveQueue] and remains +// the hulilabs-supported live path. The upstream engine behind this method +// has known gaps (see [Runner.RunLiveQueue] and the +// internal/llminternal/liveflow package doc) and its events never carry +// [session.LiveDiagnostics]. Pre-v1.5.0 RunLive callers should migrate to +// RunLiveQueue, not to this method. func (r *Runner) RunLive(ctx context.Context, userID, sessionID string, cfg agent.LiveRunConfig, opts ...RunOption) (agent.LiveSession, iter.Seq2[*session.Event, error], error) { options := runOptions{} for _, opt := range opts { From cc9f75673f9ebf152d2cb3961383dbbe82365656 Mon Sep 17 00:00:00 2001 From: David Mora Date: Mon, 13 Jul 2026 12:34:20 -0600 Subject: [PATCH 84/86] test(runner): cover own-manager install on upstream RunLive plugin guard The three existing tests all prove the inheritance branch (plugin-less runner leaves the context-seeded parent manager). A guard regressed to never installing would still pass the suite. Add the positive counterpart: a runner that owns plugins must replace the seeded parent manager with its own. Verified the test fails with the guard mutated to never-install. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WQUNntrYi9Yn1ExuN9f5VR --- runner/runner_subagent_plugin_test.go | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/runner/runner_subagent_plugin_test.go b/runner/runner_subagent_plugin_test.go index 002c485b7..f36c47254 100644 --- a/runner/runner_subagent_plugin_test.go +++ b/runner/runner_subagent_plugin_test.go @@ -529,3 +529,71 @@ func TestUpstreamRunLiveInheritsContextPluginManager(t *testing.T) { t.Errorf("RunLive installed plugin manager %p in the invocation context, want the inherited parent manager %p", got, parentMgr) } } + +// TestUpstreamRunLiveInstallsOwnPluginManager is the positive counterpart of +// TestUpstreamRunLiveInheritsContextPluginManager: a runner that owns plugins +// must install its own manager over a context-seeded parent one. Without this +// case, a guard regressed to never installing would still pass the suite. +func TestUpstreamRunLiveInstallsOwnPluginManager(t *testing.T) { + parentCounter := newCallCounter() + parentMgr, err := plugininternal.NewPluginManager(plugininternal.PluginConfig{ + Plugins: []*plugin.Plugin{parentCounter.plugin(t)}, + }) + if err != nil { + t.Fatalf("plugininternal.NewPluginManager: %v", err) + } + seededCtx := plugininternal.ToContext(context.Background(), parentMgr) + + inner, err := agent.New(agent.Config{ + Name: "capture_agent", + Description: "captures the context plugin manager from RunLive", + Run: func(agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(func(*session.Event, error) bool) {} + }, + }) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + capture := &captureLiveAgent{Agent: inner} + + svc := session.InMemoryService() + if _, err := svc.Create(context.Background(), &session.CreateRequest{ + AppName: "test", + UserID: "user1", + SessionID: "sess1", + }); err != nil { + t.Fatalf("session create: %v", err) + } + + // This runner owns a plugin, so the HasPlugins guard must fire and replace + // the seeded parent manager with the runner's own. + ownCounter := newCallCounter() + r, err := runner.New(runner.Config{ + AppName: "test", + Agent: capture, + SessionService: svc, + PluginConfig: runner.PluginConfig{Plugins: []*plugin.Plugin{ownCounter.plugin(t)}}, + }) + if err != nil { + t.Fatalf("runner.New: %v", err) + } + + sess, events, err := r.RunLive(seededCtx, "user1", "sess1", agent.LiveRunConfig{}) + if err != nil { + t.Fatalf("RunLive: %v", err) + } + defer func() { _ = sess.Close() }() + for _, err := range events { + if err != nil { + t.Fatalf("RunLive yielded error: %v", err) + } + } + + got := capture.capturedManager() + if got == parentMgr { + t.Errorf("RunLive left the seeded parent manager %p in the invocation context; want the runner's own manager (guard must install when the runner has plugins)", parentMgr) + } + if !got.HasPlugins() { + t.Errorf("RunLive installed a plugin manager with no plugins in the invocation context; want the runner's own populated manager") + } +} From ab2389ae2a5e90e6666eb1fe6119463ee16a2cac Mon Sep 17 00:00:00 2001 From: David Mora Date: Mon, 13 Jul 2026 12:34:20 -0600 Subject: [PATCH 85/86] refactor(context): realign invocation context with upstream order and docs The v1.5.0 merge union kept the fork's method ordering and dropped upstream's doc comments on the resumption-handle accessors, inflating the permanent diff against upstream in a file both sides edit. Restore upstream's declaration order and comments so the fork delta is exactly the LiveRequestQueue param and accessor. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WQUNntrYi9Yn1ExuN9f5VR --- internal/context/invocation_context.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/context/invocation_context.go b/internal/context/invocation_context.go index 134a71fa8..b1bd1acbf 100644 --- a/internal/context/invocation_context.go +++ b/internal/context/invocation_context.go @@ -92,14 +92,6 @@ func (c *InvocationContext) LiveRequestQueue() *agent.LiveRequestQueue { return c.params.LiveRequestQueue } -func (c *InvocationContext) SetLiveSessionResumptionHandle(handle string) { - c.params.LiveSessionResumptionHandle = handle -} - -func (c *InvocationContext) LiveSessionResumptionHandle() string { - return c.params.LiveSessionResumptionHandle -} - func (c *InvocationContext) EndInvocation() { c.params.EndInvocation = true } @@ -108,6 +100,18 @@ func (c *InvocationContext) Ended() bool { return c.params.EndInvocation } +// LiveSessionResumptionHandle returns the active live session's resumption handle. +// This handle is used to reconnect and resume a bidirectional streaming session with the model. +func (c *InvocationContext) LiveSessionResumptionHandle() string { + return c.params.LiveSessionResumptionHandle +} + +// SetLiveSessionResumptionHandle stores the latest resumption handle received from the model. +// This allows subsequent reconnection attempts in the live loop to resume the same session state. +func (c *InvocationContext) SetLiveSessionResumptionHandle(handle string) { + c.params.LiveSessionResumptionHandle = handle +} + func (c *InvocationContext) WithContext(ctx context.Context) agent.InvocationContext { newCtx := *c newCtx.Context = ctx From 700d2793170549670e4ac8191934407b42e7aa5e Mon Sep 17 00:00:00 2001 From: David Mora Date: Mon, 13 Jul 2026 14:29:02 -0600 Subject: [PATCH 86/86] docs(liveflow): add upstream engine goroutine leak to known-gaps list Review feedback on PR #50 identified a per-reconnect goroutine leak in the upstream Flow.RunLive engine (unbuffered errChan abandoned on resumable errors). The engine is fenced as unsupported here; record the gap alongside the others and link the upstream report (google/adk-go#1152). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WQUNntrYi9Yn1ExuN9f5VR --- internal/llminternal/liveflow/doc.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/llminternal/liveflow/doc.go b/internal/llminternal/liveflow/doc.go index 66bbba8c1..cc9034ca2 100644 --- a/internal/llminternal/liveflow/doc.go +++ b/internal/llminternal/liveflow/doc.go @@ -46,6 +46,9 @@ // by substring-matching error text ("GoAway", "EOF", "1008", ...). // - Reconnects retry immediately in a loop with no backoff and no // attempt budget. +// - Each reconnect abandons the previous attempt's unbuffered error +// channel, leaking a blocked reader/sender goroutine per cycle +// (reported upstream: google/adk-go#1152). // - The preprocessed history is re-sent on every reconnect, even when // resuming with a session handle the server already has context for. // - ToolCallCancellation server messages are dropped, so cancelled

?ss1c?z@cPYSnp7t=*JCI|qBLFLw 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