diff --git a/a2a/src/main/java/com/google/adk/a2a/executor/AgentExecutor.java b/a2a/src/main/java/com/google/adk/a2a/executor/AgentExecutor.java index 618888c4c..dd19dec90 100644 --- a/a2a/src/main/java/com/google/adk/a2a/executor/AgentExecutor.java +++ b/a2a/src/main/java/com/google/adk/a2a/executor/AgentExecutor.java @@ -20,6 +20,7 @@ import com.google.adk.a2a.converters.EventConverter; import com.google.adk.a2a.converters.PartConverter; import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.CallerIdentity; import com.google.adk.agents.RunConfig; import com.google.adk.apps.App; import com.google.adk.artifacts.BaseArtifactService; @@ -34,7 +35,9 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.genai.types.Content; import com.google.genai.types.CustomMetadata; +import io.a2a.server.ServerCallContext; import io.a2a.server.agentexecution.RequestContext; +import io.a2a.server.auth.User; import io.a2a.server.events.EventQueue; import io.a2a.server.tasks.TaskUpdater; import io.a2a.spec.Artifact; @@ -298,7 +301,7 @@ private String getUserId(RequestContext ctx) { * RunConfig#customMetadata()}. */ private RunConfig runConfigWithA2aMetadata(RequestContext ctx) { - RunConfig runConfig = agentExecutorConfig.runConfig(); + RunConfig runConfig = withCallerIdentity(agentExecutorConfig.runConfig(), ctx); MessageSendParams params = ctx.getParams(); Map requestMetadata = params == null ? null : params.metadata(); if (requestMetadata == null || requestMetadata.isEmpty()) { @@ -309,6 +312,24 @@ private RunConfig runConfigWithA2aMetadata(RequestContext ctx) { return runConfig.toBuilder().customMetadata(customMetadata).build(); } + /** + * Copies the caller the transport authenticated onto the run config, so a {@link + * com.google.adk.agents.ConfirmationApprover} can tell an operator from a peer. + * + *

The peer-supplied context id this class derives a user id from says nothing about who the + * sender is; only the server call context does. + */ + private static RunConfig withCallerIdentity(RunConfig runConfig, RequestContext ctx) { + ServerCallContext callContext = ctx.getCallContext(); + User user = callContext == null ? null : callContext.getUser(); + if (user == null) { + return runConfig.toBuilder().callerIdentity(CallerIdentity.unauthenticated()).build(); + } + return runConfig.toBuilder() + .callerIdentity(CallerIdentity.of(user.isAuthenticated(), user.getUsername())) + .build(); + } + private Maybe prepareSession( RequestContext ctx, String appName, BaseSessionService service) { return service diff --git a/a2a/src/test/java/com/google/adk/a2a/executor/AgentExecutorTest.java b/a2a/src/test/java/com/google/adk/a2a/executor/AgentExecutorTest.java index 68cb196f1..07fa2ea82 100644 --- a/a2a/src/test/java/com/google/adk/a2a/executor/AgentExecutorTest.java +++ b/a2a/src/test/java/com/google/adk/a2a/executor/AgentExecutorTest.java @@ -27,6 +27,7 @@ import static org.mockito.Mockito.when; import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.CallerIdentity; import com.google.adk.agents.InvocationContext; import com.google.adk.agents.RunConfig; import com.google.adk.apps.App; @@ -35,10 +36,13 @@ import com.google.adk.sessions.InMemorySessionService; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.genai.types.Content; import com.google.genai.types.Part; +import io.a2a.server.ServerCallContext; import io.a2a.server.agentexecution.RequestContext; +import io.a2a.server.auth.User; import io.a2a.server.events.EventQueue; import io.a2a.spec.Message; import io.a2a.spec.MessageSendParams; @@ -464,6 +468,89 @@ public void execute_withoutRequestMetadata_leavesRunConfigCustomMetadataEmpty() assertThat(runConfig.customMetadata()).doesNotContainKey("a2a_metadata"); } + @Test + public void execute_withAuthenticatedCaller_stampsThatIdentity() { + testAgent.setEventsToEmit(Flowable.empty()); + AgentExecutor executor = executorForIdentityTest(); + RequestContext ctx = createRequestContext(); + when(ctx.getCallContext()) + .thenReturn( + new ServerCallContext( + new User() { + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public String getUsername() { + return "operator"; + } + }, + ImmutableMap.of(), + ImmutableSet.of())); + + executor.execute(ctx, eventQueue); + + CallerIdentity caller = + testAgent.lastInvocationContext.runConfig().callerIdentity().orElseThrow(); + assertThat(caller.authenticated()).isTrue(); + assertThat(caller.name()).hasValue("operator"); + } + + @Test + public void execute_withUnauthenticatedCaller_stampsUnauthenticated() { + testAgent.setEventsToEmit(Flowable.empty()); + AgentExecutor executor = executorForIdentityTest(); + RequestContext ctx = createRequestContext(); + when(ctx.getCallContext()) + .thenReturn( + new ServerCallContext( + new User() { + @Override + public boolean isAuthenticated() { + return false; + } + + @Override + public String getUsername() { + return ""; + } + }, + ImmutableMap.of(), + ImmutableSet.of())); + + executor.execute(ctx, eventQueue); + + CallerIdentity caller = + testAgent.lastInvocationContext.runConfig().callerIdentity().orElseThrow(); + assertThat(caller.authenticated()).isFalse(); + assertThat(caller.name()).isEmpty(); + } + + @Test + public void execute_withoutCallContext_stampsUnauthenticated() { + // A transport that supplies no call context must not leave the identity absent, or the + // processor would have nothing to judge. + testAgent.setEventsToEmit(Flowable.empty()); + AgentExecutor executor = executorForIdentityTest(); + + executor.execute(createRequestContext(), eventQueue); + + CallerIdentity caller = + testAgent.lastInvocationContext.runConfig().callerIdentity().orElseThrow(); + assertThat(caller.authenticated()).isFalse(); + } + + private AgentExecutor executorForIdentityTest() { + return new AgentExecutor.Builder() + .agentExecutorConfig(AgentExecutorConfig.builder().build()) + .app(App.builder().name("test_app").rootAgent(testAgent).build()) + .sessionService(new InMemorySessionService()) + .artifactService(new InMemoryArtifactService()) + .build(); + } + private RequestContext createRequestContext() { Message message = new Message.Builder() diff --git a/core/src/main/java/com/google/adk/agents/CallerIdentity.java b/core/src/main/java/com/google/adk/agents/CallerIdentity.java new file mode 100644 index 000000000..bfb9b468b --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/CallerIdentity.java @@ -0,0 +1,76 @@ +/* + * 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 com.google.adk.agents; + +import com.google.auto.value.AutoValue; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * What the transport knows about the sender of the current request. + * + *

Three states: {@link #absent()} where no transport identity reached this invocation, {@link + * #unauthenticated()} where a transport saw the sender and did not authenticate it, and {@link + * #authenticatedAs(String)} where it did. A {@link ConfirmationApprover} decides which of them may + * satisfy a human tool confirmation. + */ +@AutoValue +public abstract class CallerIdentity { + + /** + * Whether the transport told us anything about the sender at all. + * + *

False means no transport identity reached this invocation, which is the ordinary shape for + * an in-process run or a surface that does not carry one -- not a claim that the sender is + * anonymous. + */ + public abstract boolean transportKnowsSender(); + + /** Whether the transport authenticated the sender. */ + public abstract boolean authenticated(); + + /** The authenticated name, present only when {@link #authenticated()} is true. */ + public abstract Optional name(); + + /** No transport identity reached this invocation. */ + public static CallerIdentity absent() { + return new AutoValue_CallerIdentity(false, false, Optional.empty()); + } + + /** A sender the transport saw but did not authenticate. */ + public static CallerIdentity unauthenticated() { + return new AutoValue_CallerIdentity(true, false, Optional.empty()); + } + + /** A sender the transport authenticated as {@code name}. */ + public static CallerIdentity authenticatedAs(String name) { + return new AutoValue_CallerIdentity(true, true, Optional.of(name)); + } + + /** + * Maps a transport's own view of the sender onto this type. + * + *

Fails closed: a name is kept only alongside {@code authenticated}, so an unauthenticated + * name cannot be mistaken for evidence, and an authenticated sender whose transport supplies no + * usable name is reported as unauthenticated rather than as an anonymous authenticated one. + */ + public static CallerIdentity of(boolean authenticated, @Nullable String name) { + return authenticated && name != null && !name.isEmpty() + ? authenticatedAs(name) + : unauthenticated(); + } +} diff --git a/core/src/main/java/com/google/adk/agents/ConfirmationApprover.java b/core/src/main/java/com/google/adk/agents/ConfirmationApprover.java new file mode 100644 index 000000000..41402f523 --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/ConfirmationApprover.java @@ -0,0 +1,56 @@ +/* + * 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 com.google.adk.agents; + +import com.google.genai.types.FunctionCall; + +/** + * Decides whether the sender of a request may satisfy a human tool confirmation. + * + *

A confirmation stands in for a human operator's consent, but a request arriving over a + * machine-to-machine transport is recorded under the same {@code user} role as one a human typed, + * so the flow cannot tell them apart on its own. The default is {@link #REJECT_UNAUTHENTICATED}; + * {@link #ALLOW_ALL} restores the historical behavior. + */ +@FunctionalInterface +public interface ConfirmationApprover { + + /** Accepts every sender, including an unauthenticated peer. */ + ConfirmationApprover ALLOW_ALL = (caller, call) -> true; + + /** + * Rejects a sender the transport saw and did not authenticate, and accepts one it never saw. + * + *

The default. A transport that reports an unauthenticated sender is one anybody can reach, so + * its "approval" is worth nothing; a surface that carries no identity at all is unchanged, which + * keeps in-process and local runs working. + */ + ConfirmationApprover REJECT_UNAUTHENTICATED = + (caller, call) -> !caller.transportKnowsSender() || caller.authenticated(); + + /** Accepts only a sender the transport authenticated, rejecting one it never saw. */ + ConfirmationApprover AUTHENTICATED_ONLY = (caller, call) -> caller.authenticated(); + + /** + * Returns whether {@code caller} may approve {@code originalCall}. + * + * @param caller the sender of the request carrying the confirmation; an unauthenticated sender is + * {@link CallerIdentity#unauthenticated()}, never null + * @param originalCall the tool call awaiting confirmation, so a policy can decide per tool + */ + boolean canApprove(CallerIdentity caller, FunctionCall originalCall); +} diff --git a/core/src/main/java/com/google/adk/agents/InvocationContext.java b/core/src/main/java/com/google/adk/agents/InvocationContext.java index 456758b95..19ed7faeb 100644 --- a/core/src/main/java/com/google/adk/agents/InvocationContext.java +++ b/core/src/main/java/com/google/adk/agents/InvocationContext.java @@ -17,9 +17,12 @@ 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; @@ -27,8 +30,13 @@ 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; @@ -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; @@ -54,6 +64,7 @@ public class InvocationContext { @Nullable private final EventsCompactionConfig eventsCompactionConfig; @Nullable private final ContextCacheConfig contextCacheConfig; private final @Nullable ResumabilityConfig resumabilityConfig; + private final ConfirmationApprover confirmationApprover; private final InvocationCostManager invocationCostManager; private final Map callbackContextData; @@ -78,6 +89,7 @@ protected InvocationContext(Builder builder) { this.eventsCompactionConfig = builder.eventsCompactionConfig; this.contextCacheConfig = builder.contextCacheConfig; this.resumabilityConfig = builder.resumabilityConfig; + this.confirmationApprover = builder.confirmationApprover; this.invocationCostManager = builder.invocationCostManager; // Don't copy the callback context data. This should be the same instance for the full // invocation invocation so that Plugins can access the same data it during the invocation @@ -151,11 +163,94 @@ public BaseAgent agent() { return agent; } + /** + * Returns the policy deciding whether a request's sender may satisfy a tool confirmation. + * + *

Deployment policy, so it comes from the app rather than the per-request run config. + */ + public ConfirmationApprover confirmationApprover() { + return confirmationApprover; + } + /** Returns the session associated with this invocation. */ public Session session() { return session; } + /** + * Returns a snapshot of the session's events, keeping only those on the branch this invocation is + * running on. + * + *

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 eventsOnCurrentBranch() { + ImmutableList 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 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. + * + *

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 branchFunctionCallIds) { + @Nullable String eventBranch = event.branch().orElse(null); + if (!Objects.equals(event.author(), USER_AUTHOR)) { + return Objects.equals(eventBranch, scopeBranch); + } + if (!isNullOrEmpty(scopeBranch)) { + ImmutableSet 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. + * + *

Branches are dot-joined, so the trailing dot keeps the prefix test on a segment boundary. + */ + private ImmutableSet branchFunctionCallIds( + ImmutableList 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 userContent() { return Optional.ofNullable(userContent); @@ -284,6 +379,7 @@ private Builder(InvocationContext context) { this.eventsCompactionConfig = context.eventsCompactionConfig; this.contextCacheConfig = context.contextCacheConfig; this.resumabilityConfig = context.resumabilityConfig; + this.confirmationApprover = context.confirmationApprover; this.invocationCostManager = context.invocationCostManager; // Don't copy the callback context data. This should be the same instance for the full // invocation invocation so that Plugins can access the same data it during the invocation @@ -307,6 +403,7 @@ private Builder(InvocationContext context) { @Nullable private EventsCompactionConfig eventsCompactionConfig; @Nullable private ContextCacheConfig contextCacheConfig; private @Nullable ResumabilityConfig resumabilityConfig; + private ConfirmationApprover confirmationApprover = ConfirmationApprover.REJECT_UNAUTHENTICATED; private InvocationCostManager invocationCostManager = new InvocationCostManager(); private Map callbackContextData = new ConcurrentHashMap<>(); @@ -382,6 +479,13 @@ public Builder branch(@Nullable String branch) { return this; } + /** Sets the policy deciding whether a sender may satisfy a tool confirmation. */ + @CanIgnoreReturnValue + public Builder confirmationApprover(ConfirmationApprover confirmationApprover) { + this.confirmationApprover = confirmationApprover; + return this; + } + /** * Sets the unique ID for the invocation. * diff --git a/core/src/main/java/com/google/adk/agents/RunConfig.java b/core/src/main/java/com/google/adk/agents/RunConfig.java index bd20b6183..2c82e5abf 100644 --- a/core/src/main/java/com/google/adk/agents/RunConfig.java +++ b/core/src/main/java/com/google/adk/agents/RunConfig.java @@ -117,6 +117,14 @@ public final boolean groupFunctionResponsesInHistory() { public abstract ImmutableMap customMetadata(); + /** + * Who the transport authenticated as the sender of this request, when it authenticates at all. + * + *

Set by the server layer, not by application code. Consulted by the app's {@code + * ConfirmationApprover}. + */ + public abstract Optional callerIdentity(); + public abstract Builder toBuilder(); public static Builder builder() { @@ -146,7 +154,8 @@ public static Builder builder(RunConfig runConfig) { .autoCreateSession(runConfig.autoCreateSession()) .groupFunctionResponsesInHistoryOverride( runConfig.groupFunctionResponsesInHistoryOverride()) - .customMetadata(runConfig.customMetadata()); + .customMetadata(runConfig.customMetadata()) + .callerIdentity(runConfig.callerIdentity()); } /** Builder for {@link RunConfig}. */ @@ -174,6 +183,12 @@ public final Builder setResponseModalities(Iterable responseModalities @CanIgnoreReturnValue public abstract Builder avatarConfig(@Nullable AvatarConfig avatarConfig); + @CanIgnoreReturnValue + public abstract Builder callerIdentity(Optional callerIdentity); + + @CanIgnoreReturnValue + public abstract Builder callerIdentity(CallerIdentity callerIdentity); + @Deprecated @CanIgnoreReturnValue public final Builder setSaveInputBlobsAsArtifacts(boolean saveInputBlobsAsArtifacts) { diff --git a/core/src/main/java/com/google/adk/apps/App.java b/core/src/main/java/com/google/adk/apps/App.java index 9120954b5..5d9f0b245 100644 --- a/core/src/main/java/com/google/adk/apps/App.java +++ b/core/src/main/java/com/google/adk/apps/App.java @@ -17,6 +17,7 @@ package com.google.adk.apps; import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.ConfirmationApprover; import com.google.adk.agents.ContextCacheConfig; import com.google.adk.agents.Role; import com.google.adk.plugins.Plugin; @@ -45,6 +46,7 @@ public class App { private final @Nullable EventsCompactionConfig eventsCompactionConfig; private final @Nullable ContextCacheConfig contextCacheConfig; private final @Nullable ResumabilityConfig resumabilityConfig; + private final ConfirmationApprover confirmationApprover; private App( String name, @@ -52,13 +54,15 @@ private App( List plugins, @Nullable EventsCompactionConfig eventsCompactionConfig, @Nullable ContextCacheConfig contextCacheConfig, - @Nullable ResumabilityConfig resumabilityConfig) { + @Nullable ResumabilityConfig resumabilityConfig, + ConfirmationApprover confirmationApprover) { this.name = name; this.rootAgent = rootAgent; this.plugins = ImmutableList.copyOf(plugins); this.eventsCompactionConfig = eventsCompactionConfig; this.contextCacheConfig = contextCacheConfig; this.resumabilityConfig = resumabilityConfig; + this.confirmationApprover = confirmationApprover; } public String name() { @@ -87,6 +91,14 @@ public ContextCacheConfig contextCacheConfig() { return resumabilityConfig; } + /** + * Returns the policy deciding whether a request's sender may satisfy a tool confirmation. + * Defaults to {@link ConfirmationApprover#REJECT_UNAUTHENTICATED}. + */ + public ConfirmationApprover confirmationApprover() { + return confirmationApprover; + } + /** Builder for {@link App}. */ public static class Builder { private String name; @@ -95,6 +107,7 @@ public static class Builder { @Nullable private EventsCompactionConfig eventsCompactionConfig; @Nullable private ContextCacheConfig contextCacheConfig; private @Nullable ResumabilityConfig resumabilityConfig; + private ConfirmationApprover confirmationApprover = ConfirmationApprover.REJECT_UNAUTHENTICATED; @CanIgnoreReturnValue public Builder name(String name) { @@ -145,6 +158,13 @@ public Builder resumabilityConfig(ResumabilityConfig resumabilityConfig) { return this; } + /** Sets who may satisfy a tool confirmation for this app. */ + @CanIgnoreReturnValue + public Builder confirmationApprover(ConfirmationApprover confirmationApprover) { + this.confirmationApprover = confirmationApprover; + return this; + } + public App build() { if (name == null) { throw new IllegalStateException("App name must be provided."); @@ -154,7 +174,13 @@ public App build() { } validateAppName(name); return new App( - name, rootAgent, plugins, eventsCompactionConfig, contextCacheConfig, resumabilityConfig); + name, + rootAgent, + plugins, + eventsCompactionConfig, + contextCacheConfig, + resumabilityConfig, + confirmationApprover); } } diff --git a/core/src/main/java/com/google/adk/flows/llmflows/RequestConfirmationLlmRequestProcessor.java b/core/src/main/java/com/google/adk/flows/llmflows/RequestConfirmationLlmRequestProcessor.java index 06e50e76d..0d63d8e00 100644 --- a/core/src/main/java/com/google/adk/flows/llmflows/RequestConfirmationLlmRequestProcessor.java +++ b/core/src/main/java/com/google/adk/flows/llmflows/RequestConfirmationLlmRequestProcessor.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.adk.JsonBaseModel; +import com.google.adk.agents.CallerIdentity; import com.google.adk.agents.InvocationContext; import com.google.adk.agents.LlmAgent; import com.google.adk.agents.Role; @@ -42,9 +43,11 @@ import io.opentelemetry.context.Context; import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.core.Single; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -57,11 +60,14 @@ public class RequestConfirmationLlmRequestProcessor implements RequestProcessor LoggerFactory.getLogger(RequestConfirmationLlmRequestProcessor.class); private static final ObjectMapper objectMapper = JsonBaseModel.getMapper(); private static final String ORIGINAL_FUNCTION_CALL = "originalFunctionCall"; + private static final String CONFIRMATION_REFUSED_ERROR = + "Tool confirmation refused: the sender of this request may not approve this tool call."; @Override public Single processRequest( InvocationContext invocationContext, LlmRequest llmRequest) { - ImmutableList 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 events = invocationContext.eventsOnCurrentBranch(); if (events.isEmpty()) { logger.trace( "No events are present in the session. Skipping request confirmation processing."); @@ -117,6 +123,7 @@ public Single processRequest( Map toolsToResumeWithConfirmation = new HashMap<>(); Map toolsToResumeWithArgs = new HashMap<>(); + List refusedCalls = new ArrayList<>(); event.functionCalls().stream() .filter( @@ -133,16 +140,31 @@ public Single processRequest( ofc, functionCallsById, confirmationRequestedIds, agentName)) .ifPresent( ofc -> { + if (!senderMayApprove(invocationContext, ofc)) { + refusedCalls.add(ofc); + return; + } toolsToResumeWithConfirmation.put( ofc.id().get(), requestConfirmationFunctionResponses.get(fc.id().get())); toolsToResumeWithArgs.put(ofc.id().get(), ofc); })); + // A refusal answers the pending call, so it settles like any other outcome: the response + // lands in alreadyResumedIds and the next pass skips this confirmation instead of re-judging + // it for the rest of the session. + ImmutableList refusalEvents = + refusedCalls.isEmpty() + ? ImmutableList.of() + : ImmutableList.of(buildRefusalEvent(invocationContext, refusedCalls)); + // If all confirmed tools in this event have already been processed, continue // searching in older events. if (toolsToResumeWithConfirmation.isEmpty()) { - continue; + if (refusalEvents.isEmpty()) { + continue; + } + return Single.just(RequestProcessingResult.create(llmRequest, refusalEvents)); } // If we found tools that were confirmed but not yet executed, execute them now. @@ -152,12 +174,17 @@ public Single processRequest( ImmutableMap.copyOf(toolsToResumeWithConfirmation)) .map( assembledEvent -> - RequestProcessingResult.create(llmRequest, ImmutableList.of(assembledEvent))) + RequestProcessingResult.create( + llmRequest, + ImmutableList.builder() + .addAll(refusalEvents) + .add(assembledEvent) + .build())) .toSingle() .onErrorReturn( e -> { logger.error("Error processing request confirmation", e); - return RequestProcessingResult.create(llmRequest, ImmutableList.of()); + return RequestProcessingResult.create(llmRequest, refusalEvents); }); } @@ -312,6 +339,57 @@ private static boolean isResumableFunctionCall( return true; } + /** + * Returns whether the sender of this request may approve {@code originalCall}. + * + *

A confirmation stands in for a human's consent, but every transport records its sender under + * the same {@code user} role, so the sender's identity is the only thing separating an operator + * from a peer that drove the agent here. + */ + private static boolean senderMayApprove( + InvocationContext invocationContext, FunctionCall originalCall) { + CallerIdentity caller = + invocationContext.runConfig().callerIdentity().orElseGet(CallerIdentity::absent); + if (invocationContext.confirmationApprover().canApprove(caller, originalCall)) { + return true; + } + logger.warn( + "Ignoring a tool confirmation: the sender is not permitted to approve one" + + " (authenticated={}).", + caller.authenticated()); + return false; + } + + /** + * Builds the function responses that report a refused confirmation back to the model and caller. + * + *

Without one the tool silently never runs, so a legitimate operator sees the agent hang. The + * response carries the call id and a fixed reason only, never the sender or the call's arguments. + */ + private static Event buildRefusalEvent( + InvocationContext invocationContext, List refusedCalls) { + ImmutableList parts = + refusedCalls.stream() + .map( + call -> + Part.builder() + .functionResponse( + FunctionResponse.builder() + .id(call.id().get()) + .name(call.name().orElse("")) + .response(ImmutableMap.of("error", CONFIRMATION_REFUSED_ERROR)) + .build()) + .build()) + .collect(toImmutableList()); + return Event.builder() + .id(Event.generateEventId()) + .invocationId(invocationContext.invocationId()) + .author(invocationContext.agent().name()) + .branch(invocationContext.branch().orElse(null)) + .content(Content.builder().role("user").parts(parts).build()) + .build(); + } + private Optional getOriginalFunctionCall(FunctionCall functionCall) { if (!functionCall.args().orElse(ImmutableMap.of()).containsKey(ORIGINAL_FUNCTION_CALL)) { return Optional.empty(); diff --git a/core/src/main/java/com/google/adk/runner/Runner.java b/core/src/main/java/com/google/adk/runner/Runner.java index fbecbf02b..c02b1d791 100644 --- a/core/src/main/java/com/google/adk/runner/Runner.java +++ b/core/src/main/java/com/google/adk/runner/Runner.java @@ -20,6 +20,7 @@ import com.google.adk.agents.ActiveStreamingTool; import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.ConfirmationApprover; import com.google.adk.agents.ContextCacheConfig; import com.google.adk.agents.InvocationContext; import com.google.adk.agents.LiveRequestQueue; @@ -88,6 +89,7 @@ public class Runner { @Nullable private final EventsCompactionConfig eventsCompactionConfig; @Nullable private final ContextCacheConfig contextCacheConfig; private final @Nullable ResumabilityConfig resumabilityConfig; + private final ConfirmationApprover confirmationApprover; private final ConcurrentMap activeSessionCompletables = new MapMaker().weakValues().makeMap(); @@ -161,6 +163,7 @@ public Runner build() { EventsCompactionConfig buildEventsCompactionConfig; ContextCacheConfig buildContextCacheConfig; ResumabilityConfig buildResumabilityConfig; + ConfirmationApprover buildConfirmationApprover = ConfirmationApprover.REJECT_UNAUTHENTICATED; if (this.app != null) { if (this.agent != null) { @@ -175,6 +178,7 @@ public Runner build() { buildEventsCompactionConfig = this.app.eventsCompactionConfig(); buildContextCacheConfig = this.app.contextCacheConfig(); buildResumabilityConfig = this.app.resumabilityConfig(); + buildConfirmationApprover = this.app.confirmationApprover(); } else { buildAgent = this.agent; buildAppName = this.appName; @@ -205,7 +209,8 @@ public Runner build() { buildPlugins, buildEventsCompactionConfig, buildContextCacheConfig, - buildResumabilityConfig); + buildResumabilityConfig, + buildConfirmationApprover); } } @@ -287,6 +292,30 @@ protected Runner( @Nullable EventsCompactionConfig eventsCompactionConfig, @Nullable ContextCacheConfig contextCacheConfig, @Nullable ResumabilityConfig resumabilityConfig) { + this( + agent, + appName, + artifactService, + sessionService, + memoryService, + plugins, + eventsCompactionConfig, + contextCacheConfig, + resumabilityConfig, + ConfirmationApprover.REJECT_UNAUTHENTICATED); + } + + private Runner( + BaseAgent agent, + String appName, + BaseArtifactService artifactService, + BaseSessionService sessionService, + @Nullable BaseMemoryService memoryService, + List plugins, + @Nullable EventsCompactionConfig eventsCompactionConfig, + @Nullable ContextCacheConfig contextCacheConfig, + @Nullable ResumabilityConfig resumabilityConfig, + ConfirmationApprover confirmationApprover) { this.agent = agent; this.appName = appName; this.artifactService = artifactService; @@ -296,6 +325,7 @@ protected Runner( this.eventsCompactionConfig = createEventsCompactionConfig(agent, eventsCompactionConfig); this.contextCacheConfig = contextCacheConfig; this.resumabilityConfig = resumabilityConfig; + this.confirmationApprover = confirmationApprover; } /** @@ -745,6 +775,7 @@ private InvocationContext.Builder newInvocationContextBuilder(Session session) { .eventsCompactionConfig(this.eventsCompactionConfig) .contextCacheConfig(this.contextCacheConfig) .resumabilityConfig(this.resumabilityConfig) + .confirmationApprover(this.confirmationApprover) .agent(this.findAgentToRun(session, rootAgent)); } diff --git a/core/src/test/java/com/google/adk/agents/InvocationContextTest.java b/core/src/test/java/com/google/adk/agents/InvocationContextTest.java index e588a38ca..d1293a6b4 100644 --- a/core/src/test/java/com/google/adk/agents/InvocationContextTest.java +++ b/core/src/test/java/com/google/adk/agents/InvocationContextTest.java @@ -20,18 +20,25 @@ import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.mock; +import com.google.adk.apps.App; 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; @@ -724,4 +731,206 @@ public void build_missingSessionService_throwsException() { IllegalStateException exception = assertThrows(IllegalStateException.class, builder::build); assertThat(exception).hasMessageThat().isEqualTo("Session service must be set."); } + + @Test + public void confirmationApprover_setOnTheApp_reachesTheInvocationContext() { + // Without this, deleting the Runner wiring leaves every other test green while an operator's + // policy silently degrades to the default. + App app = + App.builder() + .name("test_app") + .rootAgent(mockAgent) + .confirmationApprover(ConfirmationApprover.AUTHENTICATED_ONLY) + .build(); + + assertThat(app.confirmationApprover()).isEqualTo(ConfirmationApprover.AUTHENTICATED_ONLY); + assertThat(App.builder().name("a").rootAgent(mockAgent).build().confirmationApprover()) + .isEqualTo(ConfirmationApprover.REJECT_UNAUTHENTICATED); + } + + @Test + public void confirmationApprover_defaultOnTheBuilder_rejectsUnauthenticated() { + assertThat(contextOnBranch(null).confirmationApprover()) + .isEqualTo(ConfirmationApprover.REJECT_UNAUTHENTICATED); + } + + @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(); + } } diff --git a/core/src/test/java/com/google/adk/agents/RunConfigTest.java b/core/src/test/java/com/google/adk/agents/RunConfigTest.java index fc6b9083f..76a885c0f 100644 --- a/core/src/test/java/com/google/adk/agents/RunConfigTest.java +++ b/core/src/test/java/com/google/adk/agents/RunConfigTest.java @@ -23,6 +23,7 @@ import com.google.genai.types.AudioTranscriptionConfig; import com.google.genai.types.AvatarConfig; import com.google.genai.types.CustomizedAvatar; +import com.google.genai.types.FunctionCall; import com.google.genai.types.Modality; import com.google.genai.types.SpeechConfig; import java.util.Optional; @@ -187,4 +188,45 @@ public void testAvatarConfig_withCustomizedAvatar() { assertThat(runConfig.avatarConfig().customizedAvatar().get().imageMimeType()) .hasValue("image/jpeg"); } + + @Test + public void callerIdentity_default_isAbsent() { + assertThat(RunConfig.builder().build().callerIdentity()).isEmpty(); + } + + @Test + public void authenticatedOnlyApprover_rejectsUnauthenticatedSender() { + ConfirmationApprover approver = ConfirmationApprover.AUTHENTICATED_ONLY; + FunctionCall call = FunctionCall.builder().id("id").name("tool").build(); + + assertThat(approver.canApprove(CallerIdentity.unauthenticated(), call)).isFalse(); + assertThat(approver.canApprove(CallerIdentity.authenticatedAs("operator"), call)).isTrue(); + assertThat(ConfirmationApprover.ALLOW_ALL.canApprove(CallerIdentity.unauthenticated(), call)) + .isTrue(); + // The default: a sender the transport saw and did not authenticate is refused, one it never + // saw is not. + ConfirmationApprover byDefault = ConfirmationApprover.REJECT_UNAUTHENTICATED; + assertThat(byDefault.canApprove(CallerIdentity.absent(), call)).isTrue(); + assertThat(byDefault.canApprove(CallerIdentity.unauthenticated(), call)).isFalse(); + assertThat(byDefault.canApprove(CallerIdentity.authenticatedAs("operator"), call)).isTrue(); + } + + @Test + public void callerIdentity_of_withoutAuthentication_discardsTheName() { + // A name the transport did not authenticate is not evidence of anything. + assertThat(CallerIdentity.of(false, "spoofed").name()).isEmpty(); + assertThat(CallerIdentity.of(false, "spoofed").authenticated()).isFalse(); + assertThat(CallerIdentity.of(true, "operator").name()).hasValue("operator"); + } + + @Test + public void copyBuilder_preservesCallerIdentity() { + // RunConfig.builder(RunConfig) is hand-written, so a new field is dropped unless added there. + RunConfig original = + RunConfig.builder().callerIdentity(CallerIdentity.authenticatedAs("operator")).build(); + + RunConfig copy = RunConfig.builder(original).build(); + + assertThat(copy.callerIdentity()).hasValue(CallerIdentity.authenticatedAs("operator")); + } } diff --git a/core/src/test/java/com/google/adk/agents/SequentialAgentTest.java b/core/src/test/java/com/google/adk/agents/SequentialAgentTest.java index 6bbd9e55b..ea3e524fa 100644 --- a/core/src/test/java/com/google/adk/agents/SequentialAgentTest.java +++ b/core/src/test/java/com/google/adk/agents/SequentialAgentTest.java @@ -249,6 +249,22 @@ public void resumeSubAgentIndex_noMatchingAuthor_returnsEmpty() { assertThat(WorkflowAgentResumption.resumeSubAgentIndex(context, root.subAgents())).isEmpty(); } + @Test + public void resumeSubAgentIndex_branchedNestedAuthor_returnsThatSubAgentIndex() { + // A Sequential nested inside a Parallel carries a branch a filter would hide. + TestBaseAgent first = createSubAgent("first_agent"); + TestBaseAgent nested = createSubAgent("nested_agent"); + SequentialAgent branch = + SequentialAgent.builder().name("branch_agent").subAgents(ImmutableList.of(nested)).build(); + SequentialAgent root = + SequentialAgent.builder().name("root").subAgents(ImmutableList.of(first, branch)).build(); + + InvocationContext context = + contextResumingBranchedCall(root, "nested_agent", "p.root", "p.root.branch_agent"); + + assertThat(WorkflowAgentResumption.resumeSubAgentIndex(context, root.subAgents())).hasValue(1); + } + // Session ending with a function response that resumes a call authored by callAuthor. private static InvocationContext contextResumingCall(BaseAgent rootAgent, String callAuthor) { InMemorySessionService sessionService = new InMemorySessionService(); @@ -284,4 +300,45 @@ private static InvocationContext contextResumingCall(BaseAgent rootAgent, String var unusedResponse = sessionService.appendEvent(session, responseEvent).blockingGet(); return createInvocationContext(rootAgent, sessionService, session); } + + // As above, but the context sits on contextBranch and the call event on callBranch. + private static InvocationContext contextResumingBranchedCall( + BaseAgent rootAgent, String callAuthor, String contextBranch, String callBranch) { + InMemorySessionService sessionService = new InMemorySessionService(); + Session session = sessionService.createSession("test_app", "test-user").blockingGet(); + Event callEvent = + Event.builder() + .id("call_event") + .invocationId("invocationId") + .author(callAuthor) + .branch(callBranch) + .content( + Content.fromParts( + Part.builder() + .functionCall(FunctionCall.builder().id("call_id").name("tool").build()) + .build())) + .build(); + Event responseEvent = + Event.builder() + .id("response_event") + .invocationId("invocationId") + .author("user") + .branch(contextBranch) + .content( + Content.fromParts( + Part.builder() + .functionResponse( + FunctionResponse.builder() + .id("call_id") + .name("tool") + .response(ImmutableMap.of()) + .build()) + .build())) + .build(); + var unusedCall = sessionService.appendEvent(session, callEvent).blockingGet(); + var unusedResponse = sessionService.appendEvent(session, responseEvent).blockingGet(); + InvocationContext context = createInvocationContext(rootAgent, sessionService, session); + context.branch(contextBranch); + return context; + } } diff --git a/core/src/test/java/com/google/adk/flows/llmflows/RequestConfirmationLlmRequestProcessorTest.java b/core/src/test/java/com/google/adk/flows/llmflows/RequestConfirmationLlmRequestProcessorTest.java index c8da89026..78e57e6ba 100644 --- a/core/src/test/java/com/google/adk/flows/llmflows/RequestConfirmationLlmRequestProcessorTest.java +++ b/core/src/test/java/com/google/adk/flows/llmflows/RequestConfirmationLlmRequestProcessorTest.java @@ -20,10 +20,14 @@ import static com.google.adk.testing.TestUtils.createLlmResponse; import static com.google.adk.testing.TestUtils.createTestAgentBuilder; import static com.google.adk.testing.TestUtils.createTestLlm; +import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.truth.Truth.assertThat; +import com.google.adk.agents.CallerIdentity; +import com.google.adk.agents.ConfirmationApprover; import com.google.adk.agents.InvocationContext; import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.RunConfig; import com.google.adk.events.Event; import com.google.adk.events.EventActions; import com.google.adk.events.ToolConfirmation; @@ -398,6 +402,29 @@ public void testAgentNameMatchesFixtures() { assertThat(createAgentWithEchoTool().name()).isEqualTo(AGENT_NAME); } + @Test + public void runAsync_approvalOnParallelBranch_doesNotCallOriginalFunction() { + // An approval answered in a parallel tree is not this branch's, though it names the call. + LlmAgent agent = createAgentWithEchoTool(); + Session session = sessionWithApprovalOn("agent_1", "agent_2"); + + assertThat(resumedEventsOnBranch(agent, session, "agent_1")).isEmpty(); + } + + @Test + public void runAsync_approvalOnSubBranch_callsOriginalFunction() { + // The user may answer on a descendant sub-branch, so scoping must not break the normal path. + LlmAgent agent = createAgentWithEchoTool(); + Session session = sessionWithApprovalOn("agent_1", "agent_1.child"); + + ImmutableList resumed = resumedEventsOnBranch(agent, session, "agent_1"); + + assertThat(resumed).hasSize(1); + FunctionResponse response = resumed.get(0).functionResponses().get(0); + assertThat(response.id()).hasValue(ORIGINAL_FUNCTION_CALL_ID); + assertThat(response.name()).hasValue(ECHO_TOOL_NAME); + } + private static ImmutableList resumedEvents(LlmAgent agent, Session session) { return ImmutableList.copyOf( processor @@ -422,6 +449,156 @@ private static Event functionCallEvent(String author, FunctionCall functionCall) .build(); } + @Test + public void runAsync_unauthenticatedSenderUnderAuthenticatedOnlyApprover_doesNotCallFunction() { + // The peer self-approval case: every content check passes, and only the sender's identity + // separates this from a real operator's approval. + LlmAgent agent = createAgentWithEchoTool(); + Session session = Session.builder("session_id").events(CONFIRMED_CALL_EVENTS).build(); + + assertRefusal( + resumedEventsWithApprover( + agent, + session, + RunConfig.builder().callerIdentity(CallerIdentity.unauthenticated()).build(), + ConfirmationApprover.AUTHENTICATED_ONLY)); + } + + @Test + public void runAsync_authenticatedSenderUnderAuthenticatedOnlyApprover_callsOriginalFunction() { + LlmAgent agent = createAgentWithEchoTool(); + Session session = Session.builder("session_id").events(CONFIRMED_CALL_EVENTS).build(); + + ImmutableList resumed = + resumedEventsWithApprover( + agent, + session, + RunConfig.builder().callerIdentity(CallerIdentity.authenticatedAs("operator")).build(), + ConfirmationApprover.AUTHENTICATED_ONLY); + + assertThat(resumed).hasSize(1); + FunctionResponse fr = resumed.get(0).functionResponses().get(0); + assertThat(fr.id()).hasValue(ORIGINAL_FUNCTION_CALL_ID); + // The refusal event shares the id and the name, so only the payload tells the two apart. + assertThat(fr.response()).hasValue(ImmutableMap.of("result", ORIGINAL_FUNCTION_CALL_ARGS)); + } + + @Test + public void runAsync_defaultApproverAndNoTransportIdentity_stillCallsOriginalFunction() { + // No identity reached the invocation, which is every in-process and local run. The default + // must leave those alone. + LlmAgent agent = createAgentWithEchoTool(); + Session session = Session.builder("session_id").events(CONFIRMED_CALL_EVENTS).build(); + + ImmutableList resumed = + resumedEventsWithApprover( + agent, + session, + RunConfig.builder().build(), + ConfirmationApprover.REJECT_UNAUTHENTICATED); + + assertThat(resumed).hasSize(1); + // The refusal event shares the id and the name, so only the payload tells the two apart. + assertThat(resumed.get(0).functionResponses().get(0).response()) + .hasValue(ImmutableMap.of("result", ORIGINAL_FUNCTION_CALL_ARGS)); + } + + @Test + public void runAsync_defaultApproverAndUnauthenticatedSender_doesNotCallFunction() { + // The peer self-approval case, closed without anyone configuring anything. + LlmAgent agent = createAgentWithEchoTool(); + Session session = Session.builder("session_id").events(CONFIRMED_CALL_EVENTS).build(); + + assertRefusal( + resumedEventsWithApprover( + agent, + session, + RunConfig.builder().callerIdentity(CallerIdentity.unauthenticated()).build(), + ConfirmationApprover.REJECT_UNAUTHENTICATED)); + } + + @Test + public void runAsync_noCallerIdentityUnderAuthenticatedOnlyApprover_doesNotCallFunction() { + // Every non-A2A surface produces this shape, so it is the common case, not an edge. + LlmAgent agent = createAgentWithEchoTool(); + Session session = Session.builder("session_id").events(CONFIRMED_CALL_EVENTS).build(); + + assertRefusal( + resumedEventsWithApprover( + agent, session, RunConfig.builder().build(), ConfirmationApprover.AUTHENTICATED_ONLY)); + } + + @Test + public void runAsync_refusedConfirmation_respondsWithAnError() { + // A refusal that emits nothing looks to the caller like the agent hanging. + LlmAgent agent = createAgentWithEchoTool(); + Session session = Session.builder("session_id").events(CONFIRMED_CALL_EVENTS).build(); + + ImmutableList events = + resumedEventsWithApprover( + agent, + session, + RunConfig.builder().callerIdentity(CallerIdentity.unauthenticated()).build(), + ConfirmationApprover.REJECT_UNAUTHENTICATED); + + assertThat(events).hasSize(1); + assertThat(events.get(0).author()).isEqualTo(AGENT_NAME); + FunctionResponse response = events.get(0).functionResponses().get(0); + assertThat(response.id()).hasValue(ORIGINAL_FUNCTION_CALL_ID); + assertThat(response.name()).hasValue(ECHO_TOOL_NAME); + assertThat(response.response().get()).containsKey("error"); + // The reason must not carry the sender or the call's arguments back to the model. + assertThat((String) response.response().get().get("error")).doesNotContain("hello"); + } + + @Test + public void runAsync_refusedConfirmationAlreadyAnswered_isNotReExamined() { + // The refusal settles the call, so a later pass of the same invocation must skip the stale + // user event instead of judging - and logging - it again for the rest of the session. + LlmAgent agent = createAgentWithEchoTool(); + Session session = Session.builder("session_id").events(CONFIRMED_CALL_EVENTS).build(); + RunConfig runConfig = + RunConfig.builder().callerIdentity(CallerIdentity.unauthenticated()).build(); + + ImmutableList firstPass = + resumedEventsWithApprover( + agent, session, runConfig, ConfirmationApprover.REJECT_UNAUTHENTICATED); + // The runner persists whatever a processor emits, which is what the next pass reads. + session.events().addAll(firstPass); + ImmutableList secondPass = + resumedEventsWithApprover( + agent, session, runConfig, ConfirmationApprover.REJECT_UNAUTHENTICATED); + + assertThat(firstPass).hasSize(1); + assertThat(secondPass).isEmpty(); + } + + /** + * Asserts that {@code events} is a refusal for the pending call rather than the tool's result. + */ + private static void assertRefusal(ImmutableList events) { + assertThat(events).hasSize(1); + FunctionResponse response = events.get(0).functionResponses().get(0); + assertThat(response.id()).hasValue(ORIGINAL_FUNCTION_CALL_ID); + assertThat(response.response().get()).containsKey("error"); + } + + private static ImmutableList resumedEventsWithApprover( + LlmAgent agent, Session session, RunConfig runConfig, ConfirmationApprover approver) { + InvocationContext context = + InvocationContext.builder() + .pluginManager(new PluginManager()) + .invocationId(InvocationContext.newInvocationContextId()) + .agent(agent) + .session(session) + .sessionService(sessionService) + .runConfig(runConfig) + .confirmationApprover(approver) + .build(); + return ImmutableList.copyOf( + processor.processRequest(context, LlmRequest.builder().build()).blockingGet().events()); + } + private static InvocationContext buildInvocationContext(LlmAgent agent, Session session) { return InvocationContext.builder() .pluginManager(new PluginManager()) @@ -432,6 +609,44 @@ private static InvocationContext buildInvocationContext(LlmAgent agent, Session .build(); } + private static InvocationContext buildInvocationContext( + LlmAgent agent, Session session, String branch) { + return InvocationContext.builder() + .pluginManager(new PluginManager()) + .invocationId(InvocationContext.newInvocationContextId()) + .branch(branch) + .agent(agent) + .session(session) + .sessionService(sessionService) + .build(); + } + + /** + * Returns the legitimate lead-up with the agent's events on {@code agentBranch} and the user's + * approval on {@code approvalBranch}. + */ + private static Session sessionWithApprovalOn(String agentBranch, String approvalBranch) { + ImmutableList events = + CONFIRMED_CALL_EVENTS.stream() + .map( + event -> + event.toBuilder() + .branch(event.author().equals("user") ? approvalBranch : agentBranch) + .build()) + .collect(toImmutableList()); + return Session.builder("session_id").events(events).build(); + } + + private static ImmutableList resumedEventsOnBranch( + LlmAgent agent, Session session, String branch) { + return ImmutableList.copyOf( + processor + .processRequest( + buildInvocationContext(agent, session, branch), LlmRequest.builder().build()) + .blockingGet() + .events()); + } + private static LlmAgent createAgentWithEchoTool() { Content contentWithFunctionCall = Content.fromParts( diff --git a/core/src/test/java/com/google/adk/runner/RunnerTest.java b/core/src/test/java/com/google/adk/runner/RunnerTest.java index 3870d3461..aad73e44f 100644 --- a/core/src/test/java/com/google/adk/runner/RunnerTest.java +++ b/core/src/test/java/com/google/adk/runner/RunnerTest.java @@ -42,6 +42,7 @@ import com.google.adk.agents.BaseAgent; import com.google.adk.agents.Callbacks; import com.google.adk.agents.Callbacks.AfterModelCallback; +import com.google.adk.agents.ConfirmationApprover; import com.google.adk.agents.InvocationContext; import com.google.adk.agents.LiveRequestQueue; import com.google.adk.agents.LlmAgent; @@ -2899,6 +2900,35 @@ public void runAsync_parallelAgentWithLongRunningBranch_resumable_otherBranchCom assertThat(simplifyEvents(events)).contains("c_agent: agent C done"); } + @Test + public void runAsync_appSetsConfirmationApprover_reachesTheInvocationContext() { + // The policy has to survive Runner construction: a Runner left holding the default lets an + // unauthenticated peer approve a tool call the operator meant to gate. + BasePlugin capturingPlugin = mockPlugin("capturing"); + ArgumentCaptor contextCaptor = + ArgumentCaptor.forClass(InvocationContext.class); + when(capturingPlugin.beforeRunCallback(contextCaptor.capture())).thenReturn(Maybe.empty()); + Runner appRunner = + Runner.builder() + .app( + App.builder() + .name("test") + .rootAgent( + createTestAgentBuilder(createTestLlm(createTextLlmResponse("done"))) + .build()) + .plugins(ImmutableList.of(capturingPlugin)) + .confirmationApprover(ConfirmationApprover.AUTHENTICATED_ONLY) + .build()) + .build(); + Session appSession = appRunner.sessionService().createSession("test", "user").blockingGet(); + + var unused = + appRunner.runAsync("user", appSession.id(), createContent("hello")).toList().blockingGet(); + + assertThat(contextCaptor.getValue().confirmationApprover()) + .isEqualTo(ConfirmationApprover.AUTHENTICATED_ONLY); + } + // ResumabilityConfig is off by default and reflects the configured value. @Test @SuppressWarnings("deprecation") // ResumabilityConfig is intentionally deprecated (partial).