Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions core/src/main/java/com/google/adk/agents/InvocationContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,26 @@
package com.google.adk.agents;

import static com.google.common.base.Strings.isNullOrEmpty;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;

import com.google.adk.apps.ResumabilityConfig;
import com.google.adk.artifacts.BaseArtifactService;
import com.google.adk.events.Event;
import com.google.adk.memory.BaseMemoryService;
import com.google.adk.models.LlmCallsLimitExceededException;
import com.google.adk.plugins.Plugin;
import com.google.adk.plugins.PluginManager;
import com.google.adk.sessions.BaseSessionService;
import com.google.adk.sessions.Session;
import com.google.adk.summarizer.EventsCompactionConfig;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.genai.types.Content;
import com.google.genai.types.FunctionCall;
import com.google.genai.types.FunctionResponse;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
Expand All @@ -41,6 +49,8 @@
@SuppressWarnings("deprecation") // Plumbs the deprecated ResumabilityConfig.
public class InvocationContext {

private static final String USER_AUTHOR = "user";

private final BaseSessionService sessionService;
private final BaseArtifactService artifactService;
private final BaseMemoryService memoryService;
Expand Down Expand Up @@ -156,6 +166,80 @@ public Session session() {
return session;
}

/**
* Returns a snapshot of the session's events, keeping only those on the branch this invocation is
* running on.
*
* <p>The rule is author-asymmetric on purpose, so a confirmation the user answered on a
* sub-branch stays visible while a descendant agent's own events do not.
*/
public ImmutableList<Event> eventsOnCurrentBranch() {
ImmutableList<Event> events;
synchronized (session.events()) {
events = ImmutableList.copyOf(session.events());
}
// Snapshot the mutable branch too, so it cannot change between the id set and the filter.
@Nullable String scopeBranch = branch;
// Only the user-response cross-check needs these, and a null or empty branch skips it.
ImmutableSet<String> branchFunctionCallIds =
isNullOrEmpty(scopeBranch) ? ImmutableSet.of() : branchFunctionCallIds(events, scopeBranch);
return events.stream()
.filter(event -> isOnCurrentBranch(event, scopeBranch, branchFunctionCallIds))
.collect(toImmutableList());
}

/**
* Returns whether {@code event} belongs to this invocation's branch.
*
* <p>A user event matches this branch, a descendant sub-branch, or no branch at all; one carrying
* function responses must additionally answer a call issued on this branch or below, which is
* what stops a reply leaking in from a parallel tree. Any other event must sit on exactly this
* branch, so a descendant's own events stay hidden.
*/
private boolean isOnCurrentBranch(
Event event, @Nullable String scopeBranch, ImmutableSet<String> branchFunctionCallIds) {
@Nullable String eventBranch = event.branch().orElse(null);
if (!Objects.equals(event.author(), USER_AUTHOR)) {
return Objects.equals(eventBranch, scopeBranch);
}
if (!isNullOrEmpty(scopeBranch)) {
ImmutableSet<String> responseIds =
event.functionResponses().stream()
.map(FunctionResponse::id)
.flatMap(Optional::stream)
.collect(toImmutableSet());
if (!responseIds.isEmpty() && Collections.disjoint(responseIds, branchFunctionCallIds)) {
return false;
}
}
// Mirrors Python's `self.branch` guard: an empty branch has no descendants.
return eventBranch == null
|| scopeBranch == null
|| eventBranch.equals(scopeBranch)
|| (!scopeBranch.isEmpty() && eventBranch.startsWith(scopeBranch + "."));
}

/**
* Returns the IDs of function calls issued on this branch or on a descendant sub-branch.
*
* <p>Branches are dot-joined, so the trailing dot keeps the prefix test on a segment boundary.
*/
private ImmutableSet<String> branchFunctionCallIds(
ImmutableList<Event> events, String scopeBranch) {
String descendantPrefix = scopeBranch + ".";
return events.stream()
.filter(
event -> {
@Nullable String eventBranch = event.branch().orElse(null);
return !isNullOrEmpty(eventBranch)
&& (eventBranch.equals(scopeBranch) || eventBranch.startsWith(descendantPrefix));
})
.flatMap(event -> event.functionCalls().stream())
.map(FunctionCall::id)
.flatMap(Optional::stream)
.collect(toImmutableSet());
}

/** Returns the user content that triggered this invocation, if any. */
public Optional<Content> userContent() {
return Optional.ofNullable(userContent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ public class RequestConfirmationLlmRequestProcessor implements RequestProcessor
@Override
public Single<RequestProcessor.RequestProcessingResult> processRequest(
InvocationContext invocationContext, LlmRequest llmRequest) {
ImmutableList<Event> events = ImmutableList.copyOf(invocationContext.session().events());
// A confirmation is answered on the branch that asked for it; a parallel tree's is not ours.
ImmutableList<Event> events = invocationContext.eventsOnCurrentBranch();
if (events.isEmpty()) {
logger.trace(
"No events are present in the session. Skipping request confirmation processing.");
Expand Down
186 changes: 186 additions & 0 deletions core/src/test/java/com/google/adk/agents/InvocationContextTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,23 @@
import static org.mockito.Mockito.mock;

import com.google.adk.artifacts.BaseArtifactService;
import com.google.adk.events.Event;
import com.google.adk.memory.BaseMemoryService;
import com.google.adk.models.LlmCallsLimitExceededException;
import com.google.adk.plugins.PluginManager;
import com.google.adk.sessions.BaseSessionService;
import com.google.adk.sessions.Session;
import com.google.adk.summarizer.EventsCompactionConfig;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.genai.types.Content;
import com.google.genai.types.FunctionCall;
import com.google.genai.types.FunctionResponse;
import com.google.genai.types.Part;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.jspecify.annotations.Nullable;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
Expand Down Expand Up @@ -724,4 +730,184 @@ public void build_missingSessionService_throwsException() {
IllegalStateException exception = assertThrows(IllegalStateException.class, builder::build);
assertThat(exception).hasMessageThat().isEqualTo("Session service must be set.");
}

@Test
public void eventsOnCurrentBranch_userEventOnSubBranch_isIncluded() {
Event userOnChild = userEvent("agent_1.child");

assertThat(contextOnBranch("agent_1", userOnChild).eventsOnCurrentBranch())
.containsExactly(userOnChild);
}

@Test
public void eventsOnCurrentBranch_agentEventOnSubBranch_isExcluded() {
// Asymmetric with the user case on purpose: descendants' internal events stay hidden.
Event agentOnChild = agentEvent("agent_1.child");

assertThat(contextOnBranch("agent_1", agentOnChild).eventsOnCurrentBranch()).isEmpty();
}

@Test
public void eventsOnCurrentBranch_siblingBranch_isExcluded() {
Event userOnSibling = userEvent("agent_2");

assertThat(contextOnBranch("agent_1", userOnSibling).eventsOnCurrentBranch()).isEmpty();
}

@Test
public void eventsOnCurrentBranch_userEventOnLookalikeBranch_isExcluded() {
// "agent_10" shares a prefix with "agent_1" but is not a sub-branch of it.
Event userOnLookalike = userEvent("agent_10");

assertThat(contextOnBranch("agent_1", userOnLookalike).eventsOnCurrentBranch()).isEmpty();
}

@Test
public void eventsOnCurrentBranch_emptyBranch_doesNotMatchBranchedEvents() {
// An empty string is a real branch value, not a synonym for "match everything".
Event userOnBranch = userEvent("agent_1");

assertThat(contextOnBranch("", userOnBranch).eventsOnCurrentBranch()).isEmpty();
}

@Test
public void eventsOnCurrentBranch_noBranch_matchesEveryUserEventButNotAgentEvents() {
Event userElsewhere = userEvent("agent_2.child");
Event agentElsewhere = agentEvent("agent_2.child");

assertThat(contextOnBranch(null, userElsewhere, agentElsewhere).eventsOnCurrentBranch())
.containsExactly(userElsewhere);
}

@Test
public void eventsOnCurrentBranch_userResponseToCallInSubtree_isKept() {
Event callOnChild = callEvent("agent_1.child", "fc_1");
Event reply = userResponseEvent("agent_1", "fc_1");

assertThat(contextOnBranch("agent_1", callOnChild, reply).eventsOnCurrentBranch())
.containsExactly(reply);
}

@Test
public void eventsOnCurrentBranch_userResponseToCallElsewhere_isDropped() {
// Sitting on this branch is not enough: the reply answers a parallel tree's call.
Event callElsewhere = callEvent("agent_2", "fc_1");
Event reply = userResponseEvent("agent_1", "fc_1");

assertThat(contextOnBranch("agent_1", callElsewhere, reply).eventsOnCurrentBranch()).isEmpty();
}

@Test
public void eventsOnCurrentBranch_userResponseToLookalikeBranchCall_isDropped() {
// "agent_10" shares a prefix with "agent_1" but is not a sub-branch of it.
Event callOnLookalike = callEvent("agent_10", "fc_1");
Event reply = userResponseEvent("agent_1", "fc_1");

assertThat(contextOnBranch("agent_1", callOnLookalike, reply).eventsOnCurrentBranch())
.isEmpty();
}

@Test
public void eventsOnCurrentBranch_severalReplies_eachJudgedAgainstItsOwnCall() {
Event callHere = callEvent("agent_1", "fc_here");
Event callOnChild = callEvent("agent_1.child", "fc_child");
Event callElsewhere = callEvent("agent_2", "fc_far");
Event replyHere = userResponseEvent("agent_1", "fc_here");
Event replyFar = userResponseEvent("agent_1", "fc_far");
Event replyChild = userResponseEvent("agent_1", "fc_child");

InvocationContext context =
contextOnBranch(
"agent_1", callHere, callOnChild, callElsewhere, replyHere, replyFar, replyChild);

// callHere matches exactly so it survives; the sub-branch calls do not, but their replies do.
assertThat(context.eventsOnCurrentBranch())
.containsExactly(callHere, replyHere, replyChild)
.inOrder();
}

@Test
public void eventsOnCurrentBranch_emptyBranchAndDotPrefixedEvent_isExcluded() {
// Pins the empty-branch guard: without it the prefix test would admit a dot-prefixed branch.
Event dotPrefixed = userEvent(".x");

assertThat(contextOnBranch("", dotPrefixed).eventsOnCurrentBranch()).isEmpty();
}

@Test
public void eventsOnCurrentBranch_rootAgentEventWhileOnSubBranch_isExcluded() {
// The narrowing direction: the old scan admitted every event, this one demands equality.
Event rootAgentEvent = agentEvent(null);

assertThat(contextOnBranch("agent_1", rootAgentEvent).eventsOnCurrentBranch()).isEmpty();
}

@Test
public void eventsOnCurrentBranch_rootUserEventWhileOnSubBranch_isIncluded() {
// The user twin of the case above: a null-branch user event still matches.
Event rootUserEvent = userEvent(null);

assertThat(contextOnBranch("agent_1", rootUserEvent).eventsOnCurrentBranch())
.containsExactly(rootUserEvent);
}

@Test
public void eventsOnCurrentBranch_userResponseToUnbranchedCall_isDropped() {
// A root-level call contributes no id, so a reply answering only it is dropped.
Event rootCall = callEvent(null, "fc_1");
Event reply = userResponseEvent("agent_1", "fc_1");

assertThat(contextOnBranch("agent_1", rootCall, reply).eventsOnCurrentBranch()).isEmpty();
}

private InvocationContext contextOnBranch(@Nullable String branch, Event... events) {
return InvocationContext.builder()
.sessionService(mockSessionService)
.artifactService(mockArtifactService)
.memoryService(mockMemoryService)
.pluginManager(pluginManager)
.invocationId(testInvocationId)
.branch(branch)
.agent(mockAgent)
.session(Session.builder("test-session-id").events(ImmutableList.copyOf(events)).build())
.runConfig(runConfig)
.build();
}

private static Event userEvent(@Nullable String branch) {
return Event.builder().author("user").branch(branch).build();
}

private static Event agentEvent(@Nullable String branch) {
return Event.builder().author("some_agent").branch(branch).build();
}

private static Event callEvent(@Nullable String branch, String callId) {
return Event.builder()
.author("some_agent")
.branch(branch)
.content(
Content.fromParts(
Part.builder()
.functionCall(FunctionCall.builder().id(callId).name("t").build())
.build()))
.build();
}

private static Event userResponseEvent(@Nullable String branch, String callId) {
return Event.builder()
.author("user")
.branch(branch)
.content(
Content.fromParts(
Part.builder()
.functionResponse(
FunctionResponse.builder()
.id(callId)
.name("t")
.response(ImmutableMap.of())
.build())
.build()))
.build();
}
}
Loading
Loading