From 189d463a9988d5235ccf34de6a6fab1aae779d20 Mon Sep 17 00:00:00 2001 From: Damian Momot Date: Fri, 9 Oct 2026 01:05:48 -0700 Subject: [PATCH] feat: add the experimental Node and BaseNode workflow types PiperOrigin-RevId: 996360896 --- .../com/google/adk/workflow/BaseNode.java | 139 +++++++++++ .../java/com/google/adk/workflow/Node.java | 107 +++++++++ .../com/google/adk/workflow/NodeConfig.java | 70 ++++++ .../com/google/adk/workflow/RetryConfig.java | 135 +++++++++++ .../com/google/adk/workflow/BaseNodeTest.java | 220 ++++++++++++++++++ .../google/adk/workflow/NodeConfigTest.java | 73 ++++++ .../com/google/adk/workflow/NodeTest.java | 55 +++++ .../google/adk/workflow/RetryConfigTest.java | 145 ++++++++++++ 8 files changed, 944 insertions(+) create mode 100644 core/src/main/java/com/google/adk/workflow/BaseNode.java create mode 100644 core/src/main/java/com/google/adk/workflow/Node.java create mode 100644 core/src/main/java/com/google/adk/workflow/NodeConfig.java create mode 100644 core/src/main/java/com/google/adk/workflow/RetryConfig.java create mode 100644 core/src/test/java/com/google/adk/workflow/BaseNodeTest.java create mode 100644 core/src/test/java/com/google/adk/workflow/NodeConfigTest.java create mode 100644 core/src/test/java/com/google/adk/workflow/NodeTest.java create mode 100644 core/src/test/java/com/google/adk/workflow/RetryConfigTest.java diff --git a/core/src/main/java/com/google/adk/workflow/BaseNode.java b/core/src/main/java/com/google/adk/workflow/BaseNode.java new file mode 100644 index 000000000..0c3282d32 --- /dev/null +++ b/core/src/main/java/com/google/adk/workflow/BaseNode.java @@ -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 com.google.adk.workflow; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.adk.agents.Context; +import com.google.adk.agents.InvocationContext; +import com.google.adk.annotations.Experimental; +import com.google.adk.events.Event; +import com.google.genai.types.Schema; +import io.reactivex.rxjava3.core.Flowable; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * Base class for nodes in a workflow graph. It is meant for the framework's own nodes; to write a + * node, implement {@link Node}. Subclasses implement {@link #runNode}, and the framework turns each + * emission into an {@link Event}. + */ +@Experimental +public abstract class BaseNode implements Node { + + private final String name; + private final String description; + private final boolean rerunOnResume; + private final boolean waitForOutput; + private final NodeConfig config; + private final @Nullable Schema inputSchema; + private final @Nullable Schema outputSchema; + private final @Nullable Schema stateSchema; + + /** Creates a node with every property other than its name and description at its default. */ + protected BaseNode(String name, String description) { + this( + name, + description, + /* rerunOnResume= */ false, + /* waitForOutput= */ false, + NodeConfig.builder().build(), + /* inputSchema= */ null, + /* outputSchema= */ null, + /* stateSchema= */ null); + } + + /** Creates a node; each parameter sets the {@link Node} property of the same name. */ + protected BaseNode( + String name, + String description, + boolean rerunOnResume, + boolean waitForOutput, + NodeConfig config, + @Nullable Schema inputSchema, + @Nullable Schema outputSchema, + @Nullable Schema stateSchema) { + this.name = checkNotNull(name); + this.description = checkNotNull(description); + this.rerunOnResume = rerunOnResume; + this.waitForOutput = waitForOutput; + this.config = checkNotNull(config); + this.inputSchema = inputSchema; + this.outputSchema = outputSchema; + this.stateSchema = stateSchema; + } + + @Override + public final String name() { + return name; + } + + @Override + public String description() { + return description; + } + + @Override + public boolean rerunOnResume() { + return rerunOnResume; + } + + @Override + public boolean waitForOutput() { + return waitForOutput; + } + + @Override + public NodeConfig config() { + return config; + } + + @Override + public Optional inputSchema() { + return Optional.ofNullable(inputSchema); + } + + @Override + public Optional outputSchema() { + return Optional.ofNullable(outputSchema); + } + + @Override + public Optional stateSchema() { + return Optional.ofNullable(stateSchema); + } + + /** + * Runs the node and emits its events. Each emission of {@link #runNode} that is an {@link Event} + * passes through, and any other value becomes the output of a new event. + */ + final Flowable run(Context context, @Nullable Object nodeInput) { + InvocationContext invocationContext = checkNotNull(context).invocationContext(); + // The node runner fills in the author, as only it knows the node's place in the graph. + return Flowable.defer(() -> runNode(context, nodeInput)) + .map( + item -> + item instanceof Event event + ? event + : Event.builder() + .id(invocationContext.newUuid()) + .timestamp(invocationContext.now().toEpochMilli()) + .author("") + .output(item) + .build()); + } +} diff --git a/core/src/main/java/com/google/adk/workflow/Node.java b/core/src/main/java/com/google/adk/workflow/Node.java new file mode 100644 index 000000000..2fdb4ef56 --- /dev/null +++ b/core/src/main/java/com/google/adk/workflow/Node.java @@ -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 com.google.adk.workflow; + +import com.google.adk.agents.Context; +import com.google.adk.annotations.Experimental; +import com.google.adk.events.Event; +import com.google.genai.types.Schema; +import io.reactivex.rxjava3.core.Flowable; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * A unit of work in a workflow graph. Node names must be unique within a graph. Every member except + * {@link #name} and {@link #description} is experimental, so implementing this interface is too. + */ +public interface Node { + + /** Returns the name that identifies the node within its graph. */ + String name(); + + /** Returns what the node does, for humans and for a model that may call it. */ + default String description() { + return ""; + } + + /** + * Returns {@code true} if a resumed node runs again from scratch, rather than completing with the + * resuming answer as its output. + */ + @Experimental + default boolean rerunOnResume() { + return false; + } + + /** + * Returns {@code true} if the node stays re-triggerable until it produces an output or a route, + * instead of completing when its run completes. + */ + @Experimental + default boolean waitForOutput() { + return false; + } + + /** Returns the node's retry policy and execution timeout. */ + @Experimental + default NodeConfig config() { + return NodeConfig.builder().build(); + } + + /** Returns the schema the node's input must match. */ + @Experimental + default Optional inputSchema() { + return Optional.empty(); + } + + /** + * Returns the schema that the node's output value must match. The content of a message-as-output + * event is not checked against this schema. + */ + @Experimental + default Optional outputSchema() { + return Optional.empty(); + } + + /** + * Returns the schema declaring the state keys the node uses. Child nodes inherit it unless they + * declare their own. + */ + @Experimental + default Optional stateSchema() { + return Optional.empty(); + } + + /** + * Returns {@code true} if the node runs only once every predecessor has completed, receiving all + * their outputs keyed by node name, as a fan-in node does. + */ + @Experimental + default boolean requiresAllPredecessors() { + return false; + } + + /** + * Runs the node on {@code nodeInput}, the output it receives from its predecessors. + * + *

An emitted {@link Event} passes through, and any other emitted value becomes the node's + * output (at most one per run); emit nothing for no output, since RxJava forbids null. The + * wildcard lets an implementation return a {@code Flowable} of its own type. + */ + @Experimental + Flowable runNode(Context context, @Nullable Object nodeInput); +} diff --git a/core/src/main/java/com/google/adk/workflow/NodeConfig.java b/core/src/main/java/com/google/adk/workflow/NodeConfig.java new file mode 100644 index 000000000..b397442f0 --- /dev/null +++ b/core/src/main/java/com/google/adk/workflow/NodeConfig.java @@ -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 com.google.adk.workflow; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.adk.annotations.Experimental; +import com.google.auto.value.AutoValue; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.time.Duration; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** A node's retry policy and execution timeout. */ +@Experimental +@AutoValue +public abstract class NodeConfig { + + /** Returns how the node is retried when it fails; empty does not retry. */ + public abstract Optional retryConfig(); + + /** + * Returns how long an attempt may run before it is cancelled and treated as a failure; empty + * imposes no limit. + */ + public abstract Optional timeout(); + + public static Builder builder() { + return new AutoValue_NodeConfig.Builder(); + } + + public abstract Builder toBuilder(); + + /** Builder for {@link NodeConfig}. */ + @AutoValue.Builder + public abstract static class Builder { + + @CanIgnoreReturnValue + public abstract Builder retryConfig(@Nullable RetryConfig retryConfig); + + /** Sets the timeout, which must be positive; null imposes no limit. */ + @CanIgnoreReturnValue + public abstract Builder timeout(@Nullable Duration timeout); + + abstract NodeConfig autoBuild(); + + public final NodeConfig build() { + NodeConfig config = autoBuild(); + // A zero or negative timeout would fire at once. + checkArgument( + config.timeout().map(timeout -> timeout.compareTo(Duration.ZERO) > 0).orElse(true), + "timeout must be positive, or null for no timeout."); + return config; + } + } +} diff --git a/core/src/main/java/com/google/adk/workflow/RetryConfig.java b/core/src/main/java/com/google/adk/workflow/RetryConfig.java new file mode 100644 index 000000000..ce79fa622 --- /dev/null +++ b/core/src/main/java/com/google/adk/workflow/RetryConfig.java @@ -0,0 +1,135 @@ +/* + * 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.workflow; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.adk.annotations.Experimental; +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * How a node is retried when it fails. The builder initializes each property to its {@code + * DEFAULT_} constant, except {@link #exceptions}, which starts unset. + */ +@Experimental +@AutoValue +public abstract class RetryConfig { + + /** The default for {@link #maxAttempts}. */ + public static final int DEFAULT_MAX_ATTEMPTS = 5; + + /** The default for {@link #initialDelay}. */ + public static final Duration DEFAULT_INITIAL_DELAY = Duration.ofSeconds(1); + + /** The default for {@link #maxDelay}. */ + public static final Duration DEFAULT_MAX_DELAY = Duration.ofSeconds(60); + + /** The default for {@link #backoffFactor}. */ + public static final double DEFAULT_BACKOFF_FACTOR = 2.0; + + /** The default for {@link #jitter}. */ + public static final double DEFAULT_JITTER = 1.0; + + /** Returns the maximum number of attempts, including the first, so 0 or 1 means no retry. */ + public abstract int maxAttempts(); + + /** Returns the delay before the first retry. */ + public abstract Duration initialDelay(); + + /** Returns the ceiling on any single delay. */ + public abstract Duration maxDelay(); + + /** Returns the multiplier applied to the delay after each attempt. */ + public abstract double backoffFactor(); + + /** + * Returns the randomness factor, not a duration: the delay is spread over {@code delay * (1 +/- + * jitter)}. Zero removes randomness. + */ + public abstract double jitter(); + + /** + * Returns the simple type names of the failures to retry on, matched exactly with no subclass + * match. When unset, any failure is retried; an empty list retries none. + */ + public abstract Optional> exceptions(); + + public static Builder builder() { + return new AutoValue_RetryConfig.Builder() + .maxAttempts(DEFAULT_MAX_ATTEMPTS) + .initialDelay(DEFAULT_INITIAL_DELAY) + .maxDelay(DEFAULT_MAX_DELAY) + .backoffFactor(DEFAULT_BACKOFF_FACTOR) + .jitter(DEFAULT_JITTER); + } + + public abstract Builder toBuilder(); + + /** Builder for {@link RetryConfig}. */ + @AutoValue.Builder + public abstract static class Builder { + + @CanIgnoreReturnValue + public abstract Builder maxAttempts(int maxAttempts); + + @CanIgnoreReturnValue + public abstract Builder initialDelay(Duration initialDelay); + + @CanIgnoreReturnValue + public abstract Builder maxDelay(Duration maxDelay); + + @CanIgnoreReturnValue + public abstract Builder backoffFactor(double backoffFactor); + + @CanIgnoreReturnValue + public abstract Builder jitter(double jitter); + + @CanIgnoreReturnValue + abstract Builder exceptions(@Nullable ImmutableList exceptions); + + @CanIgnoreReturnValue + public Builder exceptions(@Nullable List exceptions) { + return exceptions(exceptions == null ? null : ImmutableList.copyOf(exceptions)); + } + + abstract RetryConfig autoBuild(); + + public final RetryConfig build() { + RetryConfig config = autoBuild(); + checkArgument( + config.maxAttempts() >= 0, "maxAttempts must not be negative; 0 or 1 means no retry."); + checkArgument(!config.initialDelay().isNegative(), "initialDelay must not be negative."); + checkArgument(!config.maxDelay().isNegative(), "maxDelay must not be negative."); + checkArgument( + isFiniteAndNotNegative(config.backoffFactor()), + "backoffFactor must be finite and not negative."); + checkArgument( + isFiniteAndNotNegative(config.jitter()), "jitter must be finite and not negative."); + return config; + } + + private static boolean isFiniteAndNotNegative(double value) { + return Double.isFinite(value) && value >= 0.0; + } + } +} diff --git a/core/src/test/java/com/google/adk/workflow/BaseNodeTest.java b/core/src/test/java/com/google/adk/workflow/BaseNodeTest.java new file mode 100644 index 000000000..09d28da48 --- /dev/null +++ b/core/src/test/java/com/google/adk/workflow/BaseNodeTest.java @@ -0,0 +1,220 @@ +/* + * 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.workflow; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.adk.agents.CallbackContext; +import com.google.adk.agents.Context; +import com.google.adk.agents.InvocationContext; +import com.google.adk.events.Event; +import com.google.adk.testing.TestUtils; +import com.google.genai.types.Schema; +import io.reactivex.rxjava3.core.Flowable; +import java.time.Duration; +import java.time.Instant; +import java.time.InstantSource; +import java.util.List; +import org.jspecify.annotations.Nullable; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class BaseNodeTest { + + @Test + public void constructor_setsEveryProperty() { + NodeConfig config = NodeConfig.builder().timeout(Duration.ofSeconds(5)).build(); + Schema inputSchema = Schema.builder().description("input").build(); + Schema outputSchema = Schema.builder().description("output").build(); + Schema stateSchema = Schema.builder().description("state").build(); + + BaseNode node = + new TestNode( + "summarize", + "Summarizes its input.", + /* rerunOnResume= */ true, + /* waitForOutput= */ true, + config, + inputSchema, + outputSchema, + stateSchema); + + assertThat(node.name()).isEqualTo("summarize"); + assertThat(node.description()).isEqualTo("Summarizes its input."); + assertThat(node.rerunOnResume()).isTrue(); + assertThat(node.waitForOutput()).isTrue(); + assertThat(node.config()).isEqualTo(config); + assertThat(node.inputSchema()).hasValue(inputSchema); + assertThat(node.outputSchema()).hasValue(outputSchema); + assertThat(node.stateSchema()).hasValue(stateSchema); + } + + @Test + public void constructor_withNameAndDescription_leavesTheRestAtDefaults() { + BaseNode node = new TestNode("summarize", "Summarizes its input.", Flowable.empty()); + + assertThat(node.name()).isEqualTo("summarize"); + assertThat(node.description()).isEqualTo("Summarizes its input."); + assertThat(node.rerunOnResume()).isFalse(); + assertThat(node.waitForOutput()).isFalse(); + assertThat(node.config()).isEqualTo(NodeConfig.builder().build()); + assertThat(node.inputSchema()).isEmpty(); + assertThat(node.outputSchema()).isEmpty(); + assertThat(node.stateSchema()).isEmpty(); + } + + @Test + public void constructor_rejectsANullName() { + assertThrows(NullPointerException.class, () -> new TestNode(null, "", Flowable.empty())); + } + + @Test + public void constructor_rejectsANullDescription() { + assertThrows(NullPointerException.class, () -> new TestNode("node", null, Flowable.empty())); + } + + @Test + public void constructor_rejectsANullConfig() { + assertThrows( + NullPointerException.class, + () -> + new TestNode( + "node", + "", + /* rerunOnResume= */ false, + /* waitForOutput= */ false, + /* config= */ null, + /* inputSchema= */ null, + /* outputSchema= */ null, + /* stateSchema= */ null)); + } + + @Test + public void run_passesEventsThrough() { + Event event = Event.builder().id("event_id").author("agent").build(); + BaseNode node = new TestNode("node", "", Flowable.just(event)); + + List events = node.run(context(), /* nodeInput= */ null).toList().blockingGet(); + + assertThat(events).hasSize(1); + assertThat(events.get(0)).isSameInstanceAs(event); + } + + @Test + public void run_turnsANonEventValueIntoAnOutputEvent() { + BaseNode node = new TestNode("node", "", Flowable.just("text")); + + Event event = node.run(context(), /* nodeInput= */ null).blockingSingle(); + + assertThat(event.output()).hasValue("text"); + assertThat(event.author()).isEmpty(); + } + + @Test + public void run_takesOutputEventIdAndTimestampFromTheContext() { + InvocationContext invocationContext = + invocationContext().toBuilder() + .uuidProvider(() -> "event_id") + .instantSource(InstantSource.fixed(Instant.ofEpochMilli(1234))) + .build(); + BaseNode node = new TestNode("node", "", Flowable.just("text")); + + Event event = node.run(context(invocationContext), /* nodeInput= */ null).blockingSingle(); + + assertThat(event.id()).isEqualTo("event_id"); + assertThat(event.timestamp()).isEqualTo(1234); + } + + @Test + public void run_emitsNothingWhenRunNodeEmitsNothing() { + BaseNode node = new TestNode("node", "", Flowable.empty()); + + assertThat(node.run(context(), /* nodeInput= */ null).toList().blockingGet()).isEmpty(); + } + + @Test + public void run_deliversAnExceptionThrownByRunNodeAsAnError() { + BaseNode node = + new BaseNode("node", "") { + @Override + public Flowable runNode(Context context, @Nullable Object nodeInput) { + throw new IllegalStateException("runNode failed"); + } + }; + + Flowable events = node.run(context(), /* nodeInput= */ null); + + events.test().assertError(IllegalStateException.class); + } + + @Test + public void run_rejectsANullContext() { + BaseNode node = new TestNode("node", "", Flowable.empty()); + + assertThrows(NullPointerException.class, () -> node.run(null, /* nodeInput= */ null)); + } + + private static Context context() { + return context(invocationContext()); + } + + private static Context context(InvocationContext invocationContext) { + return new CallbackContext(invocationContext, /* eventActions= */ null); + } + + private static InvocationContext invocationContext() { + return TestUtils.createInvocationContext(TestUtils.createRootAgent()); + } + + private static final class TestNode extends BaseNode { + private final Flowable emissions; + + TestNode(String name, String description, Flowable emissions) { + super(name, description); + this.emissions = emissions; + } + + TestNode( + String name, + String description, + boolean rerunOnResume, + boolean waitForOutput, + NodeConfig config, + @Nullable Schema inputSchema, + @Nullable Schema outputSchema, + @Nullable Schema stateSchema) { + super( + name, + description, + rerunOnResume, + waitForOutput, + config, + inputSchema, + outputSchema, + stateSchema); + this.emissions = Flowable.empty(); + } + + @Override + public Flowable runNode(Context context, @Nullable Object nodeInput) { + return emissions; + } + } +} diff --git a/core/src/test/java/com/google/adk/workflow/NodeConfigTest.java b/core/src/test/java/com/google/adk/workflow/NodeConfigTest.java new file mode 100644 index 000000000..30c1e34e9 --- /dev/null +++ b/core/src/test/java/com/google/adk/workflow/NodeConfigTest.java @@ -0,0 +1,73 @@ +/* + * 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.workflow; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import java.time.Duration; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class NodeConfigTest { + + @Test + public void builder_defaultsToNoRetryAndNoTimeout() { + NodeConfig config = NodeConfig.builder().build(); + + assertThat(config.retryConfig()).isEmpty(); + assertThat(config.timeout()).isEmpty(); + } + + @Test + public void toBuilder_copiesEveryProperty() { + NodeConfig config = + NodeConfig.builder() + .retryConfig(RetryConfig.builder().maxAttempts(3).build()) + .timeout(Duration.ofMillis(1500)) + .build(); + + assertThat(config.toBuilder().build()).isEqualTo(config); + } + + @Test + public void timeout_nullClearsIt() { + NodeConfig config = NodeConfig.builder().timeout(Duration.ofSeconds(1)).build(); + + assertThat(config.toBuilder().timeout(null).build().timeout()).isEmpty(); + } + + @Test + public void build_rejectsAZeroTimeout() { + NodeConfig.Builder builder = NodeConfig.builder().timeout(Duration.ZERO); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(e).hasMessageThat().contains("timeout must be positive"); + } + + @Test + public void build_rejectsANegativeTimeout() { + NodeConfig.Builder builder = NodeConfig.builder().timeout(Duration.ofSeconds(-1)); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(e).hasMessageThat().contains("timeout must be positive"); + } +} diff --git a/core/src/test/java/com/google/adk/workflow/NodeTest.java b/core/src/test/java/com/google/adk/workflow/NodeTest.java new file mode 100644 index 000000000..61b131778 --- /dev/null +++ b/core/src/test/java/com/google/adk/workflow/NodeTest.java @@ -0,0 +1,55 @@ +/* + * 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.workflow; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.adk.agents.Context; +import io.reactivex.rxjava3.core.Flowable; +import org.jspecify.annotations.Nullable; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class NodeTest { + + @Test + public void defaultMethods_returnDefaults() { + Node node = + new Node() { + @Override + public String name() { + return "bare"; + } + + @Override + public Flowable runNode(Context context, @Nullable Object nodeInput) { + return Flowable.empty(); + } + }; + + assertThat(node.description()).isEmpty(); + assertThat(node.rerunOnResume()).isFalse(); + assertThat(node.waitForOutput()).isFalse(); + assertThat(node.config()).isEqualTo(NodeConfig.builder().build()); + assertThat(node.inputSchema()).isEmpty(); + assertThat(node.outputSchema()).isEmpty(); + assertThat(node.stateSchema()).isEmpty(); + assertThat(node.requiresAllPredecessors()).isFalse(); + } +} diff --git a/core/src/test/java/com/google/adk/workflow/RetryConfigTest.java b/core/src/test/java/com/google/adk/workflow/RetryConfigTest.java new file mode 100644 index 000000000..99b03b298 --- /dev/null +++ b/core/src/test/java/com/google/adk/workflow/RetryConfigTest.java @@ -0,0 +1,145 @@ +/* + * 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.workflow; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class RetryConfigTest { + + @Test + public void builder_startsAtTheDefaultsWithExceptionsUnset() { + RetryConfig config = RetryConfig.builder().build(); + + assertThat(config.maxAttempts()).isEqualTo(RetryConfig.DEFAULT_MAX_ATTEMPTS); + assertThat(config.initialDelay()).isEqualTo(RetryConfig.DEFAULT_INITIAL_DELAY); + assertThat(config.maxDelay()).isEqualTo(RetryConfig.DEFAULT_MAX_DELAY); + assertThat(config.backoffFactor()).isEqualTo(RetryConfig.DEFAULT_BACKOFF_FACTOR); + assertThat(config.jitter()).isEqualTo(RetryConfig.DEFAULT_JITTER); + assertThat(config.exceptions()).isEmpty(); + } + + @Test + public void toBuilder_copiesEveryProperty() { + RetryConfig config = + RetryConfig.builder() + .maxAttempts(3) + .initialDelay(Duration.ofMillis(10)) + .maxDelay(Duration.ofSeconds(2)) + .backoffFactor(1.5) + .jitter(0.5) + .exceptions(ImmutableList.of("TimeoutException")) + .build(); + + assertThat(config.toBuilder().build()).isEqualTo(config); + } + + @Test + public void exceptions_copiesAMutableList() { + List exceptions = new ArrayList<>(); + exceptions.add("TimeoutException"); + + RetryConfig config = RetryConfig.builder().exceptions(exceptions).build(); + exceptions.add("IOException"); + + assertThat(config.exceptions()).hasValue(ImmutableList.of("TimeoutException")); + } + + @Test + public void exceptions_emptyListIsDistinctFromUnset() { + RetryConfig config = RetryConfig.builder().exceptions(ImmutableList.of()).build(); + + assertThat(config.exceptions()).hasValue(ImmutableList.of()); + } + + @Test + public void build_acceptsZeroValues() { + RetryConfig config = + RetryConfig.builder() + .maxAttempts(0) + .initialDelay(Duration.ZERO) + .maxDelay(Duration.ZERO) + .backoffFactor(0.0) + .jitter(0.0) + .build(); + + assertThat(config.maxAttempts()).isEqualTo(0); + } + + @Test + public void build_rejectsNegativeMaxAttempts() { + RetryConfig.Builder builder = RetryConfig.builder().maxAttempts(-1); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(e).hasMessageThat().contains("maxAttempts"); + } + + @Test + public void build_rejectsNegativeInitialDelay() { + RetryConfig.Builder builder = RetryConfig.builder().initialDelay(Duration.ofMillis(-1)); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(e).hasMessageThat().contains("initialDelay"); + } + + @Test + public void build_rejectsNegativeMaxDelay() { + RetryConfig.Builder builder = RetryConfig.builder().maxDelay(Duration.ofMillis(-1)); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(e).hasMessageThat().contains("maxDelay"); + } + + @Test + public void build_rejectsNegativeBackoffFactor() { + RetryConfig.Builder builder = RetryConfig.builder().backoffFactor(-0.5); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(e).hasMessageThat().contains("backoffFactor"); + } + + @Test + public void build_rejectsInfiniteBackoffFactor() { + RetryConfig.Builder builder = RetryConfig.builder().backoffFactor(Double.POSITIVE_INFINITY); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(e).hasMessageThat().contains("backoffFactor"); + } + + @Test + public void build_rejectsNaNJitter() { + RetryConfig.Builder builder = RetryConfig.builder().jitter(Double.NaN); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(e).hasMessageThat().contains("jitter"); + } +}