Skip to content

Commit 367b402

Browse files
damianmomotgooglecopybara-github
authored andcommitted
fix: enforce node retry policies and timeouts
- Add `Context.attemptCount()`, which tells a workflow node which attempt it is on. - When a failed child workflow runs again under the same run ID, rerun its nodes that failed because their own child failed. PiperOrigin-RevId: 1008119395
1 parent 08ca701 commit 367b402

15 files changed

Lines changed: 1215 additions & 74 deletions

‎core/src/main/java/com/google/adk/agents/Context.java‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,17 @@ public final void routes(List<? extends Route> routes) {
334334
return requireNodeState().resumeInputs();
335335
}
336336

337+
/**
338+
* Returns the number of the attempt this workflow node is on, counting from 1; each retry adds
339+
* one.
340+
*
341+
* @throws IllegalStateException if this is not the context of a workflow node.
342+
*/
343+
@Experimental
344+
public final int attemptCount() {
345+
return requireNodeState().attemptCount();
346+
}
347+
337348
/**
338349
* Runs {@code node} as a child of this workflow node without an input, as {@link #runNode(Node,
339350
* Object, RunNodeConfig)} does with the default {@link RunNodeConfig}.

‎core/src/main/java/com/google/adk/agents/InvocationContext.java‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,9 +314,10 @@ public String userId() {
314314
/**
315315
* Returns the {@link Scheduler} on which this invocation runs the work ADK hands to worker
316316
* threads: the sub-agents of a {@code ParallelAgent}, tool calls in {@code
317-
* ToolExecutionMode.PARALLEL_SUBSCRIBE} mode, and the hop that starts the live send loop. An
318-
* agent-level setting ({@code ParallelAgent.Builder#scheduler}, {@code
319-
* LlmAgent.Builder#executor}) takes precedence over it. Defaults to {@link Schedulers#io()}.
317+
* ToolExecutionMode.PARALLEL_SUBSCRIBE} mode, workflow node retries and attempts with a timeout,
318+
* and the hop that starts the live send loop. An agent-level setting ({@code
319+
* ParallelAgent.Builder#scheduler}, {@code LlmAgent.Builder#executor}) takes precedence over it.
320+
* Defaults to {@link Schedulers#io()}.
320321
*/
321322
public Scheduler scheduler() {
322323
return scheduler != null ? scheduler : Schedulers.io();

‎core/src/main/java/com/google/adk/runner/Runner.java‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,11 @@ public Builder plugins(Plugin... plugins) {
184184
/**
185185
* Sets the {@link Scheduler} for the work ADK hands off to worker threads in this runner's
186186
* invocations: the sub-agents of a {@code ParallelAgent}, tool calls in {@link
187-
* RunConfig.ToolExecutionMode#PARALLEL_SUBSCRIBE} mode, and the hop that starts the live send
188-
* loop. The runs that an {@code AgentTool} nests inherit it. An agent-level {@code
189-
* ParallelAgent.Builder#scheduler} or {@code LlmAgent.Builder#executor} takes precedence.
190-
* {@code null} (the default) selects {@code Schedulers.io()}.
187+
* RunConfig.ToolExecutionMode#PARALLEL_SUBSCRIBE} mode, workflow node retries and attempts with
188+
* a timeout, and the hop that starts the live send loop. The runs that an {@code AgentTool}
189+
* nests inherit it. An agent-level {@code ParallelAgent.Builder#scheduler} or {@code
190+
* LlmAgent.Builder#executor} takes precedence. {@code null} (the default) selects {@code
191+
* Schedulers.io()}.
191192
*
192193
* <p>Only these hand-offs are affected; work that completes asynchronously elsewhere (model
193194
* clients, session services, MCP, plugins) stays on its own threads. {@code

‎core/src/main/java/com/google/adk/workflow/NodeConfig.java‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,31 @@
1818

1919
import static com.google.common.base.Preconditions.checkArgument;
2020

21+
import com.google.adk.agents.Context;
2122
import com.google.adk.annotations.Experimental;
2223
import com.google.auto.value.AutoValue;
2324
import com.google.errorprone.annotations.CanIgnoreReturnValue;
2425
import java.time.Duration;
2526
import java.util.Optional;
27+
import java.util.concurrent.TimeoutException;
2628
import org.jspecify.annotations.Nullable;
2729

2830
/** A node's retry policy and execution timeout. */
2931
@Experimental
3032
@AutoValue
3133
public abstract class NodeConfig {
3234

33-
/** Returns how the node is retried when it fails; empty does not retry. */
35+
/**
36+
* Returns how the node is retried when it fails; empty does not retry. A retried workflow replays
37+
* its nodes that finished and left an event in the session, and reruns the others. A failure that
38+
* a node passes on unchanged from a child it runs with {@link Context#runNode} is not retried;
39+
* give the child a retry policy of its own instead.
40+
*/
3441
public abstract Optional<RetryConfig> retryConfig();
3542

3643
/**
37-
* Returns how long an attempt may run before it is cancelled and treated as a failure; empty
38-
* imposes no limit.
44+
* Returns how long an attempt may run before it is canceled and fails with a {@link
45+
* TimeoutException}; empty imposes no limit.
3946
*/
4047
public abstract Optional<Duration> timeout();
4148

‎core/src/main/java/com/google/adk/workflow/NodeExecutionState.java‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,16 @@ public final class NodeExecutionState {
6161
// The parent's state when this run's output is also the parent's.
6262
private final @Nullable NodeExecutionState outputParent;
6363
private final String runId;
64+
private final int attemptCount;
6465
private final String nodePath;
6566
private final ImmutableList<String> outputFor;
6667
private final Map<String, @Nullable Object> resumeInputs;
67-
private final ConcurrentMap<String, Integer> childRunCounts = new ConcurrentHashMap<>();
68-
private final ConcurrentMap<String, SingleSubject<Context>> childRuns = new ConcurrentHashMap<>();
69-
// The paths of the node runs started but not finished in this turn, shared under one root.
68+
private final ConcurrentMap<String, Integer> childRunCounts;
69+
private final ConcurrentMap<String, SingleSubject<Context>> childRuns;
70+
// Paths of runs started but not finished in this turn, shared under one root and its attempts.
7071
private final Set<String> unfinishedNodePaths;
72+
// Paths of runs that failed this turn and have not finished cleanly since, shared the same way.
73+
private final Set<String> failedNodePaths;
7174
private final Set<String> interruptIds = new CopyOnWriteArraySet<>();
7275
private final AtomicReference<OutputState> outputState = new AtomicReference<>(NO_OUTPUT);
7376
// Set once the node drops its own output because a useAsOutput child holds the output.
@@ -100,9 +103,19 @@ private NodeExecutionState(Builder builder) {
100103
? ImmutableList.<String>builder().add(nodePath).addAll(outputParent.outputFor).build()
101104
: ImmutableList.of(nodePath);
102105
this.eventAuthor = parentState == null ? "" : parentState.eventAuthor;
106+
NodeExecutionState previousAttempt = builder.previousAttempt;
107+
this.attemptCount = previousAttempt == null ? 1 : previousAttempt.attemptCount + 1;
108+
NodeExecutionState turnRecords = parentState != null ? parentState : previousAttempt;
103109
this.unfinishedNodePaths =
104-
parentState == null ? ConcurrentHashMap.newKeySet() : parentState.unfinishedNodePaths;
110+
turnRecords == null ? ConcurrentHashMap.newKeySet() : turnRecords.unfinishedNodePaths;
111+
this.failedNodePaths =
112+
turnRecords == null ? ConcurrentHashMap.newKeySet() : turnRecords.failedNodePaths;
105113
this.resumeInputs = Collections.unmodifiableMap(new LinkedHashMap<>(builder.resumeInputs));
114+
// A retried workflow restarts its node numbering, so its finished nodes replay.
115+
boolean continuesChildRuns = previousAttempt != null && !(node instanceof Workflow);
116+
this.childRunCounts =
117+
continuesChildRuns ? previousAttempt.childRunCounts : new ConcurrentHashMap<>();
118+
this.childRuns = continuesChildRuns ? previousAttempt.childRuns : new ConcurrentHashMap<>();
106119
}
107120

108121
/** Returns a builder for the state of one node run. */
@@ -128,6 +141,11 @@ public String runId() {
128141
return runId;
129142
}
130143

144+
/** Returns the number of this attempt at running the node, counting from 1. */
145+
public int attemptCount() {
146+
return attemptCount;
147+
}
148+
131149
/** Returns the context of the node that ran this node, or empty if this is a root node. */
132150
public Optional<Context> parent() {
133151
return Optional.ofNullable(parent);
@@ -266,6 +284,11 @@ void markStarted() {
266284
/** Records that this run finished, with or without a result. */
267285
void markFinished() {
268286
unfinishedNodePaths.remove(nodePath);
287+
if (failure == null) {
288+
failedNodePaths.remove(nodePath);
289+
} else {
290+
failedNodePaths.add(nodePath);
291+
}
269292
}
270293

271294
/**
@@ -277,6 +300,14 @@ boolean childCanceledMidRun(String childKey) {
277300
return unfinishedNodePaths.contains(nodePath + "/" + childKey);
278301
}
279302

303+
/**
304+
* Returns whether this run's child {@code childKey} failed in this turn and has not finished
305+
* cleanly since. A child that takes on its own child's failure leaves no error event to show it.
306+
*/
307+
boolean childFailedThisTurn(String childKey) {
308+
return failedNodePaths.contains(nodePath + "/" + childKey);
309+
}
310+
280311
/**
281312
* Returns the states of this node's children from earlier turns, keyed by {@code name@runId},
282313
* which the first call reconstructs from the events of {@code invocationContext}.
@@ -457,6 +488,7 @@ static final class Builder {
457488
private @Nullable String runId;
458489
private boolean useAsOutput;
459490
private Map<String, @Nullable Object> resumeInputs = ImmutableMap.of();
491+
private @Nullable NodeExecutionState previousAttempt;
460492

461493
private Builder() {}
462494

@@ -505,6 +537,17 @@ Builder resumeInputs(Map<String, @Nullable Object> resumeInputs) {
505537
return this;
506538
}
507539

540+
/**
541+
* Sets the state of the run's previous attempt, or null for a first attempt. A later attempt's
542+
* count is one higher. It shares the previous attempt's records of unfinished and failed runs
543+
* and, unless the node is a workflow, its child run IDs and child runs.
544+
*/
545+
@CanIgnoreReturnValue
546+
Builder previousAttempt(@Nullable NodeExecutionState previousAttempt) {
547+
this.previousAttempt = previousAttempt;
548+
return this;
549+
}
550+
508551
/**
509552
* Builds the state.
510553
*

0 commit comments

Comments
 (0)